Skip to main content

ocelot_base/
cli.rs

1use crate::error::OcelotError;
2use crate::result::OcelotResult;
3use std::fmt::Write as _;
4use std::process::ExitCode;
5
6/// What does `try_main` provide for CLI binaries?
7///
8/// It runs a fallible entrypoint, prints a readable error report on failure,
9/// and converts the outcome into a process exit code.
10pub fn try_main(run: impl FnOnce() -> OcelotResult<()>) -> ExitCode {
11    match run() {
12        Ok(()) => ExitCode::SUCCESS,
13        Err(error) => {
14            eprint!("{}", format_cli_error("operation failed", &error));
15            ExitCode::FAILURE
16        }
17    }
18}
19
20/// What does `try_main_with_headline` provide?
21///
22/// It behaves like [`try_main`], but lets a binary choose a more specific
23/// headline for its top-level error report.
24pub fn try_main_with_headline(headline: &str, run: impl FnOnce() -> OcelotResult<()>) -> ExitCode {
25    match run() {
26        Ok(()) => ExitCode::SUCCESS,
27        Err(error) => {
28            eprint!("{}", format_cli_error(headline, &error));
29            ExitCode::FAILURE
30        }
31    }
32}
33
34/// What does `format_cli_error` return?
35///
36/// It returns a stable, human-readable rendering of a [`OcelotError`] that is
37/// suitable for printing from a command-line binary.
38pub fn format_cli_error(headline: &str, error: &OcelotError) -> String {
39    let mut rendered = String::new();
40    let _ = writeln!(&mut rendered, "\u{1b}[1;31m━━ {}\u{1b}[0m", headline);
41    if error.write_to(&mut rendered).is_err() {
42        let _ = writeln!(
43            &mut rendered,
44            "\u{1b}[1;31m× error\u{1b}[0m failed to render detailed error output"
45        );
46    }
47
48    let mut causes = Vec::new();
49    let mut current = error.source();
50    while let Some(cause) = current {
51        causes.push((cause.kind().to_string(), cause.location()));
52        current = cause.source();
53    }
54
55    if !causes.is_empty() {
56        let simple_causes: Vec<_> = causes
57            .iter()
58            .filter(|(cause, _)| !cause.contains('\n'))
59            .collect();
60        if simple_causes.is_empty() {
61            return rendered;
62        }
63
64        rendered.push('\n');
65        rendered.push_str("\u{1b}[1;33m━━ cause chain\u{1b}[0m\n");
66        for (cause, location) in simple_causes {
67            let _ = writeln!(&mut rendered, "  • {}", cause);
68            let _ = writeln!(
69                &mut rendered,
70                "    at {}:{}:{}",
71                location.file(),
72                location.line(),
73                location.column()
74            );
75        }
76    }
77
78    rendered
79}
80
81#[cfg(test)]
82mod tests {
83    use crate::error::OcelotError;
84    use expect_test::expect;
85
86    use super::format_cli_error;
87
88    #[test]
89    fn format_cli_error_renders_headline_and_cause_chain() {
90        let error = OcelotError::message("failed to verify")
91            .with_source(OcelotError::message("missing reference output"));
92
93        expect!([r#"
94            ━━ verification failed
95            × error failed to verify
96              at crates/base/src/cli.rs:90:21
97            caused by: missing reference output
98                 at crates/base/src/cli.rs:91:26
99
100            ━━ cause chain
101              • missing reference output
102                at crates/base/src/cli.rs:91:26
103        "#])
104        .assert_eq(&crate::unansi(&format_cli_error(
105            "verification failed",
106            &error,
107        )));
108    }
109
110    #[test]
111    fn format_cli_error_skips_cause_chain_for_multiline_cause() {
112        let error = OcelotError::message("failed to load recipe")
113            .with_source(OcelotError::message("line one\nline two"));
114
115        expect!([r#"
116            ━━ recipe failed
117            × error failed to load recipe
118              at crates/base/src/cli.rs:112:21
119            caused by:
120               line one
121               line two
122                 at crates/base/src/cli.rs:113:26
123        "#])
124        .assert_eq(&crate::unansi(&format_cli_error("recipe failed", &error)));
125    }
126}