Radish alpha
r
rad:zwTxygwuz5LDGBq255RA2CbNGrz8
Radicle CI broker
Radicle
Git
Handle almost all errors as errors, instead of causing a panic
Merged liw opened 1 year ago

Instead of calling Result::unwrap or Result::expect, which panic if the result is not Ok, we return the error to the caller. That will hopefully be logged and thus visible to the user in a way that is easier to deal with than a panic

This leaves only a few JoinHandle::join results that are handled in a way that causes a panic. JoinHandle returns errors that do not implement the std::error::Error trait, which makes them a little harder to handle the way other errors are handled. Specifically, thiserror doesn’t like them as source errors. I’m ignoring the join errors for now.

Every other error is handled. The only unwrap or expect method calls now are for the join errors or for Options.

Signed-off-by: Lars Wirzenius liw@liw.fi

11 files changed +289 -184 4acabd5f 0a8925d9
modified build.rs
@@ -23,5 +23,8 @@ fn main() {
    println!("cargo:rustc-env=GIT_HEAD={hash}");
    println!("cargo:rustc-rerun-if-changed=.git/HEAD");

-
    subplot_build::codegen("ci-broker.subplot").expect("failed to generate code with Subplot");
+
    if let Err(e) = subplot_build::codegen("ci-broker.subplot") {
+
        eprintln!("failed to generate code with Subplot: {e}");
+
        std::process::exit(1);
+
    }
}
modified src/adapter.rs
@@ -223,17 +223,17 @@ mod test {
        test::{log_in_tests, mock_adapter, trigger_request, TestResult},
    };

