use serde::Serialize;
use serde_json::json;
#[derive(Debug, Clone, Serialize)]
pub struct FallbackCommand {
pub available: bool,
pub command: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct StructuredFailure {
pub kind: String,
pub reason: String,
pub recommendation: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub fallback: Option<FallbackCommand>,
}
impl StructuredFailure {
pub fn new(
kind: impl Into<String>,
reason: impl Into<String>,
recommendation: impl Into<String>,
) -> Self {
Self {
kind: kind.into(),
reason: reason.into(),
recommendation: recommendation.into(),
fallback: None,
}
}
pub fn with_fallback(mut self, command: impl Into<String>) -> Self {
self.fallback = Some(FallbackCommand {
available: true,
command: command.into(),
});
self
}
pub fn render_text(&self, cmd_name: &str) -> String {
let mut out = String::new();
out.push_str(&format!("{cmd_name} failed.\n"));
out.push_str(&format!(" kind: {}\n", self.kind));
out.push_str(&format!(" reason: {}\n", self.reason));
out.push_str(&format!(" recommendation: {}\n", self.recommendation));
if let Some(fallback) = &self.fallback {
out.push_str(&format!(" fallback: {}\n", fallback.command));
}
out
}
pub fn render_json(&self, cmd_name: &str) -> serde_json::Value {
let mut payload = json!({
"ok": false,
"error": format!("{cmd_name} failed"),
"kind": self.kind,
"reason": self.reason,
"recommendation": self.recommendation,
});
if let Some(fallback) = &self.fallback {
payload["fallback"] = json!({
"available": fallback.available,
"command": fallback.command,
});
}
payload
}
}
pub fn emit_and_error(cmd_name: &str, json: bool, failure: StructuredFailure) -> anyhow::Error {
if json {
let payload = failure.render_json(cmd_name);
match serde_json::to_string_pretty(&payload) {
Ok(rendered) => println!("{rendered}"),
Err(_) => println!("{payload}"),
}
} else {
eprint!("{}", failure.render_text(cmd_name));
}
anyhow::anyhow!("{}: {}", failure.kind, failure.reason)
}
pub fn want_json_envelope(explicit_json_flag: bool) -> bool {
explicit_json_flag
|| std::env::var("AICX_JSON")
.map(|v| !v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false"))
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_envelope_has_canonical_field_order() {
let failure = StructuredFailure::new(
"missing_required_arg",
"argument --source is required",
"rerun with --source claude",
);
let rendered = failure.render_text("aicx ingest");
let mut lines = rendered.lines();
assert_eq!(lines.next().unwrap(), "aicx ingest failed.");
assert!(lines.next().unwrap().starts_with(" kind:"));
assert!(lines.next().unwrap().starts_with(" reason:"));
assert!(lines.next().unwrap().starts_with(" recommendation:"));
assert!(lines.next().is_none());
}
#[test]
fn text_envelope_includes_fallback_when_set() {
let failure =
StructuredFailure::new("k", "r", "rec").with_fallback("aicx ingest --source claude");
let rendered = failure.render_text("aicx ingest");
assert!(rendered.contains(" fallback: aicx ingest --source claude"));
}
#[test]
fn json_envelope_omits_fallback_when_unset() {
let failure = StructuredFailure::new("k", "r", "rec");
let payload = failure.render_json("aicx ingest");
assert_eq!(payload["ok"], serde_json::Value::Bool(false));
assert_eq!(
payload["error"],
serde_json::Value::String("aicx ingest failed".to_string())
);
assert_eq!(payload["kind"], serde_json::Value::String("k".to_string()));
assert_eq!(
payload["reason"],
serde_json::Value::String("r".to_string())
);
assert_eq!(
payload["recommendation"],
serde_json::Value::String("rec".to_string())
);
assert!(payload.get("fallback").is_none());
}
#[test]
fn json_envelope_includes_fallback_when_set() {
let failure = StructuredFailure::new("k", "r", "rec").with_fallback("cmd");
let payload = failure.render_json("aicx ingest");
let fb = &payload["fallback"];
assert_eq!(fb["available"], serde_json::Value::Bool(true));
assert_eq!(fb["command"], serde_json::Value::String("cmd".to_string()));
}
#[test]
fn want_json_envelope_respects_flag_and_env() {
unsafe {
std::env::remove_var("AICX_JSON");
}
assert!(want_json_envelope(true));
assert!(!want_json_envelope(false));
unsafe {
std::env::set_var("AICX_JSON", "1");
}
assert!(want_json_envelope(false));
unsafe {
std::env::set_var("AICX_JSON", "0");
}
assert!(!want_json_envelope(false));
unsafe {
std::env::set_var("AICX_JSON", "false");
}
assert!(!want_json_envelope(false));
unsafe {
std::env::set_var("AICX_JSON", "");
}
assert!(!want_json_envelope(false));
unsafe {
std::env::remove_var("AICX_JSON");
}
}
}