Skip to main content

callisto_cli/
error.rs

1use callisto_graph::locate::LocateError;
2use callisto_graph::{ConfigError, GraphError};
3use callisto_model::CommandError;
4use miette::Diagnostic;
5
6/// Formats a [`CliError`] as a JSON value with a consistent error envelope:
7///
8/// ```json
9/// {
10///   "schemaVersion": 1,
11///   "error": {
12///     "code": "callisto::some_code",
13///     "message": "human-readable error text",
14///     "help": "optional guidance string or null"
15///   }
16/// }
17/// ```
18///
19/// `"code"`, `"message"`, and `"help"` are always present; `"help"` is `null`
20/// when the diagnostic provides no help text.  This guarantees a stable shape
21/// regardless of which [`CliError`] variant is serialized.
22pub fn format_error_json(err: &CliError) -> serde_json::Value {
23    let code = err
24        .code()
25        .map(|c| c.to_string())
26        .unwrap_or_else(|| "callisto::error".to_string());
27    let help = err.help().map(|h| h.to_string());
28    serde_json::json!({
29        "schemaVersion": callisto_model::SCHEMA_VERSION,
30        "error": {
31            "code": code,
32            "message": err.to_string(),
33            "help": help,
34        }
35    })
36}
37
38#[derive(Debug, thiserror::Error, Diagnostic)]
39#[non_exhaustive]
40pub enum CliError {
41    #[error(transparent)]
42    #[diagnostic(transparent)]
43    Graph(#[from] GraphError),
44
45    #[error(transparent)]
46    #[diagnostic(transparent)]
47    Locate(#[from] LocateError),
48
49    #[error(transparent)]
50    #[diagnostic(transparent)]
51    Config(#[from] ConfigError),
52
53    #[error(transparent)]
54    #[diagnostic(transparent)]
55    Command(#[from] CommandError),
56
57    #[error(transparent)]
58    #[diagnostic(
59        code(callisto::registry_error),
60        help("verify registry credentials/authentication and network connectivity, then retry")
61    )]
62    Registry(#[from] callisto_model::RegistryError),
63
64    #[error(transparent)]
65    #[diagnostic(transparent)]
66    ChangesetParse(#[from] callisto_format::ParseError),
67
68    #[error(transparent)]
69    #[diagnostic(transparent)]
70    ChangesetWrite(#[from] callisto_format::WriteError),
71
72    #[error(transparent)]
73    #[diagnostic(transparent)]
74    Manifest(#[from] callisto_model::ManifestError),
75
76    #[error(transparent)]
77    #[diagnostic(transparent)]
78    Vcs(#[from] callisto_vcs::VcsError),
79
80    #[error(transparent)]
81    #[diagnostic(code(callisto::pre_json_error))]
82    PreJson(#[from] callisto_format::PreJsonError),
83
84    #[error("I/O error{}", match &path {
85        Some(p) => format!(" accessing `{}`", p.display()),
86        None => String::new(),
87    })]
88    #[diagnostic(
89        code(callisto::io_error),
90        help("check that the path exists and that you have permission to access it")
91    )]
92    Io {
93        #[source]
94        source: std::io::Error,
95        path: Option<std::path::PathBuf>,
96    },
97
98    #[error("refusing to prompt interactively: stdin is not a terminal and no non-interactive flags were given")]
99    #[diagnostic(
100        code(callisto::not_a_tty),
101        help("specify package names explicitly via `callisto add --package <name>:<severity>` in CI environments")
102    )]
103    NotATty,
104
105    #[error("{0}")]
106    #[diagnostic(code(callisto::error))]
107    Other(String),
108}
109
110impl From<std::io::Error> for CliError {
111    fn from(source: std::io::Error) -> Self {
112        CliError::Io { source, path: None }
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    /// Every JSON error object must have `"code"`, `"message"`, and `"help"` keys
121    /// regardless of which variant is serialized.
122    fn assert_envelope_shape(json: &serde_json::Value) {
123        assert_eq!(
124            json["schemaVersion"],
125            serde_json::json!(callisto_model::SCHEMA_VERSION),
126            "schemaVersion must be present and match SCHEMA_VERSION"
127        );
128        let error = &json["error"];
129        assert!(
130            error.is_object(),
131            "top-level 'error' key must be an object, got: {error:?}"
132        );
133        assert!(
134            error["code"].is_string(),
135            "error.code must always be a string, got: {:?}",
136            error["code"]
137        );
138        assert!(
139            error["message"].is_string(),
140            "error.message must always be a string, got: {:?}",
141            error["message"]
142        );
143        // help is present as a key always; its value is either a string or null
144        assert!(
145            error["help"].is_string() || error["help"].is_null(),
146            "error.help must be a string or null, got: {:?}",
147            error["help"]
148        );
149    }
150
151    #[test]
152    fn format_error_json_other_has_stable_envelope() {
153        let err = CliError::Other("something went wrong".to_string());
154        let json = format_error_json(&err);
155        assert_envelope_shape(&json);
156        assert_eq!(json["error"]["code"], "callisto::error");
157        assert_eq!(json["error"]["message"], "something went wrong");
158        // Other has no help text
159        assert!(json["error"]["help"].is_null());
160    }
161
162    #[test]
163    fn format_error_json_not_a_tty_includes_code_and_help() {
164        let err = CliError::NotATty;
165        let json = format_error_json(&err);
166        assert_envelope_shape(&json);
167        assert_eq!(json["error"]["code"], "callisto::not_a_tty");
168        assert!(
169            !json["error"]["message"].as_str().unwrap().is_empty(),
170            "message must be non-empty"
171        );
172        let help = json["error"]["help"].as_str().expect("NotATty must have help text");
173        assert!(
174            help.contains("callisto add --package"),
175            "help should reference the --package flag; got: {help}"
176        );
177    }
178
179    #[test]
180    fn format_error_json_io_error_includes_code_and_help() {
181        let err = CliError::Io {
182            source: std::io::Error::new(std::io::ErrorKind::NotFound, "file missing"),
183            path: Some(std::path::PathBuf::from("/some/path")),
184        };
185        let json = format_error_json(&err);
186        assert_envelope_shape(&json);
187        assert_eq!(json["error"]["code"], "callisto::io_error");
188        assert!(
189            json["error"]["message"].as_str().unwrap().contains("/some/path"),
190            "I/O error message should include the path"
191        );
192        let help = json["error"]["help"].as_str().expect("Io must have help text");
193        assert!(
194            help.contains("path exists"),
195            "help should reference path existence check; got: {help}"
196        );
197    }
198
199    /// Structural consistency: two different error variants must produce the same
200    /// top-level key set (code + message + help always present).
201    #[test]
202    fn format_error_json_structure_is_consistent_across_variants() {
203        let errors: &[CliError] = &[CliError::Other("first".to_string()), CliError::NotATty];
204        let jsons: Vec<serde_json::Value> = errors.iter().map(format_error_json).collect();
205        for json in &jsons {
206            assert_envelope_shape(json);
207        }
208        // Both must have the same top-level keys
209        let keys_0: std::collections::BTreeSet<String> =
210            jsons[0]["error"].as_object().unwrap().keys().cloned().collect();
211        let keys_1: std::collections::BTreeSet<String> =
212            jsons[1]["error"].as_object().unwrap().keys().cloned().collect();
213        assert_eq!(keys_0, keys_1, "All error variants must produce the same JSON key set");
214    }
215}