Radish alpha
r
Radicle CI broker
Radicle
Git (anonymous pull)
Log in to clone via SSH
feat! generate HTML report pages
Lars Wirzenius committed 2 years ago
commit 443bf0b184aa4cd4842d787dd4e8b0874784d5e6
parent d96861408c0dbf59622228e4b795ffe231a449be
15 files changed +889 -221
modified Cargo.lock
@@ -664,6 +664,25 @@ dependencies = [
]

[[package]]
+
name = "html-escape"
+
version = "0.2.13"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476"
+
dependencies = [
+
 "utf8-width",
+
]
+

+
[[package]]
+
name = "html-page"
+
version = "0.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3f8314b0ea57e9e3fc648213a02315e8a16154bb86da7516fec7a09ec4d7417c"
+
dependencies = [
+
 "html-escape",
+
 "line-col",
+
]
+

+
[[package]]
name = "humantime"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -780,6 +799,12 @@ dependencies = [
]

[[package]]
+
name = "line-col"
+
version = "0.2.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9e69cdf6b85b5c8dce514f694089a2cf8b1a702f6cd28607bcb3cf296c9778db"
+

+
[[package]]
name = "linux-raw-sys"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1135,10 +1160,12 @@ dependencies = [
name = "radicle-ci-broker"
version = "0.1.0"
dependencies = [
+
 "html-page",
 "log",
 "pretty_env_logger",
 "radicle",
 "radicle-git-ext",
+
 "radicle-native-ci",
 "radicle-surf",
 "serde",
 "serde_json",
@@ -1150,6 +1177,24 @@ dependencies = [
]

[[package]]
+
name = "radicle-ci-broker"
+
version = "0.1.0"
+
source = "git+https://radicle.liw.fi/zwTxygwuz5LDGBq255RA2CbNGrz8.git?branch=main#d96861408c0dbf59622228e4b795ffe231a449be"
+
dependencies = [
+
 "log",
+
 "pretty_env_logger",
+
 "radicle",
+
 "radicle-git-ext",
+
 "radicle-surf",
+
 "serde",
+
 "serde_json",
+
 "serde_yaml",
+
 "thiserror",
+
 "time",
+
 "uuid",
+
]
+

+
[[package]]
name = "radicle-cob"
version = "0.2.0"
source = "git+https://seed.radicle.xyz/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git?branch=master#292ff01923de86b9139eae538962e98d76178b9a"
@@ -1210,6 +1255,23 @@ dependencies = [
]

[[package]]
+
name = "radicle-native-ci"
+
version = "0.1.0"
+
dependencies = [
+
 "html-page",
+
 "radicle",
+
 "radicle-ci-broker 0.1.0 (git+https://radicle.liw.fi/zwTxygwuz5LDGBq255RA2CbNGrz8.git?branch=main)",
+
 "radicle-git-ext",
+
 "serde",
+
 "serde_yaml",
+
 "tempfile",
+
 "thiserror",
+
 "time",
+
 "uuid",
+
 "walkdir",
+
]
+

+
[[package]]
name = "radicle-ssh"
version = "0.2.0"
source = "git+https://seed.radicle.xyz/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git?branch=master#292ff01923de86b9139eae538962e98d76178b9a"
@@ -1363,6 +1425,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c"

[[package]]
+
name = "same-file"
+
version = "1.0.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+
dependencies = [
+
 "winapi-util",
+
]
+

+
[[package]]
name = "sec1"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1756,6 +1827,12 @@ dependencies = [
]

[[package]]
+
name = "utf8-width"
+
version = "0.1.7"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3"
+

+
[[package]]
name = "uuid"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1777,6 +1854,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"

[[package]]
+
name = "walkdir"
+
version = "2.4.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d71d857dc86794ca4c280d616f7da00d2dbfd8cd788846559a6813e6aa4b54ee"
+
dependencies = [
+
 "same-file",
+
 "winapi-util",
+
]
+

+
[[package]]
name = "wasi"
version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
modified Cargo.toml
@@ -15,11 +15,15 @@ thiserror = "1.0.50"
radicle-surf = { version = "0.18.0", default-features = false, features = ["serde"] }
uuid = { version = "1.7.0", features = ["v4"] }
time = { version = "0.3.34", features = ["formatting", "macros"] }
+
html-page = "0.1.0"

[dependencies.radicle]
git = "https://seed.radicle.xyz/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git"
branch = "master"
features = ["default", "test"]

+
[dependencies.radicle-native-ci]
+
path = "../radicle-native-ci"
+

[dev-dependencies]
tempfile = { version = "3.9.0" }
modified src/adapter.rs
@@ -18,8 +18,8 @@ use std::{

use crate::{
    msg::{MessageError, Request, Response},
+
    pages::StatusPage,
    run::{Run, RunState},
-
    status::Status,
};

/// An external executable that runs CI on request.
@@ -52,13 +52,13 @@ impl Adapter {
        &self,
        trigger: &Request,
        run: &mut Run,
-
        status: &mut Status,
+
        status: &mut StatusPage,
    ) -> Result<(), AdapterError> {
        run.set_state(RunState::Triggered);
-
        status.ci_run(run);
+
        status.push_run(run.clone());
        let x = self.run_helper(trigger, run, status);
        run.set_state(RunState::Finished);
-
        status.ci_run(run);
+
        status.push_run(run.clone());
        x
    }

@@ -66,7 +66,7 @@ impl Adapter {
        &self,
        trigger: &Request,
        run: &mut Run,
-
        status: &mut Status,
+
        status: &mut StatusPage,
    ) -> Result<(), AdapterError> {
        assert!(matches!(trigger, Request::Trigger { .. }));

@@ -99,7 +99,7 @@ impl Adapter {
                Response::Triggered { run_id } => {
                    run.set_state(RunState::Running);
                    run.set_adapter_run_id(run_id);
-
                    status.ci_run(run);
+
                    status.push_run(run.clone());
                }
                _ => return Err(AdapterError::NotTriggered(resp)),
            }
@@ -111,7 +111,7 @@ impl Adapter {
            match resp {
                Response::Finished { result } => {
                    run.set_result(result);
-
                    status.ci_run(run);
+
                    status.push_run(run.clone());
                }
                _ => return Err(AdapterError::NotTriggered(resp)),
            }
@@ -173,18 +173,42 @@ pub enum AdapterError {

#[cfg(test)]
mod test {
-
    use std::{fs::write, io::ErrorKind, path::Path};
+
    use std::{fs::write, io::ErrorKind};

    use tempfile::tempdir;

-
    use super::{Adapter, Run};
+
    use radicle::git::Oid;
+
    use radicle::prelude::RepoId;
+

+
    use super::{Adapter, Run, StatusPage};
    use crate::{
        adapter::AdapterError,
-
        msg::{MessageError, Response, RunResult},
-
        status::Status,
+
        msg::{MessageError, Response, RunId, RunResult},
+
        pages::PageBuilder,
+
        run::Whence,
        test::{mock_adapter, trigger_request, TestResult},
    };

+
    fn run() -> Run {
+
        Run::new(
+
            RunId::default(),
+
            RepoId::from_urn("rad:zwTxygwuz5LDGBq255RA2CbNGrz8").unwrap(),
+
            "test.repo",
+
            Whence::branch(
+
                "main",
+
                Oid::try_from("ff3099ba5de28d954c41d0b5a84316f943794ea4").unwrap(),
+
            ),
+
            "2024-02-29T12:58:12+02:00".into(),
+
        )
+
    }
+

+
    fn status_page() -> StatusPage {
+
        PageBuilder::default()
+
            .node_alias("test.alias")
+
            .build()
+
            .unwrap()
+
    }
+

    #[test]
    fn adapter_reports_success() -> TestResult<()> {
        const ADAPTER: &str = r#"#!/bin/bash
@@ -196,8 +220,8 @@ echo '{"response":"finished","result":"success"}'
        let bin = tmp.path().join("adapter.sh");
        mock_adapter(&bin, ADAPTER)?;

-
        let mut run = Run::default();
-
        let mut status = Status::new(Path::new("/dev/null"));
+
        let mut run = run();
+
        let mut status = status_page();
        Adapter::new(&bin).run(&trigger_request()?, &mut run, &mut status)?;
        assert_eq!(run.result(), Some(&RunResult::Success));

@@ -215,8 +239,8 @@ echo '{"response":"finished","result":"failure"}'
        let bin = tmp.path().join("adapter.sh");
        mock_adapter(&bin, ADAPTER)?;

-
        let mut run = Run::default();
-
        let mut status = Status::new(Path::new("/dev/null"));
+
        let mut run = run();
+
        let mut status = status_page();
        Adapter::new(&bin).run(&trigger_request()?, &mut run, &mut status)?;
        assert_eq!(run.result(), Some(&RunResult::Failure));

@@ -234,8 +258,8 @@ echo '{"response":"finished","result":{"error":"error message\nsecond line"}}'
        let bin = tmp.path().join("adapter.sh");
        mock_adapter(&bin, ADAPTER)?;

-
        let mut run = Run::default();
-
        let mut status = Status::new(Path::new("/dev/null"));
+
        let mut run = run();
+
        let mut status = status_page();
        Adapter::new(&bin).run(&trigger_request()?, &mut run, &mut status)?;
        assert_eq!(
            run.result(),
@@ -255,8 +279,8 @@ kill -9 $BASHPID
        let bin = tmp.path().join("adapter.sh");
        mock_adapter(&bin, ADAPTER)?;

-
        let mut run = Run::default();
-
        let mut status = Status::new(Path::new("/dev/null"));
+
        let mut run = run();
+
        let mut status = status_page();
        let x = Adapter::new(&bin).run(&trigger_request()?, &mut run, &mut status);
        assert!(matches!(x, Err(AdapterError::Failed(_))));

@@ -274,8 +298,8 @@ kill -9 $BASHPID
        let bin = tmp.path().join("adapter.sh");
        mock_adapter(&bin, ADAPTER)?;

-
        let mut run = Run::default();
-
        let mut status = Status::new(Path::new("/dev/null"));
+
        let mut run = run();
+
        let mut status = status_page();
        let x = Adapter::new(&bin).run(&trigger_request()?, &mut run, &mut status);
        assert!(matches!(x, Err(AdapterError::Failed(_))));

@@ -294,8 +318,8 @@ kill -9 $BASHPID
        let bin = tmp.path().join("adapter.sh");
        mock_adapter(&bin, ADAPTER)?;

-
        let mut run = Run::default();
-
        let mut status = Status::new(Path::new("/dev/null"));
+
        let mut run = run();
+
        let mut status = status_page();
        let x = Adapter::new(&bin).run(&trigger_request()?, &mut run, &mut status);
        assert!(matches!(x, Err(AdapterError::Failed(_))));

@@ -313,8 +337,8 @@ echo '{"response":"finished","result":"success","bad":"field"}'
        let bin = tmp.path().join("adapter.sh");
        mock_adapter(&bin, ADAPTER)?;

-
        let mut run = Run::default();
-
        let mut status = Status::new(Path::new("/dev/null"));
+
        let mut run = run();
+
        let mut status = status_page();
        let x = Adapter::new(&bin).run(&trigger_request()?, &mut run, &mut status);
        assert!(matches!(
            x,
@@ -334,8 +358,8 @@ echo '{"response":"finished","result":"success"}'
        let bin = tmp.path().join("adapter.sh");
        mock_adapter(&bin, ADAPTER)?;

-
        let mut run = Run::default();
-
        let mut status = Status::new(Path::new("/dev/null"));
+
        let mut run = run();
+
        let mut status = status_page();
        let x = Adapter::new(&bin).run(&trigger_request()?, &mut run, &mut status);
        assert!(matches!(
            x,
@@ -359,8 +383,8 @@ echo '{"response":"finished","result":"success"}'
        let bin = tmp.path().join("adapter.sh");
        mock_adapter(&bin, ADAPTER)?;

-
        let mut run = Run::default();
-
        let mut status = Status::new(Path::new("/dev/null"));
+
        let mut run = run();
+
        let mut status = status_page();
        let x = Adapter::new(&bin).run(&trigger_request()?, &mut run, &mut status);
        assert!(matches!(
            x,
@@ -377,8 +401,8 @@ echo '{"response":"finished","result":"success"}'
        let tmp = tempdir()?;
        let bin = tmp.path().join("adapter.sh");

-
        let mut run = Run::default();
-
        let mut status = Status::new(Path::new("/dev/null"));
+
        let mut run = run();
+
        let mut status = status_page();
        let x = Adapter::new(&bin).run(&trigger_request()?, &mut run, &mut status);
        match x {
            Err(AdapterError::SpawnAdapter(filename, e)) => {
@@ -402,8 +426,8 @@ echo '{"response":"finished","result":"success"}'
        let bin = tmp.path().join("adapter.sh");
        write(&bin, ADAPTER)?;

-
        let mut run = Run::default();
-
        let mut status = Status::new(Path::new("/dev/null"));
+
        let mut run = run();
+
        let mut status = status_page();
        let x = Adapter::new(&bin).run(&trigger_request()?, &mut run, &mut status);
        match x {
            Err(AdapterError::SpawnAdapter(filename, e)) => {
@@ -431,8 +455,8 @@ echo '{"response":"finished","result":"success"}'
        let bin = tmp.path().join("adapter.sh");
        mock_adapter(&bin, ADAPTER)?;

-
        let mut run = Run::default();
-
        let mut status = Status::new(Path::new("/dev/null"));
+
        let mut run = run();
+
        let mut status = status_page();
        let x = Adapter::new(&bin).run(&trigger_request()?, &mut run, &mut status);
        match x {
            Err(AdapterError::SpawnAdapter(filename, e)) => {
modified src/bin/ci-broker.rs
@@ -1,6 +1,6 @@
use std::{
    error::Error,
-
    path::{Path, PathBuf},
+
    path::PathBuf,
    thread::{sleep, spawn},
    time::Duration,
};
@@ -9,8 +9,13 @@ use log::{debug, info};

use radicle::prelude::Profile;
use radicle_ci_broker::{
-
    adapter::Adapter, broker::Broker, config::Config, error::BrokerError, event::NodeEventSource,
-
    msg::Request, status::Status,
+
    adapter::Adapter,
+
    broker::Broker,
+
    config::Config,
+
    error::BrokerError,
+
    event::NodeEventSource,
+
    msg::Request,
+
    pages::{PageBuilder, StatusPage},
};

fn main() {
@@ -72,29 +77,46 @@ fn fallible_main() -> Result<(), BrokerError> {
    }
    debug!("added filters to node event source");

-
    // Spawn a thread that updates the status page.
-
    let mut status = Status::new(config.status_page().unwrap_or(Path::new("/dev/null")));
-
    let s2 = status.clone();
+
    // Spawn a thread that updates the status pages.
+
    let mut page = PageBuilder::default().node_alias("fixme.alias").build()?;
+
    let page2 = page.clone();
+
    let report_dir = if let Some(dir) = &config.report_dir {
+
        dir.to_path_buf()
+
    } else {
+
        PathBuf::from(".")
+
    };
    let interval = Duration::from_secs(config.status_page_update_interval());
-
    let _status_thread = spawn(move || status_updater(s2, interval));
+
    let status_thread = spawn(move || status_updater(report_dir, page2, interval));
+
    debug!(
+
        "started thread to update status pages in the background: {:?}",
+
        status_thread.thread().id()
+
    );

    // This loop ends when there's an error, e.g., failure to read an
    // event from the node.
    loop {
        debug!("waiting for event from node");
        for e in source.event()? {
-
            status.broker_event(&e);
+
            page.broker_event(&e);
            debug!("broker event {e:#?}");
            let req = Request::trigger(&profile, &e)?;
-
            broker.execute_ci(&req, &mut status)?;
+
            broker.execute_ci(&req, &mut page)?;
        }
    }
}

-
fn status_updater(mut status: Status, interval: Duration) {
+
fn status_updater(dirname: PathBuf, mut page: StatusPage, interval: Duration) {
+
    let filename = dirname.join("status.json");
    loop {
-
        if let Err(e) = status.write() {
-
            eprintln!("ERROR: failed to update status page: {e}");
+
        page.update_timestamp();
+
        if let Err(e) = page.write_json(&filename) {
+
            eprintln!("ERROR: failed to update {}: {e}", filename.display());
+
        }
+
        if let Err(e) = page.write(&dirname) {
+
            eprintln!(
+
                "ERROR: failed to update repot pages in {}: {e}",
+
                dirname.display()
+
            );
        }
        sleep(interval);
    }
added src/bin/pagegen.rs
@@ -0,0 +1,77 @@
+
use std::{path::Path, str::FromStr};
+

+
use radicle::git::Oid;
+
use radicle::prelude::RepoId;
+

+
use radicle_ci_broker::{
+
    msg::{RunId, RunResult},
+
    pages::{PageBuilder, PageError},
+
    run::{Run, RunState, Whence},
+
};
+

+
const DIR: &str = "html";
+

+
fn main() -> Result<(), PageError> {
+
    let mut page = PageBuilder::default()
+
        .node_alias("radicle.liw.fi")
+
        .build()?;
+

+
    let runid1 = RunId::default();
+
    let rid1 = RepoId::from_urn("rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5").unwrap();
+
    let alias1 = "heartwood";
+
    let mut run1 = Run::new(
+
        runid1.clone(),
+
        rid1,
+
        alias1,
+
        Whence::branch(
+
            "master",
+
            Oid::from_str("a48081f2717f069d456ec09f31d9e639b232dbed").unwrap(),
+
        ),
+
        "2024-02-27T18:29:25+02:00".into(),
+
        // RunState::Running,
+
        // RunResult::Success,
+
    );
+
    run1.set_state(RunState::Running);
+
    run1.set_result(RunResult::Success);
+
    page.push_run(run1.clone());
+

+
    let mut run2 = run1.clone();
+
    run2.set_state(RunState::Finished);
+
    page.push_run(run2);
+

+
    let mut run3 = Run::new(
+
        RunId::default(),
+
        rid1,
+
        alias1,
+
        Whence::patch(
+
            Oid::from_str("60abd513e0fb858c0dfe95ad6c4aaeace9c25d60").unwrap(),
+
            Oid::from_str("091f7b7e986d05381718e2aeed2497c55dd0179a").unwrap(),
+
        ),
+
        "2024-02-27T18:29:09+02:00".into(),
+
        // RunState::Finished,
+
        // RunResult::Failure,
+
    );
+
    run3.set_state(RunState::Finished);
+
    run3.set_result(RunResult::Failure);
+
    page.push_run(run3);
+

+
    let rid2 = RepoId::from_urn("rad:zwTxygwuz5LDGBq255RA2CbNGrz8").unwrap();
+
    let alias2 = "radicle-ci-broker";
+
    let mut run4 = Run::new(
+
        RunId::default(),
+
        rid2,
+
        alias2,
+
        Whence::branch(
+
            "master",
+
            Oid::from_str("79469d57841632ec4c0041f564e0b2b024abe7ec").unwrap(),
+
        ),
+
        "2024-02-27T18:29:25+02:00".into(),
+
    );
+
    run4.set_state(RunState::Finished);
+
    run4.set_result(RunResult::Success);
+
    page.push_run(run4);
+

+
    page.write(Path::new(DIR)).unwrap();
+

+
    Ok(())
+
}
deleted src/bin/status.rs
@@ -1,19 +0,0 @@
-
use std::{error::Error, path::Path};
-

-
use radicle_ci_broker::status::*;
-

-
fn main() {
-
    if let Err(e) = fallible_main() {
-
        eprintln!("ERROR: {e}");
-
        let mut e = e.source();
-
        while let Some(source) = e {
-
            eprintln!("caused by: {}", source);
-
            e = source.source();
-
        }
-
    }
-
}
-

-
fn fallible_main() -> Result<(), StatusError> {
-
    Status::new(Path::new("status.json")).write()?;
-
    Ok(())
-
}
modified src/broker.rs
@@ -5,9 +5,17 @@

use std::collections::HashMap;

+
use time::{macros::format_description, OffsetDateTime};
+

use radicle::prelude::RepoId;

-
use crate::{adapter::Adapter, error::BrokerError, msg::Request, run::Run, status::Status};
+
use crate::{
+
    adapter::Adapter,
+
    error::BrokerError,
+
    msg::{PatchEvent, PushEvent, Request, RunId},
+
    pages::StatusPage,
+
    run::{Run, Whence},
+
};

/// A CI broker.
///
@@ -38,40 +46,66 @@ impl Broker {
    }

    #[allow(clippy::result_large_err)]
-
    pub fn execute_ci(&self, trigger: &Request, status: &mut Status) -> Result<Run, BrokerError> {
-
        let adapter = match trigger {
+
    pub fn execute_ci(
+
        &self,
+
        trigger: &Request,
+
        status: &mut StatusPage,
+
    ) -> Result<Run, BrokerError> {
+
        let run = match trigger {
            Request::Trigger {
                common,
-
                push: _,
-
                patch: _,
+
                push,
+
                patch,
            } => {
                let rid = &common.repository.id;
                if let Some(adapter) = self.adapter(rid) {
-
                    adapter
+
                    let whence = if let Some(PushEvent {
+
                        pusher: _,
+
                        before: _,
+
                        after,
+
                        commits: _,
+
                    }) = push
+
                    {
+
                        Whence::branch("push-event-has-no-branch-name", *after)
+
                    } else if let Some(PatchEvent { action: _, patch }) = patch {
+
                        Whence::patch(patch.id, patch.after)
+
                    } else {
+
                        panic!("neither push not patch event");
+
                    };
+

+
                    let mut run = Run::new(
+
                        RunId::default(),
+
                        *rid,
+
                        &common.repository.name,
+
                        whence,
+
                        now(),
+
                    );
+
                    adapter.run(trigger, &mut run, status)?;
+
                    run
                } else {
                    return Err(BrokerError::NoAdapter(*rid));
                }
            }
        };

-
        let mut run = Run::default();
-
        adapter.run(trigger, &mut run, status)?;
-

        Ok(run)
    }
}

+
fn now() -> String {
+
    let fmt = format_description!("[year]-[month]-[day] [hour]:[minute]:[second]Z");
+
    OffsetDateTime::now_utc().format(fmt).expect("format time")
+
}
+

#[cfg(test)]
mod test {
-
    use std::path::Path;
-

    use tempfile::tempdir;

    use super::{Adapter, Broker, RepoId};
    use crate::{
        msg::{RunId, RunResult},
+
        pages::{PageBuilder, StatusPage},
        run::RunState,
-
        status::Status,
        test::{mock_adapter, trigger_request, TestResult},
    };

@@ -85,6 +119,13 @@ mod test {
        RepoId::from_urn(RID).unwrap()
    }

+
    fn status_page() -> StatusPage {
+
        PageBuilder::default()
+
            .node_alias("test.alias")
+
            .build()
+
            .unwrap()
+
    }
+

    #[test]
    fn has_no_adapters_initially() -> TestResult<()> {
        let broker = Broker::default();
@@ -142,7 +183,7 @@ mod test {
    }

    #[test]
-
    fn exectues_adapter() -> TestResult<()> {
+
    fn executes_adapter() -> TestResult<()> {
        const ADAPTER: &str = r#"#!/bin/bash
echo '{"response":"triggered","run_id":{"id":"xyzzy"}}'
echo '{"response":"finished","result":"success"}'
@@ -157,7 +198,7 @@ echo '{"response":"finished","result":"success"}'

        let trigger = trigger_request()?;

-
        let mut status = Status::new(Path::new("/dev/null"));
+
        let mut status = status_page();
        let x = broker.execute_ci(&trigger, &mut status);
        assert!(x.is_ok());
        let run = x.unwrap();
modified src/config.rs
@@ -16,7 +16,7 @@ pub struct Config {
    pub default_adapter: String,
    pub adapters: HashMap<String, Adapter>,
    pub filters: Vec<EventFilter>,
-
    pub status_page: Option<PathBuf>,
+
    pub report_dir: Option<PathBuf>,
    pub status_update_interval_seconds: Option<u64>,
}

@@ -31,10 +31,6 @@ impl Config {
        self.adapters.get(name)
    }

-
    pub fn status_page(&self) -> Option<&Path> {
-
        self.status_page.as_deref()
-
    }
-

    pub fn status_page_update_interval(&self) -> u64 {
        self.status_update_interval_seconds
            .unwrap_or(DEFAULT_STATUS_PAGE_UPDATE_INTERVAL)
modified src/error.rs
@@ -12,7 +12,7 @@ use crate::{
    adapter::AdapterError,
    config::ConfigError,
    msg::{MessageError, Request},
-
    status::StatusError,
+
    pages::PageError,
};

/// All possible errors from the CI broker messages.
@@ -65,5 +65,5 @@ pub enum BrokerError {

    /// Status page error.
    #[error(transparent)]
-
    Status(#[from] StatusError),
+
    StatusPage(#[from] PageError),
}
modified src/lib.rs
@@ -11,7 +11,7 @@ pub mod config;
pub mod error;
pub mod event;
pub mod msg;
+
pub mod pages;
pub mod run;
-
pub mod status;
#[cfg(test)]
pub mod test;
modified src/msg.rs
@@ -12,6 +12,7 @@

use std::{
    fmt,
+
    hash::{Hash, Hasher},
    io::{BufRead, BufReader, Read, Write},
};

@@ -50,6 +51,12 @@ impl Default for RunId {
    }
}

+
impl Hash for RunId {
+
    fn hash<H: Hasher>(&self, h: &mut H) {
+
        self.id.hash(h);
+
    }
+
}
+

impl From<&str> for RunId {
    fn from(id: &str) -> Self {
        Self { id: id.into() }
added src/pages.rs
@@ -0,0 +1,451 @@
+
//! Status and report pages for CI broker.
+
//!
+
//! This module generates an HTML status page for the CI broker, as
+
//! well as per-repository pages for any repository for which the CI
+
//! broker has mediated to run CI. The status page gives the latest
+
//! known status of the broker, plus lists the repositories that CI
+
//! has run for. The per-repository pages lists all the runs for that
+
//! repository.
+

+
use std::{
+
    collections::{HashMap, HashSet},
+
    fs::write,
+
    path::{Path, PathBuf},
+
    sync::{Arc, Mutex, MutexGuard},
+
};
+

+
use html_page::{Document, Element, Tag};
+
use serde::Serialize;
+
use time::{macros::format_description, OffsetDateTime};
+

+
use radicle::prelude::RepoId;
+

+
use crate::{
+
    event::BrokerEvent,
+
    msg::RunId,
+
    run::{Run, RunState, Whence},
+
};
+

+
const CSS: &str = include_str!("radicle-ci.css");
+

+
/// All possible errors returned from the status page module.
+
#[derive(Debug, thiserror::Error)]
+
pub enum PageError {
+
    #[error("failed to write status page to {0}")]
+
    Write(PathBuf, #[source] std::io::Error),
+

+
    #[error("no node alias has been set for builder")]
+
    NoAlias,
+

+
    #[error("no status data has been set for builder")]
+
    NoStatusData,
+
}
+

+
/// A builder for constructing a [`StatusPage`] value. It will only
+
/// construct a valid value.
+
#[derive(Default)]
+
pub struct PageBuilder {
+
    node_alias: Option<String>,
+
}
+

+
impl PageBuilder {
+
    pub fn node_alias(mut self, alias: &str) -> Self {
+
        self.node_alias = Some(alias.into());
+
        self
+
    }
+

+
    pub fn build(self) -> Result<StatusPage, PageError> {
+
        Ok(StatusPage::new(PageData {
+
            timestamp: now(),
+
            ci_broker_version: env!("CARGO_PKG_VERSION"),
+
            ci_broker_git_commit: env!("GIT_HEAD"),
+
            node_alias: self.node_alias.ok_or(PageError::NoAlias)?,
+
            runs: HashMap::new(),
+
            broker_event_counter: 0,
+
            latest_broker_event: None,
+
            latest_ci_run: None,
+
        }))
+
    }
+
}
+

+
fn now() -> String {
+
    let fmt = format_description!("[year]-[month]-[day] [hour]:[minute]:[second]Z");
+
    OffsetDateTime::now_utc().format(fmt).ok().unwrap()
+
}
+

+
struct PageData {
+
    timestamp: String,
+
    ci_broker_version: &'static str,
+
    ci_broker_git_commit: &'static str,
+
    node_alias: String,
+
    runs: HashMap<RunId, Run>,
+
    broker_event_counter: usize,
+
    latest_broker_event: Option<BrokerEvent>,
+
    latest_ci_run: Option<Run>,
+
}
+

+
impl PageData {
+
    fn status_page_as_html(&self) -> Document {
+
        let mut doc = Document::default();
+

+
        doc.push_to_head(
+
            &Element::new(Tag::Title)
+
                .with_text("CI for Radicle node ")
+
                .with_text(&self.node_alias),
+
        );
+

+
        doc.push_to_head(&Element::new(Tag::Style).with_text(CSS));
+

+
        doc.push_to_body(
+
            &Element::new(Tag::H1)
+
                .with_text("CI for Radicle node ")
+
                .with_text(&self.node_alias),
+
        );
+

+
        doc.push_to_body(&Element::new(Tag::H2).with_text("Broker status"));
+
        doc.push_to_body(
+
            &Element::new(Tag::P)
+
                .with_text("Last updated: ")
+
                .with_text(&self.timestamp),
+
        );
+
        doc.push_to_body(
+
            &Element::new(Tag::P)
+
                .with_text("CI broker version: ")
+
                .with_text(self.ci_broker_version)
+
                .with_text(" (commit ")
+
                .with_child(Element::new(Tag::Code).with_text(self.ci_broker_git_commit))
+
                .with_text(")"),
+
        );
+

+
        doc.push_to_body(&Element::new(Tag::H2).with_text("Repositories"));
+

+
        doc.push_to_body(&Element::new(Tag::P).with_text("Latest CI run for each repository."));
+

+
        let mut list = Element::new(Tag::Ul).with_attribute("class", "repolist");
+
        for (alias, rid) in self.repos() {
+
            let mut item = Element::new(Tag::Li);
+

+
            item.push_child(
+
                &Element::new(Tag::Span).with_child(
+
                    Element::new(Tag::A)
+
                        .with_attribute("href", &format!("{}.html", rid_to_basename(rid)))
+
                        .with_text("Repository ")
+
                        .with_child(
+
                            Element::new(Tag::Span)
+
                                .with_attribute("class", "alias")
+
                                .with_text(&alias),
+
                        )
+
                        .with_text(" (")
+
                        .with_child(
+
                            Element::new(Tag::Code)
+
                                .with_attribute("class", "repoid")
+
                                .with_text(&rid.to_string()),
+
                        )
+
                        .with_text(")"),
+
                ),
+
            );
+

+
            if let Some(run) = self.latest_run(rid) {
+
                item.push_child(&Element::new(Tag::Br));
+
                item.push_child(
+
                    &Element::new(Tag::Span)
+
                        .with_text(run.timestamp())
+
                        .with_child(Element::new(Tag::Br))
+
                        .with_text(" ")
+
                        .with_child(Self::whence_as_html(run.whence())),
+
                );
+
                item.push_child(&Element::new(Tag::Br));
+

+
                let state = run.state().to_string();
+
                item.push_child(
+
                    &Element::new(Tag::Span)
+
                        .with_attribute("class", &state)
+
                        .with_text(&state),
+
                );
+
            }
+

+
            list.push_child(&item);
+
        }
+
        doc.push_to_body(&list);
+

+
        doc
+
    }
+

+
    fn whence_as_html(whence: &Whence) -> Element {
+
        match whence {
+
            Whence::Branch { name, commit } => Element::new(Tag::Span)
+
                .with_text("branch ")
+
                .with_child(
+
                    Element::new(Tag::Code)
+
                        .with_attribute("class", "branch")
+
                        .with_text(name),
+
                )
+
                .with_text(", commit  ")
+
                .with_child(
+
                    Element::new(Tag::Code)
+
                        .with_attribute("class", "commit")
+
                        .with_text(&commit.to_string()),
+
                ),
+
            Whence::Patch { patch, commit } => Element::new(Tag::Span)
+
                .with_text("patch ")
+
                .with_child(
+
                    Element::new(Tag::Code)
+
                        .with_attribute("class", "branch")
+
                        .with_text(&patch.to_string()),
+
                )
+
                .with_text(", commit ")
+
                .with_child(
+
                    Element::new(Tag::Code)
+
                        .with_attribute("class", "commit")
+
                        .with_text(&commit.to_string()),
+
                ),
+
        }
+
    }
+

+
    fn per_repo_page_as_html(&self, rid: RepoId, alias: &str, timestamp: &str) -> Document {
+
        let mut doc = Document::default();
+

+
        doc.push_to_head(
+
            &Element::new(Tag::Title)
+
                .with_text("CI runs for repository ")
+
                .with_text(alias),
+
        );
+

+
        doc.push_to_head(&Element::new(Tag::Style).with_text(CSS));
+

+
        doc.push_to_body(
+
            &Element::new(Tag::H1)
+
                .with_text("CI runs for repository ")
+
                .with_text(alias),
+
        );
+

+
        doc.push_to_body(
+
            &Element::new(Tag::P)
+
                .with_text("Last updated: ")
+
                .with_text(timestamp),
+
        );
+

+
        doc.push_to_body(
+
            &Element::new(Tag::P)
+
                .with_text("Repository ID ")
+
                .with_child(Element::new(Tag::Code).with_text(&rid.to_string())),
+
        );
+

+
        let mut runs = self.runs(rid);
+
        runs.sort_by_cached_key(|run| run.timestamp());
+
        runs.reverse();
+
        let mut list = Element::new(Tag::Ol).with_attribute("class", "runlist");
+
        for run in runs {
+
            let current = match run.state() {
+
                RunState::Triggered => Element::new(Tag::Span)
+
                    .with_attribute("state", "triggered")
+
                    .with_text("triggered"),
+
                RunState::Running => Element::new(Tag::Span)
+
                    .with_attribute("class", "running")
+
                    .with_text("running"),
+
                RunState::Finished => {
+
                    let result = if let Some(result) = run.result() {
+
                        result.to_string()
+
                    } else {
+
                        "unknown".into()
+
                    };
+
                    Element::new(Tag::Span)
+
                        .with_attribute("class", &result)
+
                        .with_text(&result)
+
                }
+
            };
+

+
            let link = Element::new(Tag::A)
+
                .with_attribute("href", &format!("{}/log.html", run.broker_run_id()))
+
                .with_text("log");
+

+
            list.push_child(
+
                &Element::new(Tag::Li)
+
                    .with_text(run.timestamp())
+
                    .with_text(" ")
+
                    .with_child(current)
+
                    .with_text(" ")
+
                    .with_child(link)
+
                    .with_child(Element::new(Tag::Br))
+
                    .with_child(Self::whence_as_html(run.whence())),
+
            );
+
        }
+

+
        doc.push_to_body(&list);
+

+
        doc
+
    }
+

+
    fn repos(&self) -> Vec<(String, RepoId)> {
+
        let rids: HashSet<(String, RepoId)> = self
+
            .runs
+
            .values()
+
            .map(|run| (run.repo_alias().to_string(), run.repo_id()))
+
            .collect();
+
        let mut repos: Vec<(String, RepoId)> = rids.iter().cloned().collect();
+
        repos.sort();
+
        repos
+
    }
+

+
    fn repo_alias(&self, wanted: RepoId) -> Option<String> {
+
        self.repos().iter().find_map(|(alias, rid)| {
+
            if *rid == wanted {
+
                Some(alias.into())
+
            } else {
+
                None
+
            }
+
        })
+
    }
+

+
    fn runs(&self, repoid: RepoId) -> Vec<&Run> {
+
        self.runs
+
            .iter()
+
            .filter_map(|(_, run)| {
+
                if run.repo_id() == repoid {
+
                    Some(run)
+
                } else {
+
                    None
+
                }
+
            })
+
            .collect()
+
    }
+

+
    fn latest_run(&self, repoid: RepoId) -> Option<&Run> {
+
        let mut value: Option<&Run> = None;
+
        for run in self.runs(repoid) {
+
            if let Some(latest) = value {
+
                if run.timestamp() > latest.timestamp() {
+
                    value = Some(run);
+
                }
+
            } else {
+
                value = Some(run);
+
            }
+
        }
+
        value
+
    }
+
}
+

+
/// Data for status pages for CI broker.
+
///
+
/// There is a "front page" with status about the broker, and a list
+
/// of repositories for which the broker has run CI. Then there is a
+
/// page per such repository, with a list of CI runs for that
+
/// repository.
+
pub struct StatusPage {
+
    data: Arc<Mutex<PageData>>,
+
}
+

+
impl StatusPage {
+
    fn new(data: PageData) -> Self {
+
        Self {
+
            data: Arc::new(Mutex::new(data)),
+
        }
+
    }
+

+
    fn lock(&mut self) -> MutexGuard<PageData> {
+
        self.data.lock().expect("lock StatusPage::data")
+
    }
+

+
    pub fn update_timestamp(&mut self) {
+
        let mut data = self.lock();
+
        data.timestamp = now();
+
    }
+

+
    pub fn broker_event(&mut self, event: &BrokerEvent) {
+
        let mut data = self.lock();
+
        data.latest_broker_event = Some(event.clone());
+
        data.broker_event_counter += 1;
+
    }
+

+
    /// Add a new CI run to the status page.
+
    pub fn push_run(&mut self, new: Run) {
+
        let mut data = self.lock();
+
        data.latest_ci_run = Some(new.clone());
+
        data.runs.insert(new.broker_run_id().clone(), new);
+
    }
+

+
    /// Write the status page (as index.html) and per-repository pages
+
    /// (`<RID>.html`) into the directory given as an argument. The directory must exist.
+
    pub fn write(&mut self, dirname: &Path) -> Result<(), PageError> {
+
        let nameless = String::from("nameless repo");
+

+
        // We avoid writing while keeping the lock, to reduce
+
        // contention.
+
        let (status, repos) = {
+
            let data = self.lock();
+

+
            let status = data.status_page_as_html().to_string();
+

+
            let mut repos = vec![];
+
            for (_, rid) in data.repos() {
+
                let basename = rid_to_basename(rid);
+
                let filename = dirname.join(format!("{basename}.html"));
+
                let alias = data.repo_alias(rid).unwrap_or(nameless.clone());
+
                let repopage = data.per_repo_page_as_html(rid, &alias, &data.timestamp);
+
                repos.push((filename, repopage.to_string()));
+
            }
+

+
            (status, repos)
+
        };
+

+
        write(dirname.join("index.html"), status).unwrap();
+

+
        for (filename, repopage) in repos {
+
            write(filename, repopage).unwrap();
+
        }
+

+
        Ok(())
+
    }
+

+
    /// Write the JSON status file.
+
    pub fn write_json(&mut self, filename: &Path) -> Result<(), PageError> {
+
        // We avoid writing while keeping the lock, to reduce
+
        // contention.
+
        let status = {
+
            let data = self.lock();
+
            serde_json::to_string(&StatusData::from(&*data)).unwrap()
+
        };
+

+
        write(filename, status).unwrap();
+

+
        Ok(())
+
    }
+
}
+

+
impl Clone for StatusPage {
+
    fn clone(&self) -> Self {
+
        Self {
+
            data: Arc::clone(&self.data),
+
        }
+
    }
+
}
+

+
#[derive(Debug, Clone, Serialize)]
+
struct StatusData {
+
    timestamp: String,
+
    broker_event_counter: usize,
+
    ci_broker_version: &'static str,
+
    ci_broker_git_commit: &'static str,
+
    latest_broker_event: Option<BrokerEvent>,
+
    latest_ci_run: Option<Run>,
+
}
+

+
impl From<&PageData> for StatusData {
+
    fn from(page: &PageData) -> Self {
+
        Self {
+
            timestamp: page.timestamp.clone(),
+
            broker_event_counter: page.broker_event_counter,
+
            ci_broker_version: page.ci_broker_version,
+
            ci_broker_git_commit: page.ci_broker_git_commit,
+
            latest_broker_event: page.latest_broker_event.clone(),
+
            latest_ci_run: page.latest_ci_run.clone(),
+
        }
+
    }
+
}
+

+
fn rid_to_basename(repoid: RepoId) -> String {
+
    let mut basename = repoid.to_string();
+
    assert!(basename.starts_with("rad:"));
+
    basename.drain(..4);
+
    basename
+
}
added src/radicle-ci.css
@@ -0,0 +1,50 @@
+
ul.repolist li {
+
    margin-top: 1em;
+
}
+

+
ol.runlist li {
+
    margin-top: 1em;
+
}
+
span.success {
+
    color: white;
+
    background-color: green;
+
}
+
span.failure {
+
    color: red;
+
    background-color: white;
+
}
+
span.unknown {
+
    color: white;
+
    background-color: grey;
+
}
+

+
span.alias {
+
    font-weight: bold;
+
}
+

+
code.branch {
+
    font-weight: bold;
+
}
+

+
code.patch {
+
    font-weight: bold;
+
}
+

+
code.commit {
+
    font-weight: bold;
+
}
+

+
code.repoid {
+
    font-weight: bold;
+
}
+

+
span.triggered {
+
    font-weight: bold;
+
}
+

+
span.running {
+
    color: red;
+
}
+

+
span.finished {
+
}
modified src/run.rs
@@ -2,28 +2,64 @@ use std::fmt;

use serde::{Deserialize, Serialize};

+
use radicle::git::Oid;
+
use radicle::prelude::RepoId;
+

use crate::msg::{RunId, RunResult};

#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub struct Run {
    broker_run_id: RunId,
    adapter_run_id: Option<RunId>,
+
    repo_id: RepoId,
+
    repo_alias: String,
+
    timestamp: String,
+
    whence: Whence,
    state: RunState,
    result: Option<RunResult>,
}

-
impl Default for Run {
-
    fn default() -> Self {
+
impl Run {
+
    /// Create a new run.
+
    pub fn new(
+
        run_id: RunId,
+
        repo_id: RepoId,
+
        alias: &str,
+
        whence: Whence,
+
        timestamp: String,
+
    ) -> Self {
        Self {
-
            broker_run_id: RunId::default(),
+
            broker_run_id: run_id,
            adapter_run_id: None,
+
            repo_id,
+
            repo_alias: alias.into(),
+
            timestamp,
+
            whence,
            state: RunState::Triggered,
            result: None,
        }
    }
-
}

-
impl Run {
+
    /// Return the repo alias.
+
    pub fn repo_alias(&self) -> &str {
+
        &self.repo_alias
+
    }
+

+
    /// Return the repo id.
+
    pub fn repo_id(&self) -> RepoId {
+
        self.repo_id
+
    }
+

+
    /// Return timestamp of run.
+
    pub fn timestamp(&self) -> &str {
+
        &self.timestamp
+
    }
+

+
    /// Return where the commit came from.
+
    pub fn whence(&self) -> &Whence {
+
        &self.whence
+
    }
+

    /// Return the run id assigned by the broker. This is set when the
    /// run is created and can't be changed.
    pub fn broker_run_id(&self) -> &RunId {
@@ -86,6 +122,26 @@ impl fmt::Display for RunState {
    }
}

+
/// Where did the commit come that CI is run for?
+
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
+
pub enum Whence {
+
    Branch { name: String, commit: Oid },
+
    Patch { patch: Oid, commit: Oid },
+
}
+

+
impl Whence {
+
    pub fn branch(name: &str, commit: Oid) -> Self {
+
        Self::Branch {
+
            name: name.into(),
+
            commit,
+
        }
+
    }
+

+
    pub fn patch(patch: Oid, commit: Oid) -> Self {
+
        Self::Patch { patch, commit }
+
    }
+
}
+

#[cfg(test)]
mod test {
    use super::*;
deleted src/status.rs
@@ -1,128 +0,0 @@
-
use std::{
-
    path::{Path, PathBuf},
-
    sync::{Arc, Mutex, MutexGuard},
-
};
-

-
use serde::Serialize;
-
use time::{macros::format_description, OffsetDateTime};
-

-
use crate::{event::BrokerEvent, run::Run};
-

-
#[derive(Debug, Serialize)]
-
struct StatusData {
-
    timestamp: String,
-
    broker_event_counter: usize,
-
    ci_broker_version: &'static str,
-
    ci_broker_git_commit: &'static str,
-
    latest_broker_event: Option<BrokerEvent>,
-
    latest_ci_run: Option<Run>,
-
}
-

-
impl Default for StatusData {
-
    fn default() -> Self {
-
        Self {
-
            timestamp: "".into(),
-
            broker_event_counter: 0,
-
            ci_broker_version: env!("CARGO_PKG_VERSION"),
-
            ci_broker_git_commit: env!("GIT_HEAD"),
-
            latest_broker_event: None,
-
            latest_ci_run: None,
-
        }
-
    }
-
}
-

-
impl StatusData {
-
    fn write(&self, filename: &Path) -> Result<(), StatusError> {
-
        let tmp = filename.with_extension("update");
-
        let s = serde_json::to_string_pretty(&self).map_err(StatusError::serialize)?;
-
        std::fs::write(&tmp, s.as_bytes()).map_err(|e| StatusError::status_write(filename, e))?;
-
        std::fs::rename(&tmp, filename).map_err(|e| StatusError::status_rename(filename, e))?;
-
        Ok(())
-
    }
-
}
-

-
pub struct Status {
-
    filename: PathBuf,
-
    status: Arc<Mutex<StatusData>>,
-
}
-

-
impl Status {
-
    pub fn new(filename: &Path) -> Self {
-
        Self {
-
            filename: filename.into(),
-
            status: Arc::new(Mutex::new(StatusData::default())),
-
        }
-
    }
-

-
    fn lock(&mut self) -> MutexGuard<StatusData> {
-
        self.status.lock().expect("lock StatusGuard::status")
-
    }
-

-
    pub fn broker_event(&mut self, event: &BrokerEvent) {
-
        let mut status = self.lock();
-
        status.latest_broker_event = Some(event.clone());
-
        status.broker_event_counter += 1;
-
    }
-

-
    pub fn ci_run(&mut self, run: &Run) {
-
        let mut status = self.lock();
-
        status.latest_ci_run = Some(run.clone());
-
    }
-

-
    pub fn write(&mut self) -> Result<(), StatusError> {
-
        let filename = self.filename.clone();
-
        let mut status = self.lock();
-
        status.timestamp = Self::now()?;
-
        status.write(&filename)?;
-
        Ok(())
-
    }
-

-
    fn now() -> Result<String, StatusError> {
-
        let fmt = format_description!("[year]-[month]-[day] [hour]:[minute]:[second]Z");
-
        OffsetDateTime::now_utc()
-
            .format(fmt)
-
            .map_err(StatusError::format_now)
-
    }
-
}
-

-
impl Clone for Status {
-
    fn clone(&self) -> Self {
-
        Self {
-
            filename: self.filename.clone(),
-
            status: Arc::clone(&self.status),
-
        }
-
    }
-
}
-

-
#[derive(Debug, thiserror::Error)]
-
pub enum StatusError {
-
    #[error("failed to format current time stamp")]
-
    FormatNow(#[source] time::error::Format),
-

-
    #[error("failed to serialize status as JSON")]
-
    Serizalize(#[source] serde_json::Error),
-

-
    #[error("failed to write status to file {0}")]
-
    StatusWrite(PathBuf, #[source] std::io::Error),
-

-
    #[error("failed to rename status to file {0}")]
-
    StatusRename(PathBuf, #[source] std::io::Error),
-
}
-

-
impl StatusError {
-
    fn format_now(err: time::error::Format) -> Self {
-
        Self::FormatNow(err)
-
    }
-

-
    fn serialize(err: serde_json::Error) -> Self {
-
        Self::Serizalize(err)
-
    }
-

-
    fn status_write(filename: &Path, err: std::io::Error) -> Self {
-
        Self::StatusWrite(filename.into(), err)
-
    }
-

-
    fn status_rename(filename: &Path, err: std::io::Error) -> Self {
-
        Self::StatusRename(filename.into(), err)
-
    }
-
}