1use callisto_graph::locate::LocateError;
2use callisto_graph::{ConfigError, GraphError};
3use callisto_model::CommandError;
4use miette::Diagnostic;
5
6pub 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 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 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 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 #[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 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}