-
    fn run() -> Run {
-
        Run::new(
-
            RepoId::from_urn("rad:zwTxygwuz5LDGBq255RA2CbNGrz8").unwrap(),
+
    fn run() -> anyhow::Result<Run> {
+
        Ok(Run::new(
+
            RepoId::from_urn("rad:zwTxygwuz5LDGBq255RA2CbNGrz8")?,
            "test.repo",
            Whence::branch(
                "main",
-
                Oid::try_from("ff3099ba5de28d954c41d0b5a84316f943794ea4").unwrap(),
+
                Oid::try_from("ff3099ba5de28d954c41d0b5a84316f943794ea4")?,
                Some("J. Random Hacker <random@example.com>"),
            ),
            "2024-02-29T12:58:12+02:00".into(),
-
        )
+
        ))
    }

    #[test]
@@ -250,7 +250,7 @@ echo '{"response":"finished","result":"success"}'
        let bin = tmp.path().join("adapter.sh");
        mock_adapter(&bin, ADAPTER)?;

-
        let mut run = run();
+
        let mut run = run()?;
        Adapter::new(&bin).run(&trigger_request()?, &mut run)?;
        assert_eq!(run.result(), Some(&RunResult::Success));

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

-
        let mut run = run();
+
        let mut run = run()?;
        let x = Adapter::new(&bin).run(&trigger_request()?, &mut run);

        match x {
@@ -299,7 +299,7 @@ exit 1
        let bin = tmp.path().join("adapter.sh");
        mock_adapter(&bin, ADAPTER)?;

-
        let mut run = run();
+
        let mut run = run()?;
        let x = Adapter::new(&bin).run(&trigger_request()?, &mut run);
        eprintln!("{x:#?}");
        assert!(x.is_err());
@@ -320,7 +320,7 @@ kill -9 $BASHPID
        let bin = tmp.path().join("adapter.sh");
        mock_adapter(&bin, ADAPTER)?;

-
        let mut run = run();
+
        let mut run = run()?;
        let x = Adapter::new(&bin).run(&trigger_request()?, &mut run);
        eprintln!("{x:#?}");
        assert!(matches!(
@@ -345,7 +345,7 @@ kill -9 $BASHPID
        let bin = tmp.path().join("adapter.sh");
        mock_adapter(&bin, ADAPTER)?;

-
        let mut run = run();
+
        let mut run = run()?;
        let x = Adapter::new(&bin).run(&trigger_request()?, &mut run);
        eprintln!("{x:#?}");
        assert!(matches!(x, Err(AdapterError::Failed(_))));
@@ -368,7 +368,7 @@ kill -9 $BASHPID
        let bin = tmp.path().join("adapter.sh");
        mock_adapter(&bin, ADAPTER)?;

-
        let mut run = run();
+
        let mut run = run()?;
        let x = Adapter::new(&bin).run(&trigger_request()?, &mut run);
        eprintln!("{x:#?}");
        assert!(matches!(x, Err(AdapterError::Failed(_))));
@@ -390,7 +390,7 @@ echo '{"response":"finished","result":"success","bad":"field"}'
        let bin = tmp.path().join("adapter.sh");
        mock_adapter(&bin, ADAPTER)?;

-
        let mut run = run();
+
        let mut run = run()?;
        let x = Adapter::new(&bin).run(&trigger_request()?, &mut run);

        match x {
@@ -414,7 +414,7 @@ echo '{"response":"finished","result":"success"}'
        let bin = tmp.path().join("adapter.sh");
        mock_adapter(&bin, ADAPTER)?;

-
        let mut run = run();
+
        let mut run = run()?;
        let x = Adapter::new(&bin).run(&trigger_request()?, &mut run);
        eprintln!("{x:#?}");
        assert!(matches!(
@@ -442,7 +442,7 @@ echo '{"response":"finished","result":"success"}'
        let bin = tmp.path().join("adapter.sh");
        mock_adapter(&bin, ADAPTER)?;

-
        let mut run = run();
+
        let mut run = run()?;
        let x = Adapter::new(&bin).run(&trigger_request()?, &mut run);
        eprintln!("{x:#?}");
        assert!(matches!(
@@ -462,7 +462,7 @@ echo '{"response":"finished","result":"success"}'
        let tmp = tempdir()?;
        let bin = tmp.path().join("adapter.sh");

-
        let mut run = run();
+
        let mut run = run()?;
        let x = Adapter::new(&bin).run(&trigger_request()?, &mut run);
        eprintln!("{x:#?}");
        match x {
@@ -490,7 +490,7 @@ echo '{"response":"finished","result":"success"}'
        let bin = tmp.path().join("adapter.sh");
        write(&bin, ADAPTER)?;

-
        let mut run = run();
+
        let mut run = run()?;
        let x = Adapter::new(&bin).run(&trigger_request()?, &mut run);
        eprintln!("{x:#?}");
        match x {
@@ -522,7 +522,7 @@ echo '{"response":"finished","result":"success"}'
        let bin = tmp.path().join("adapter.sh");
        mock_adapter(&bin, ADAPTER)?;

-
        let mut run = run();
+
        let mut run = run()?;
        let x = Adapter::new(&bin).run(&trigger_request()?, &mut run);
        eprintln!("{x:#?}");
        match x {
modified src/bin/cib.rs
@@ -91,8 +91,10 @@ struct ConfigCmd {
}

impl ConfigCmd {
-
    fn run(&self, _args: &Args, config: &Config) -> Result<(), CibError> {
-
        let json = config.to_json();
+
    fn run(&self, args: &Args, config: &Config) -> Result<(), CibError> {
+
        let json = config
+
            .to_json()
+
            .map_err(|e| CibError::to_json(&args.config, e))?;

        if let Some(output) = &self.output {
            write(output, json.as_bytes()).map_err(|e| CibError::write_config(output, e))?;
@@ -225,6 +227,9 @@ enum CibError {
    #[error("failed to write config as JSON to file {0}")]
    WriteConfig(PathBuf, #[source] std::io::Error),

+
    #[error("failed to convert config as JSON")]
+
    ToJson(PathBuf, #[source] ConfigError),
+

    #[error("failed to look up node profile")]
    Profile(#[source] radicle::profile::Error),

@@ -259,6 +264,10 @@ impl CibError {
        Self::WriteConfig(filename.into(), e)
    }

+
    fn to_json(filename: &Path, e: ConfigError) -> Self {
+
        Self::ToJson(filename.into(), e)
+
    }
+

    fn profile(e: radicle::profile::Error) -> Self {
        Self::Profile(e)
    }
modified src/bin/cibtool.rs
@@ -341,7 +341,8 @@ impl AddEvent {
            self.lookup_nid()?,
            self.name.as_str()
        );
-
        let name = RefString::try_from(name).expect("RefString");
+
        let name =
+
            RefString::try_from(name.clone()).map_err(|e| CibToolError::RefString(name, e))?;

        let event = BrokerEvent::new(&rid, &name, &oid, self.base);

@@ -356,7 +357,8 @@ impl AddEvent {
            println!("{id}");

            if let Some(filename) = &self.id_file {
-
                write(filename, id.to_string().as_bytes()).expect("write id file");
+
                write(filename, id.to_string().as_bytes())
+
                    .map_err(|e| CibToolError::WriteEventId(filename.into(), e))?;
            }
        }
        Ok(())
@@ -441,12 +443,12 @@ impl ShowEvent {

        let id = if let Some(id) = &self.id {
            id.clone()
-
        } else {
-
            assert!(self.id_file.is_some());
-
            let file = self.id_file.as_ref().unwrap();
-
            let id = read(file).expect("read id file");
+
        } else if let Some(filename) = &self.id_file {
+
            let id = read(filename).map_err(|e| CibToolError::ReadEventId(filename.into(), e))?;
            let id = String::from_utf8_lossy(&id).to_string();
            QueueId::from(&id)
+
        } else {
+
            return Err(CibToolError::MissingId);
        };

        if let Some(event) = db.get_queued_event(&id)? {
@@ -485,12 +487,12 @@ impl RemoveEvent {

        let id = if let Some(id) = &self.id {
            id.clone()
-
        } else {
-
            assert!(self.id_file.is_some());
-
            let file = self.id_file.as_ref().unwrap();
-
            let id = read(file).expect("read id file");
+
        } else if let Some(filename) = &self.id_file {
+
            let id = read(filename).map_err(|e| CibToolError::ReadEventId(filename.into(), e))?;
            let id = String::from_utf8_lossy(&id).to_string();
            QueueId::from(&id)
+
        } else {
+
            return Err(CibToolError::MissingId);
        };

        db.remove_queued_event(&id)?;
@@ -513,7 +515,8 @@ impl Shutdown {
        let id = db.push_queued_event(BrokerEvent::Shutdown)?;

        if let Some(filename) = &self.id_file {
-
            write(filename, id.to_string().as_bytes()).expect("write id file");
+
            write(filename, id.to_string().as_bytes())
+
                .map_err(|e| CibToolError::WriteEventId(filename.into(), e))?;
        }

        Ok(())
@@ -625,7 +628,7 @@ impl AddRun {
            RunResult::Failure
        });

-
        db.push_run(run).unwrap();
+
        db.push_run(run)?;

        Ok(())
    }
@@ -644,7 +647,10 @@ impl ListRuns {

        if self.json {
            let runs: Vec<RunInfo> = db.get_all_runs()?.iter().map(RunInfo::from).collect();
-
            println!("{}", serde_json::to_string_pretty(&runs).expect("JSON"));
+
            println!(
+
                "{}",
+
                serde_json::to_string_pretty(&runs).map_err(CibToolError::RunToJson)?
+
            );
        } else {
            for run in db.get_all_runs()? {
                println!(
@@ -761,7 +767,8 @@ impl TriggerCmd {
            self.lookup_nid()?,
            self.name.as_str()
        );
-
        let name = RefString::try_from(name).expect("RefString");
+
        let name =
+
            RefString::try_from(name.clone()).map_err(|e| CibToolError::RefString(name, e))?;

        let event = BrokerEvent::new(&rid, &name, &oid, None);

@@ -770,7 +777,8 @@ impl TriggerCmd {
        println!("{id}");

        if let Some(filename) = &self.id_file {
-
            write(filename, id.to_string().as_bytes()).expect("write id file");
+
            write(filename, id.to_string().as_bytes())
+
                .map_err(|e| CibToolError::WriteEventId(filename.into(), e))?;
        }

        Ok(())
@@ -864,9 +872,24 @@ enum CibToolError {
    #[error("failed to serialize broker event to JSON: {0:#?}")]
    EventToJson(BrokerEvent, #[source] serde_json::Error),

+
    #[error("failed to serialize CI run information to JSON")]
+
    RunToJson(#[source] serde_json::Error),
+

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

    #[error(transparent)]
    Page(#[from] PageError),
+

+
    #[error("failed to interpret as a Git reference: {0:?}")]
+
    RefString(String, #[source] radicle::git::fmt::Error),
+

+
    #[error("failed to read event ID from file {0}")]
+
    ReadEventId(PathBuf, #[source] std::io::Error),
+

+
    #[error("failed to write event ID to file {0}")]
+
    WriteEventId(PathBuf, #[source] std::io::Error),
+

+
    #[error("one of --id or --id-file is required")]
+
    MissingId,
}
modified src/broker.rs
@@ -98,7 +98,7 @@ impl Broker {
                        panic!("neither push not patch event");
                    };

-
                    let mut run = Run::new(*rid, &common.repository.name, whence, now());
+
                    let mut run = Run::new(*rid, &common.repository.name, whence, now()?);

                    // We run the adapter, but if that fails, we just
                    // log the error. The `Run` value records the
@@ -135,15 +135,19 @@ impl Broker {
    }
}

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

/// All possible errors from this module.
#[derive(Debug, thiserror::Error)]
#[allow(clippy::large_enum_variant)]
pub enum BrokerError {
+
    /// Error formatting a time as a string.
+
    #[error(transparent)]
+
    Timeformat(#[from] time::error::Format),
+

    /// Error from an node event subscriber.
    #[error(transparent)]
    NodeEvent(#[from] crate::event::NodeEventError),
@@ -193,38 +197,38 @@ mod test {
        test::{log_in_tests, mock_adapter, trigger_request, TestResult},
    };

-
    fn broker(filename: &Path) -> Broker {
-
        Broker::new(filename).unwrap()
+
    fn broker(filename: &Path) -> anyhow::Result<Broker> {
+
        Ok(Broker::new(filename)?)
    }

-
    fn rid() -> RepoId {
+
    fn rid() -> anyhow::Result<RepoId> {
        const RID: &str = "rad:zwTxygwuz5LDGBq255RA2CbNGrz8";
-
        RepoId::from_urn(RID).unwrap()
+
        Ok(RepoId::from_urn(RID)?)
    }

-
    fn rid2() -> RepoId {
+
    fn rid2() -> anyhow::Result<RepoId> {
        const RID: &str = "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5";
-
        RepoId::from_urn(RID).unwrap()
+
        Ok(RepoId::from_urn(RID)?)
    }

    #[test]
    fn has_no_adapters_initially() -> TestResult<()> {
-
        let tmp = tempdir().unwrap();
+
        let tmp = tempdir()?;
        let db = tmp.path().join("db.db");
-
        let broker = broker(&db);
-
        let rid = rid();
+
        let broker = broker(&db)?;
+
        let rid = rid()?;
        assert_eq!(broker.adapter(&rid), None);
        Ok(())
    }

    #[test]
    fn adds_adapter() -> TestResult<()> {
-
        let tmp = tempdir().unwrap();
+
        let tmp = tempdir()?;
        let db = tmp.path().join("db.db");
-
        let mut broker = broker(&db);
+
        let mut broker = broker(&db)?;

        let adapter = Adapter::default();
-
        let rid = rid();
+
        let rid = rid()?;
        broker.set_repository_adapter(&rid, &adapter);
        assert_eq!(broker.adapter(&rid), Some(&adapter));
        Ok(())
@@ -232,13 +236,13 @@ mod test {

    #[test]
    fn does_not_find_unknown_repo() -> TestResult<()> {
-
        let tmp = tempdir().unwrap();
+
        let tmp = tempdir()?;
        let db = tmp.path().join("db.db");
-
        let mut broker = broker(&db);
+
        let mut broker = broker(&db)?;

        let adapter = Adapter::default();
-
        let rid = rid();
-
        let rid2 = rid2();
+
        let rid = rid()?;
+
        let rid2 = rid2()?;
        broker.set_repository_adapter(&rid, &adapter);
        assert_eq!(broker.adapter(&rid2), None);
        Ok(())
@@ -246,9 +250,9 @@ mod test {

    #[test]
    fn does_not_have_a_default_adapter_initially() -> TestResult<()> {
-
        let tmp = tempdir().unwrap();
+
        let tmp = tempdir()?;
        let db = tmp.path().join("db.db");
-
        let broker = broker(&db);
+
        let broker = broker(&db)?;

        assert_eq!(broker.default_adapter(), None);
        Ok(())
@@ -256,9 +260,9 @@ mod test {

    #[test]
    fn sets_a_default_adapter_initially() -> TestResult<()> {
-
        let tmp = tempdir().unwrap();
+
        let tmp = tempdir()?;
        let db = tmp.path().join("db.db");
-
        let mut broker = broker(&db);
+
        let mut broker = broker(&db)?;

        let adapter = Adapter::default();
        broker.set_default_adapter(&adapter);
@@ -268,14 +272,14 @@ mod test {

    #[test]
    fn finds_default_adapter_for_unknown_repo() -> TestResult<()> {
-
        let tmp = tempdir().unwrap();
+
        let tmp = tempdir()?;
        let db = tmp.path().join("db.db");
-
        let mut broker = broker(&db);
+
        let mut broker = broker(&db)?;

        let adapter = Adapter::default();
        broker.set_default_adapter(&adapter);

-
        let rid = rid();
+
        let rid = rid()?;
        assert_eq!(broker.adapter(&rid), Some(&adapter));
        Ok(())
    }
@@ -292,16 +296,16 @@ echo '{"response":"finished","result":"success"}'
        let bin = tmp.path().join("adapter.sh");
        let adapter = mock_adapter(&bin, ADAPTER)?;

-
        let tmp = tempdir().unwrap();
+
        let tmp = tempdir()?;
        let db = tmp.path().join("db.db");
-
        let mut broker = broker(&db);
+
        let mut broker = broker(&db)?;
        broker.set_default_adapter(&adapter);

        let trigger = trigger_request()?;

        let x = broker.execute_ci(&trigger);
        assert!(x.is_ok());
-
        let run = x.unwrap();
+
        let run = x?;
        assert_eq!(run.adapter_run_id(), Some(&RunId::from("xyzzy")));
        assert_eq!(run.state(), RunState::Finished);
        assert_eq!(run.result(), Some(&RunResult::Success));
@@ -325,16 +329,16 @@ exit 1
        let bin = tmp.path().join("adapter.sh");
        let adapter = mock_adapter(&bin, ADAPTER)?;

-
        let tmp = tempdir().unwrap();
+
        let tmp = tempdir()?;
        let db = tmp.path().join("db.db");
-
        let mut broker = broker(&db);
+
        let mut broker = broker(&db)?;
        broker.set_default_adapter(&adapter);

        let trigger = trigger_request()?;

        let x = broker.execute_ci(&trigger);
        assert!(x.is_ok());
-
        let run = x.unwrap();
+
        let run = x?;
        assert_eq!(run.adapter_run_id(), Some(&RunId::from("xyzzy")));
        assert_eq!(run.state(), RunState::Finished);
        assert_eq!(run.result(), Some(&RunResult::Success));
modified src/config.rs
@@ -42,8 +42,8 @@ impl Config {
        &self.db
    }

-
    pub fn to_json(&self) -> String {
-
        serde_json::to_string_pretty(self).expect("serialize config to JSON")
+
    pub fn to_json(&self) -> Result<String, ConfigError> {
+
        serde_json::to_string_pretty(self).map_err(ConfigError::ToJson)
    }
}

@@ -88,4 +88,8 @@ pub enum ConfigError {
    /// Can't parse config file as YAML.
    #[error("failed to parse configuration file as YAML: {0}")]
    ParseConfig(PathBuf, #[source] serde_yaml::Error),
+

+
    /// Can't convert configuration into JSON.
+
    #[error("failed to convert configuration into JSON")]
+
    ToJson(#[source] serde_json::Error),
}
modified src/db.rs
@@ -248,10 +248,10 @@ impl Db {

    /// Add a new event to the event queue, returning its id.
    pub fn push_queued_event(&self, event: BrokerEvent) -> Result<QueueId, DbError> {
-
        let json = serde_json::to_string(&event).expect("serialize BrokerEvent to JSON");
+
        let json = serde_json::to_string(&event).map_err(DbError::event_to_json)?;

        let id = QueueId::default();
-
        let ts = now();
+
        let ts = now().map_err(DbError::time_format)?;

        let mut insert =
            self.prepare("INSERT INTO event_queue (id, timestamp, event) VALUES (:id, :ts, :e)")?;
@@ -391,7 +391,7 @@ impl Db {
    pub fn push_run(&self, run: Run) -> Result<RunId, DbError> {
        let id = run.adapter_run_id().ok_or(DbError::without_id())?.clone();

-
        let json = serde_json::to_string(&run).expect("serialize BrokerEvent to JSON");
+
        let json = serde_json::to_string(&run).map_err(DbError::event_to_json)?;

        let mut insert = self.prepare("INSERT INTO ci_runs (run_id, json) VALUES (:id, :json)")?;
        insert
@@ -429,10 +429,10 @@ impl Db {
    }
}

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

// A wrapper around a statement that remembers its text form.
@@ -516,6 +516,10 @@ impl QueuedEvent {
/// All errors from this module.
#[derive(Debug, thiserror::Error)]
pub enum DbError {
+
    /// Error formatting a time as a string.
+
    #[error(transparent)]
+
    Timeformat(#[from] time::error::Format),
+

    #[error("failed to set a busy timer one SQLite database {0}")]
    BusyTimer(PathBuf, #[source] sqlite::Error),

@@ -558,6 +562,10 @@ pub enum DbError {
    #[error("failed to parse queued event as JSON: {0}")]
    EventFromJson(String, #[source] serde_json::Error),

+
    /// Can't convert broker event into JSON.
+
    #[error("failed to convert broker event into JSON")]
+
    EventToJson(#[source] serde_json::Error),
+

    #[error("failed to insert an event into queue")]
    PushEvent(String, #[source] sqlite::Error),

@@ -587,6 +595,10 @@ pub enum DbError {
}

impl DbError {
+
    fn time_format(e: time::error::Format) -> Self {
+
        Self::Timeformat(e)
+
    }
+

    fn busy_timer(filename: &Path, e: sqlite::Error) -> Self {
        Self::BusyTimer(filename.into(), e)
    }
@@ -639,6 +651,10 @@ impl DbError {
        Self::EventFromJson(json.into(), e)
    }

+
    fn event_to_json(e: serde_json::Error) -> Self {
+
        Self::EventToJson(e)
+
    }
+

    fn push_event(sql: &str, e: sqlite::Error) -> Self {
        Self::PushEvent(sql.into(), e)
    }
modified src/event.rs
@@ -91,15 +91,15 @@ impl NodeEventSource {
        self.allowed.push(filter);
    }

-
    fn allowed(&self, event: &BrokerEvent) -> bool {
+
    fn allowed(&self, event: &BrokerEvent) -> Result<bool, NodeEventError> {
        for filter in self.allowed.iter() {
-
            if !event.is_allowed(filter) {
+
            if !event.is_allowed(filter)? {
                trace!("event is not allowed");
-
                return false;
+
                return Ok(false);
            }
        }
        trace!("event is allowed");
-
        true
+
        Ok(true)
    }

    /// Get the allowed next event from an event source. This will
@@ -117,7 +117,7 @@ impl NodeEventSource {
                        if let Some(broker_events) = BrokerEvent::from_event(&event) {
                            info!("node event became {} broker events", broker_events.len());
                            for e in broker_events {
-
                                if self.allowed(&e) {
+
                                if self.allowed(&e)? {
                                    info!("allowed event {e:?}");
                                    result.push(e);
                                }
@@ -155,6 +155,10 @@ impl NodeEventSource {
/// Possible errors from accessing the local Radicle node.
#[derive(Debug, thiserror::Error)]
pub enum NodeEventError {
+
    /// Regex compilation error.
+
    #[error("programming error in regular expression {0:?}")]
+
    Regex(&'static str, regex::Error),
+

    /// Node control socket does not exist.
    #[error("node control socket does not exist: {0}")]
    NoControlSocket(PathBuf),
@@ -190,6 +194,10 @@ pub enum NodeEventError {
    /// An error parsing JSON as filters, from an in-memory string.
    #[error("failed to parser filters as JSON")]
    FiltersJsonString(#[source] serde_json::Error),
+

+
    /// An error parsing a Git object id as string into an Oid.
+
    #[error("failed to parse string as a Git object id: {0:?}")]
+
    ParseOid(String, #[source] radicle::git::raw::Error),
}

/// An event filter for allowing events. Or an "AND" combination of events.
@@ -366,14 +374,18 @@ impl BrokerEvent {
    }

    /// Is this broker event allowed by a filter?
-
    fn is_allowed(&self, filter: &EventFilter) -> bool {
+
    fn is_allowed(&self, filter: &EventFilter) -> Result<bool, NodeEventError> {
        debug!("is_allowed called: filter={filter:?}");
-
        let res = self.is_allowed_helper(filter, 0);
+
        let res = self.is_allowed_helper(filter, 0)?;
        debug!("is_allowed: res={res}");
-
        res
+
        Ok(res)
    }

-
    fn is_allowed_helper(&self, filter: &EventFilter, level: usize) -> bool {
+
    fn is_allowed_helper(
+
        &self,
+
        filter: &EventFilter,
+
        level: usize,
+
    ) -> Result<bool, NodeEventError> {
        let prefix = format!("{:width$}", " ", width = level * 4);

        trace!("is_allowed: {prefix} called {self:?}");
@@ -392,7 +404,7 @@ impl BrokerEvent {
                trace!("is_allowed: {prefix} old={old:?}");
                trace!("is_allowed: {prefix} filter={filter:?}");

-
                let parsed = parse_ref(name);
+
                let parsed = parse_ref(name)?;
                trace!("is_allowed: {prefix} parsed={parsed:?}");

                match filter {
@@ -401,28 +413,32 @@ impl BrokerEvent {
                    EventFilter::Branch(wanted) => parsed == Some(ParsedRef::Push(wanted.into())),
                    EventFilter::AnyPatch => matches!(parsed, Some(ParsedRef::Patch(_))),
                    EventFilter::Patch(wanted) => {
-
                        parsed == Some(ParsedRef::Patch(Oid::try_from(wanted.as_str()).unwrap()))
+
                        let oid = Oid::try_from(wanted.as_str())
+
                            .map_err(|e| NodeEventError::ParseOid(wanted.into(), e))?;
+
                        parsed == Some(ParsedRef::Patch(oid))
                    }
                    EventFilter::AnyPatchRef => matches!(parsed, Some(ParsedRef::Patch(_))),
                    EventFilter::AnyPushRef => matches!(parsed, Some(ParsedRef::Push(_))),
                    EventFilter::PatchRef(wanted) => {
-
                        parsed == Some(ParsedRef::Patch(Oid::try_from(wanted.as_str()).unwrap()))
+
                        let oid = Oid::try_from(wanted.as_str())
+
                            .map_err(|e| NodeEventError::ParseOid(wanted.into(), e))?;
+
                        parsed == Some(ParsedRef::Patch(oid))
                    }
                    EventFilter::And(conds) => conds
                        .iter()
-
                        .all(|cond| self.is_allowed_helper(cond, level + 1)),
+
                        .all(|cond| self.is_allowed_helper(cond, level + 1).unwrap_or(false)),
                    EventFilter::Or(conds) => conds
                        .iter()
-
                        .any(|cond| self.is_allowed_helper(cond, level + 1)),
+
                        .any(|cond| self.is_allowed_helper(cond, level + 1).unwrap_or(false)),
                    EventFilter::Not(conds) => !conds
                        .iter()
-
                        .any(|cond| self.is_allowed_helper(cond, level + 1)),
+
                        .any(|cond| self.is_allowed_helper(cond, level + 1).unwrap_or(false)),
                }
            }
        };

        trace!("is_allowed: {prefix} allowed={allowed}");
-
        allowed
+
        Ok(allowed)
    }

    pub fn name(&self) -> Option<&RefString> {
@@ -434,36 +450,37 @@ impl BrokerEvent {

    /// Extract the NID from the RefString.
    /// The RefString will start with `refs/namespaces/<nid>/...`
-
    pub fn nid(&self) -> Option<NodeId> {
+
    pub fn nid(&self) -> Result<Option<NodeId>, NodeEventError> {
        if let Some(name) = self.name() {
-
            parse_nid_from_refstring(name)
+
            Ok(parse_nid_from_refstring(name)?)
        } else {
-
            None
+
            Ok(None)
        }
    }

-
    pub fn patch_id(&self) -> Option<Oid> {
+
    pub fn patch_id(&self) -> Result<Option<Oid>, NodeEventError> {
        if let Some(name) = self.name() {
-
            if let Some(ParsedRef::Patch(oid)) = parse_ref(name) {
-
                return Some(oid);
+
            if let Some(ParsedRef::Patch(oid)) = parse_ref(name)? {
+
                return Ok(Some(oid));
            }
        }
-
        None
+
        Ok(None)
    }
}

/// Extract the NID from a the ref string in a repository.
/// The RefString should start with `refs/namespaces/<nid>/...`
-
pub fn parse_nid_from_refstring(name: &RefString) -> Option<NodeId> {
-
    let pat = Regex::new(r"^refs/namespaces/(?P<nid>[^/]+)/").unwrap();
+
pub fn parse_nid_from_refstring(name: &RefString) -> Result<Option<NodeId>, NodeEventError> {
+
    const PAT: &str = r"^refs/namespaces/(?P<nid>[^/]+)/";
+
    let pat = Regex::new(PAT).map_err(|e| NodeEventError::Regex(PAT, e))?;
    if let Some(captures) = pat.captures(name.as_str()) {
        if let Some(m) = captures.name("nid") {
            if let Ok(parsed) = m.as_str().parse() {
-
                return Some(parsed);
+
                return Ok(Some(parsed));
            }
        }
    }
-
    None
+
    Ok(None)
}

#[cfg(test)]
@@ -473,12 +490,13 @@ mod test_broker_event {
    use super::{BrokerEvent, NodeId, Oid, RefString, RepoId};

    #[test]
-
    fn name_for_branch() {
-
        let rid = RepoId::from_urn("rad:zwTxygwuz5LDGBq255RA2CbNGrz8").expect("RepoId");
-
        let name = RefString::try_from("main").expect("RefString");
-
        let oid = Oid::from_str("11d03559fb10183b0f14331175f254fbb077159a").expect("Oid");
+
    fn name_for_branch() -> anyhow::Result<()> {
+
        let rid = RepoId::from_urn("rad:zwTxygwuz5LDGBq255RA2CbNGrz8")?;
+
        let name = RefString::try_from("main")?;
+
        let oid = Oid::from_str("11d03559fb10183b0f14331175f254fbb077159a")?;
        let be = BrokerEvent::new(&rid, &name, &oid, None);
        assert_eq!(be.name(), Some(&name));
+
        Ok(())
    }

    #[test]
@@ -488,35 +506,36 @@ mod test_broker_event {
    }

    #[test]
-
    fn nid_for_plain_branch_name() {
-
        let rid = RepoId::from_urn("rad:zwTxygwuz5LDGBq255RA2CbNGrz8").expect("RepoId");
-
        let name = RefString::try_from("main").expect("RefString");
-
        let oid = Oid::from_str("11d03559fb10183b0f14331175f254fbb077159a").expect("Oid");
+
    fn nid_for_plain_branch_name() -> anyhow::Result<()> {
+
        let rid = RepoId::from_urn("rad:zwTxygwuz5LDGBq255RA2CbNGrz8")?;
+
        let name = RefString::try_from("main")?;
+
        let oid = Oid::from_str("11d03559fb10183b0f14331175f254fbb077159a")?;
        let be = BrokerEvent::new(&rid, &name, &oid, None);
-
        assert_eq!(be.nid(), None);
+
        assert_eq!(be.nid()?, None);
+
        Ok(())
    }

    #[test]
-
    fn nid_for_ref_without_namespace() {
-
        let rid = RepoId::from_urn("rad:zwTxygwuz5LDGBq255RA2CbNGrz8").expect("RepoId");
-
        let name = RefString::try_from("something/else/main").expect("RefString");
-
        let oid = Oid::from_str("11d03559fb10183b0f14331175f254fbb077159a").expect("Oid");
+
    fn nid_for_ref_without_namespace() -> anyhow::Result<()> {
+
        let rid = RepoId::from_urn("rad:zwTxygwuz5LDGBq255RA2CbNGrz8")?;
+
        let name = RefString::try_from("something/else/main")?;
+
        let oid = Oid::from_str("11d03559fb10183b0f14331175f254fbb077159a")?;
        let be = BrokerEvent::new(&rid, &name, &oid, None);
-
        assert_eq!(be.nid(), None);
+
        assert_eq!(be.nid()?, None);
+
        Ok(())
    }

    #[test]
-
    fn nid_for_ref_with_namespace() {
-
        let rid = RepoId::from_urn("rad:zwTxygwuz5LDGBq255RA2CbNGrz8").expect("RepoId");
+
    fn nid_for_ref_with_namespace() -> anyhow::Result<()> {
+
        let rid = RepoId::from_urn("rad:zwTxygwuz5LDGBq255RA2CbNGrz8")?;
        let name = RefString::try_from(
            "refs/namespaces/z6MkgEMYod7Hxfy9qCvDv5hYHkZ4ciWmLFgfvm3Wn1b2w2FV/main",
-
        )
-
        .expect("RefString");
-
        let nid =
-
            NodeId::from_str("z6MkgEMYod7Hxfy9qCvDv5hYHkZ4ciWmLFgfvm3Wn1b2w2FV").expect("NodeId");
-
        let oid = Oid::from_str("11d03559fb10183b0f14331175f254fbb077159a").expect("Oid");
+
        )?;
+
        let nid = NodeId::from_str("z6MkgEMYod7Hxfy9qCvDv5hYHkZ4ciWmLFgfvm3Wn1b2w2FV")?;
+
        let oid = Oid::from_str("11d03559fb10183b0f14331175f254fbb077159a")?;
        let be = BrokerEvent::new(&rid, &name, &oid, None);
-
        assert_eq!(be.nid(), Some(nid));
+
        assert_eq!(be.nid()?, Some(nid));
+
        Ok(())
    }
}

@@ -544,32 +563,35 @@ pub enum ParsedRef {
///     }
/// }
/// ```
-
pub fn parse_ref(s: &str) -> Option<ParsedRef> {
+
pub fn parse_ref(s: &str) -> Result<Option<ParsedRef>, NodeEventError> {
    trace!("parse_ref: s={s:?}");

-
    let patch_re = Regex::new(r"^refs/namespaces/[^/]+/refs/heads/patches/([^/]+)$").unwrap();
+
    const PAT_PATCH: &str = r"^refs/namespaces/[^/]+/refs/heads/patches/([^/]+)$";
+
    let patch_re = Regex::new(PAT_PATCH).map_err(|e| NodeEventError::Regex(PAT_PATCH, e))?;
    if let Some(patch_captures) = patch_re.captures(s) {
        trace!("parse_ref: patch_captures={patch_captures:?}");
        if let Some(patch_id) = patch_captures.get(1) {
            trace!("parse_ref: patch_id={patch_id:?}");
            let patch_id_str = patch_id.as_str();
-
            let oid = Oid::try_from(patch_id_str).unwrap();
+
            let oid = Oid::try_from(patch_id_str)
+
                .map_err(|e| NodeEventError::ParseOid(patch_id_str.into(), e))?;
            trace!("parse_ref: patch oid={oid:?}");
-
            return Some(ParsedRef::Patch(oid));
+
            return Ok(Some(ParsedRef::Patch(oid)));
        }
    }

-
    let push_re = Regex::new(r"^refs/namespaces/[^/]+/refs/heads/(.+)$").unwrap();
+
    const PAT_BRANCH: &str = r"^refs/namespaces/[^/]+/refs/heads/(.+)$";
+
    let push_re = Regex::new(PAT_BRANCH).map_err(|e| NodeEventError::Regex(PAT_BRANCH, e))?;
    if let Some(push_captures) = push_re.captures(s) {
        trace!("parse_ref: push_captures={push_captures:?}");
        if let Some(branch) = push_captures.get(1) {
            trace!("parse_ref: branch={branch:?}");
-
            return Some(ParsedRef::Push(branch.as_str().to_string()));
+
            return Ok(Some(ParsedRef::Push(branch.as_str().to_string())));
        }
    }

    trace!("parse_ref: neither push nor patch");
-
    None
+
    Ok(None)
}

#[cfg(test)]
@@ -586,41 +608,45 @@ mod test_parse_ref {
    use super::{log_init, parse_ref, Oid, ParsedRef};

    #[test]
-
    fn plain_branch_name_is_none() {
+
    fn plain_branch_name_is_none() -> anyhow::Result<()> {
        log_init();
-
        assert_eq!(parse_ref("main"), None);
+
        assert_eq!(parse_ref("main")?, None);
+
        Ok(())
    }

    #[test]
-
    fn namespaced_branch() {
+
    fn namespaced_branch() -> anyhow::Result<()> {
        log_init();
        assert_eq!(
            parse_ref(
                "refs/namespaces/z6MkfBU2cwcZfaE6Z1dLqb7Ve7u4pdgbSo9tP6qUVsqFn2xv/refs/heads/main"
-
            ),
+
            )?,
            Some(ParsedRef::Push("main".into()))
        );
+
        Ok(())
    }

    #[test]
-
    fn namespaced_branch_with_slashes() {
+
    fn namespaced_branch_with_slashes() -> anyhow::Result<()> {
        log_init();
        assert_eq!(
            parse_ref(
                "refs/namespaces/z6MkfBU2cwcZfaE6Z1dLqb7Ve7u4pdgbSo9tP6qUVsqFn2xv/refs/heads/liw/cob/draft/v2"
-
            ),
+
            )?,
            Some(ParsedRef::Push("liw/cob/draft/v2".into()))
        );
+
        Ok(())
    }

    #[test]
-
    fn namespaced_patch() {
+
    fn namespaced_patch() -> anyhow::Result<()> {
        log_init();
        const SHA: &str = "0a4c69183fc8b8d849f5ab977d70f2a1f4788bca";
        assert_eq!(
-
            parse_ref(&format!("refs/namespaces/NID/refs/heads/patches/{SHA}")),
-
            Some(ParsedRef::Patch(Oid::try_from(SHA).unwrap()))
+
            parse_ref(&format!("refs/namespaces/NID/refs/heads/patches/{SHA}"))?,
+
            Some(ParsedRef::Patch(Oid::try_from(SHA)?))
        );
+
        Ok(())
    }
}

@@ -649,25 +675,26 @@ mod test {
    use super::{is_patch_update, log_init, parse_ref, push_branch, Oid, ParsedRef};

    #[test]
-
    fn test_parse_patch_ref() {
+
    fn test_parse_patch_ref() -> anyhow::Result<()> {
        log_init();
        let patch_ref =
            "refs/namespaces/NID/refs/heads/patches/9183ed6232687d3105482960cecb01a53018b80a";

        assert_eq!(
-
            parse_ref(patch_ref),
-
            Some(ParsedRef::Patch(
-
                Oid::try_from("9183ed6232687d3105482960cecb01a53018b80a").unwrap()
-
            ))
+
            parse_ref(patch_ref)?,
+
            Some(ParsedRef::Patch(Oid::try_from(
+
                "9183ed6232687d3105482960cecb01a53018b80a"
+
            )?))
        );
+
        Ok(())
    }

    #[test]
-
    fn test_parse_push_ref() {
+
    fn test_parse_push_ref() -> anyhow::Result<()> {
        log_init();
        let push_ref =
            "refs/namespaces/z6MkuhvCnrcow7vzkyQzkuFixzpTa42iC2Cfa4DA8HRLCmys/refs/heads/main";
-
        let parsed_ref = parse_ref(push_ref);
+
        let parsed_ref = parse_ref(push_ref)?;
        eprintln!("parsed_ref={parsed_ref:#?}");
        assert!(parsed_ref.is_some());
        if let Some(ref parsed) = parsed_ref {
@@ -676,14 +703,16 @@ mod test {
                _ => panic!("Expected Push ref"),
            }
        }
+
        Ok(())
    }

    #[test]
-
    fn test_parse_invalid_ref() {
+
    fn test_parse_invalid_ref() -> anyhow::Result<()> {
        log_init();
        let invalid_ref = "invalid_ref";
-
        let parsed_ref = parse_ref(invalid_ref);
+
        let parsed_ref = parse_ref(invalid_ref)?;
        assert!(parsed_ref.is_none());
+
        Ok(())
    }

    #[test]
modified src/msg.rs
@@ -153,7 +153,7 @@ impl<'a> RequestBuilder<'a> {
            }
        };

-
        match parse_ref(name) {
+
        match parse_ref(name)? {
            None => Err(MessageError::NoEventHandler),
            Some(ParsedRef::Patch(_oid)) => Ok(Request::Trigger {
                common: EventCommonFields {
@@ -192,7 +192,7 @@ impl<'a> RequestBuilder<'a> {
        profile: &Profile,
        author: Author,
    ) -> Result<PatchEvent, MessageError> {
-
        let patch_id = event.patch_id().ok_or(MessageError::Trigger)?;
+
        let patch_id = event.patch_id()?.ok_or(MessageError::Trigger)?;
        let patch = patch::Patches::open(&repository)?
            .get(&patch_id.into())?
            .ok_or(MessageError::Trigger)?;
@@ -369,7 +369,7 @@ fn did_to_author(profile: &Profile, did: &Did) -> Result<Author, MessageError> {
}

fn extract_author(profile: &Profile, event: &BrokerEvent) -> Result<Author, MessageError> {
-
    let nid = match event.nid() {
+
    let nid = match event.nid()? {
        Some(nid) => nid,
        None => {
            return Err(MessageError::Trigger);
@@ -741,6 +741,10 @@ pub enum MessageError {
    #[error(transparent)]
    StorageError(#[from] radicle::storage::Error),

+
    /// Error from event module.
+
    #[error(transparent)]
+
    EventError(#[from] crate::event::NodeEventError),
+

    /// Error from Radicle repository.
    #[error(transparent)]
    RepositoryError(#[from] radicle::storage::RepositoryError),
@@ -795,8 +799,7 @@ pub mod tests {
        let req = RequestBuilder::default()
            .profile(&profile)
            .broker_event(&be)
-
            .build_trigger()
-
            .unwrap();
+
            .build_trigger()?;
        let Request::Trigger {
            common,
            push,
@@ -864,8 +867,7 @@ pub mod tests {
        let req = RequestBuilder::default()
            .profile(&profile)
            .broker_event(&be)
-
            .build_trigger()
-
            .unwrap();
+
            .build_trigger()?;
        let Request::Trigger {
            common,
            push,
modified src/pages.rs
@@ -37,6 +37,10 @@ const UPDATE_INTERVAL: Duration = Duration::from_secs(60);
/// All possible errors returned from the status page module.
#[derive(Debug, thiserror::Error)]
pub enum PageError {
+
    /// Error formatting a time as a string.
+
    #[error(transparent)]
+
    Timeformat(#[from] time::error::Format),
+

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

@@ -48,6 +52,12 @@ pub enum PageError {

    #[error(transparent)]
    Db(#[from] DbError),
+

+
    #[error("failed to lock page data structure")]
+
    Lock(&'static str),
+

+
    #[error("failed to represent status page data as JSON")]
+
    StatusToJson(#[source] serde_json::Error),
}

/// A builder for constructing a [`StatusPage`] value. It will only
@@ -77,7 +87,7 @@ impl PageBuilder {
        debug!("broker database has {} CI runs", runs.len());

        Ok(StatusPage::new(PageData {
-
            timestamp: now(),
+
            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)?,
@@ -89,9 +99,9 @@ impl PageBuilder {
    }
}

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

struct PageData {
@@ -106,7 +116,7 @@ struct PageData {
}

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

        doc.push_to_head(
@@ -142,7 +152,7 @@ impl PageData {
                .with_text(")"),
        );

-
        let status = StatusData::from(self).as_json();
+
        let status = StatusData::from(self).as_json()?;
        doc.push_to_body(
            Element::new(Tag::P).with_child(
                Element::new(Tag::A)
@@ -229,7 +239,7 @@ impl PageData {
        }
        doc.push_to_body(list);

-
        doc
+
        Ok(doc)
    }

    fn whence_as_html(whence: &Whence) -> Element {
@@ -459,33 +469,38 @@ impl StatusPage {
        }
    }

-
    fn lock(&mut self) -> MutexGuard<PageData> {
-
        self.data.lock().expect("lock StatusPage::data")
+
    fn lock(&mut self) -> Result<MutexGuard<PageData>, PageError> {
+
        self.data
+
            .lock()
+
            .map_err(|_| PageError::Lock("lock StatusPage::data"))
    }

    pub fn set_output_dir(&mut self, dirname: &Path) {
        self.dirname = Some(dirname.into());
    }

-
    pub fn update_timestamp(&mut self) {
-
        let mut data = self.lock();
-
        data.timestamp = now();
+
    pub fn update_timestamp(&mut self) -> Result<(), PageError> {
+
        let mut data = self.lock()?;
+
        data.timestamp = now()?;
+
        Ok(())
    }

-
    pub fn broker_event(&mut self, event: &BrokerEvent) {
-
        let mut data = self.lock();
+
    pub fn broker_event(&mut self, event: &BrokerEvent) -> Result<(), PageError> {
+
        let mut data = self.lock()?;
        data.latest_broker_event = Some(event.clone());
        data.broker_event_counter += 1;
+
        Ok(())
    }

    /// Add a new CI run to the status page.
-
    pub fn push_run(&mut self, new: Run) {
+
    pub fn push_run(&mut self, new: Run) -> Result<(), PageError> {
        // We silently ignore a run until its id has been set.
        if let Some(id) = new.adapter_run_id() {
-
            let mut data = self.lock();
+
            let mut data = self.lock()?;
            data.latest_ci_run = Some(new.clone());
            data.runs.insert(id.clone(), new);
        }
+
        Ok(())
    }

    pub fn update_in_thread(
@@ -532,9 +547,9 @@ impl StatusPage {
        // We avoid writing while keeping the lock, to reduce
        // contention.
        let (status, repos) = {
-
            let data = self.lock();
+
            let data = self.lock()?;

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

            let mut repos = vec![];
            for (_, rid) in data.repos() {
@@ -548,10 +563,11 @@ impl StatusPage {
            (status, repos)
        };

-
        Self::write_file(&dirname.join("index.html"), &status).unwrap();
+
        let filename = dirname.join("index.html");
+
        Self::write_file(&filename, &status)?;

        for (filename, repopage) in repos {
-
            Self::write_file(&filename, &repopage).unwrap();
+
            Self::write_file(&filename, &repopage)?;
        }

        Ok(())
@@ -562,18 +578,16 @@ impl StatusPage {
        // We avoid writing while keeping the lock, to reduce
        // contention.
        let status = {
-
            let data = self.lock();
-
            StatusData::from(&*data).as_json()
+
            let data = self.lock()?;
+
            StatusData::from(&*data).as_json()?
        };

-
        Self::write_file(filename, &status).unwrap();
-

-
        Ok(())
+
        Self::write_file(filename, &status)
    }

    fn write_file(filename: &Path, text: &str) -> Result<(), PageError> {
        debug!("write file {}", filename.display());
-
        write(filename, text).unwrap();
+
        write(filename, text).map_err(|e| PageError::Write(filename.into(), e))?;
        Ok(())
    }
}
@@ -599,8 +613,8 @@ struct StatusData {
}

impl StatusData {
-
    fn as_json(&self) -> String {
-
        serde_json::to_string_pretty(self).unwrap()
+
    fn as_json(&self) -> Result<String, PageError> {
+
        serde_json::to_string_pretty(self).map_err(PageError::StatusToJson)
    }
}

modified src/run.rs
@@ -179,8 +179,9 @@ mod test {
    use super::*;

    #[test]
-
    fn serialize_run_state() {
-
        let s = serde_json::to_string(&RunState::Finished).unwrap();
+
    fn serialize_run_state() -> anyhow::Result<()> {
+
        let s = serde_json::to_string(&RunState::Finished)?;
        assert_eq!(s, r#""finished""#);
+
        Ok(())
    }
}