Skip to main content

aristo_cli/
error.rs

1//! CLI error type and exit-code mapping.
2//!
3//! New variants are added by their first concrete user. Exit codes
4//! follow loose `sysexits.h` precedent where reasonable: 0 success,
5//! 1 general failure, 2 misuse / clap rejection, 64 not-yet-implemented.
6
7use std::fmt;
8use std::path::PathBuf;
9
10pub type CliResult<T> = Result<T, CliError>;
11
12/// Top-level CLI failure mode.
13#[derive(Debug)]
14pub enum CliError {
15    /// A defined subcommand whose body has not yet landed. The `hint`
16    /// field is appended to the message so the user knows where to
17    /// look for the planned design / when to expect it.
18    NotImplemented {
19        what: &'static str,
20        hint: &'static str,
21    },
22    /// The current working directory is not inside an Aristo workspace
23    /// (no `aristo.toml` found by walking upward).
24    NotInWorkspace { searched_from: PathBuf },
25    /// I/O failure during file read/write.
26    Io(std::io::Error),
27    /// Catch-all for command-specific errors that carry their own message
28    /// and exit code. Use when no structured variant fits and the message
29    /// is the user-facing diagnostic (e.g. `aristo lang`'s
30    /// "no supported language detected").
31    Other { message: String, exit_code: u8 },
32    /// Non-success exit where the command has already printed its own
33    /// human-readable output (e.g. `aristo lint --check` listing
34    /// findings). Lib's top-level wrapper skips the generic
35    /// `error: ...` line for this variant.
36    Silent { exit_code: u8 },
37}
38
39impl fmt::Display for CliError {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        match self {
42            CliError::NotImplemented { what, hint } => {
43                write!(f, "{what} is not implemented yet — {hint}")
44            }
45            CliError::NotInWorkspace { searched_from } => {
46                write!(
47                    f,
48                    "not inside an Aristo workspace (no aristo.toml found at or above {})\n\
49                     hint: run `aristo init` to bootstrap a new workspace here",
50                    searched_from.display()
51                )
52            }
53            CliError::Io(e) => write!(f, "io: {e}"),
54            CliError::Other { message, .. } => write!(f, "{message}"),
55            CliError::Silent { .. } => Ok(()),
56        }
57    }
58}
59
60impl CliError {
61    /// True iff the error wants the top-level wrapper to skip its
62    /// generic `error: ...` print (because the command already produced
63    /// its own structured output).
64    pub fn is_silent(&self) -> bool {
65        matches!(self, CliError::Silent { .. })
66    }
67}
68
69impl std::error::Error for CliError {
70    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
71        match self {
72            CliError::Io(e) => Some(e),
73            _ => None,
74        }
75    }
76}
77
78impl From<std::io::Error> for CliError {
79    fn from(e: std::io::Error) -> Self {
80        CliError::Io(e)
81    }
82}
83
84impl CliError {
85    /// Process exit code for this error class.
86    pub fn exit_code(&self) -> u8 {
87        match self {
88            CliError::NotImplemented { .. } => 64,
89            CliError::NotInWorkspace { .. } => 2,
90            CliError::Io(_) => 1,
91            CliError::Other { exit_code, .. } => *exit_code,
92            CliError::Silent { exit_code } => *exit_code,
93        }
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    #[test]
102    fn not_implemented_message_includes_what_and_hint() {
103        let e = CliError::NotImplemented {
104            what: "aristo init",
105            hint: "see docs/deferred/...",
106        };
107        let msg = e.to_string();
108        assert!(msg.contains("aristo init"), "msg: {msg}");
109        assert!(msg.contains("see docs/deferred/..."), "msg: {msg}");
110        assert!(msg.contains("not implemented yet"), "msg: {msg}");
111    }
112
113    #[test]
114    fn not_in_workspace_includes_search_origin_and_hint() {
115        let e = CliError::NotInWorkspace {
116            searched_from: PathBuf::from("/tmp/elsewhere"),
117        };
118        let msg = e.to_string();
119        assert!(msg.contains("/tmp/elsewhere"));
120        assert!(msg.contains("aristo init"), "should hint at init: {msg}");
121    }
122
123    #[test]
124    fn exit_codes_are_distinct_per_class() {
125        let ni = CliError::NotImplemented {
126            what: "x",
127            hint: "y",
128        }
129        .exit_code();
130        let nw = CliError::NotInWorkspace {
131            searched_from: PathBuf::new(),
132        }
133        .exit_code();
134        let io = CliError::Io(std::io::Error::other("boom")).exit_code();
135        assert_ne!(ni, nw);
136        assert_ne!(nw, io);
137        assert_ne!(ni, io);
138    }
139}