use callisto_graph::locate::LocateError;
use callisto_graph::{ConfigError, GraphError};
use callisto_model::CommandError;
use miette::Diagnostic;
pub fn format_error_json(err: &CliError) -> serde_json::Value {
let code = err
.code()
.map(|c| c.to_string())
.unwrap_or_else(|| "callisto::error".to_string());
let help = err.help().map(|h| h.to_string());
serde_json::json!({
"schemaVersion": callisto_model::SCHEMA_VERSION,
"error": {
"code": code,
"message": err.to_string(),
"help": help,
}
})
}
#[derive(Debug, thiserror::Error, Diagnostic)]
#[non_exhaustive]
pub enum CliError {
#[error(transparent)]
#[diagnostic(transparent)]
Graph(#[from] GraphError),
#[error(transparent)]
#[diagnostic(transparent)]
Locate(#[from] LocateError),
#[error(transparent)]
#[diagnostic(transparent)]
Config(#[from] ConfigError),
#[error(transparent)]
#[diagnostic(transparent)]
Command(#[from] CommandError),
#[error(transparent)]
#[diagnostic(
code(callisto::registry_error),
help("verify registry credentials/authentication and network connectivity, then retry")
)]
Registry(#[from] callisto_model::RegistryError),
#[error(transparent)]
#[diagnostic(transparent)]
ChangesetParse(#[from] callisto_format::ParseError),
#[error(transparent)]
#[diagnostic(transparent)]
ChangesetWrite(#[from] callisto_format::WriteError),
#[error(transparent)]
#[diagnostic(transparent)]
Manifest(#[from] callisto_model::ManifestError),
#[error(transparent)]
#[diagnostic(transparent)]
Vcs(#[from] callisto_vcs::VcsError),
#[error(transparent)]
#[diagnostic(code(callisto::pre_json_error))]
PreJson(#[from] callisto_format::PreJsonError),
#[error("I/O error{}", match &path {
Some(p) => format!(" accessing `{}`", p.display()),
None => String::new(),
})]
#[diagnostic(
code(callisto::io_error),
help("check that the path exists and that you have permission to access it")
)]
Io {
#[source]
source: std::io::Error,
path: Option<std::path::PathBuf>,
},
#[error("refusing to prompt interactively: stdin is not a terminal and no non-interactive flags were given")]
#[diagnostic(
code(callisto::not_a_tty),
help("specify package names explicitly via `callisto add --package <name>:<severity>` in CI environments")
)]
NotATty,
#[error("{0}")]
#[diagnostic(code(callisto::error))]
Other(String),
}
impl From<std::io::Error> for CliError {
fn from(source: std::io::Error) -> Self {
CliError::Io { source, path: None }
}
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_envelope_shape(json: &serde_json::Value) {
assert_eq!(
json["schemaVersion"],
serde_json::json!(callisto_model::SCHEMA_VERSION),
"schemaVersion must be present and match SCHEMA_VERSION"
);
let error = &json["error"];
assert!(
error.is_object(),
"top-level 'error' key must be an object, got: {error:?}"
);
assert!(
error["code"].is_string(),
"error.code must always be a string, got: {:?}",
error["code"]
);
assert!(
error["message"].is_string(),
"error.message must always be a string, got: {:?}",
error["message"]
);
assert!(
error["help"].is_string() || error["help"].is_null(),
"error.help must be a string or null, got: {:?}",
error["help"]
);
}
#[test]
fn format_error_json_other_has_stable_envelope() {
let err = CliError::Other("something went wrong".to_string());
let json = format_error_json(&err);
assert_envelope_shape(&json);
assert_eq!(json["error"]["code"], "callisto::error");
assert_eq!(json["error"]["message"], "something went wrong");
assert!(json["error"]["help"].is_null());
}
#[test]
fn format_error_json_not_a_tty_includes_code_and_help() {
let err = CliError::NotATty;
let json = format_error_json(&err);
assert_envelope_shape(&json);
assert_eq!(json["error"]["code"], "callisto::not_a_tty");
assert!(
!json["error"]["message"].as_str().unwrap().is_empty(),
"message must be non-empty"
);
let help = json["error"]["help"].as_str().expect("NotATty must have help text");
assert!(
help.contains("callisto add --package"),
"help should reference the --package flag; got: {help}"
);
}
#[test]
fn format_error_json_io_error_includes_code_and_help() {
let err = CliError::Io {
source: std::io::Error::new(std::io::ErrorKind::NotFound, "file missing"),
path: Some(std::path::PathBuf::from("/some/path")),
};
let json = format_error_json(&err);
assert_envelope_shape(&json);
assert_eq!(json["error"]["code"], "callisto::io_error");
assert!(
json["error"]["message"].as_str().unwrap().contains("/some/path"),
"I/O error message should include the path"
);
let help = json["error"]["help"].as_str().expect("Io must have help text");
assert!(
help.contains("path exists"),
"help should reference path existence check; got: {help}"
);
}
#[test]
fn format_error_json_structure_is_consistent_across_variants() {
let errors: &[CliError] = &[CliError::Other("first".to_string()), CliError::NotATty];
let jsons: Vec<serde_json::Value> = errors.iter().map(format_error_json).collect();
for json in &jsons {
assert_envelope_shape(json);
}
let keys_0: std::collections::BTreeSet<String> =
jsons[0]["error"].as_object().unwrap().keys().cloned().collect();
let keys_1: std::collections::BTreeSet<String> =
jsons[1]["error"].as_object().unwrap().keys().cloned().collect();
assert_eq!(keys_0, keys_1, "All error variants must produce the same JSON key set");
}
}