cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! `CliError` — uniform error envelope for the CLI surface (ISSUE-0128).
//!
//! Human output is unchanged (`error: <message>` on stderr, optional
//! `  hint: <hint>` line). When `--output json|yaml` is set, the same data
//! is serialised as a flat envelope so machine consumers don't have to
//! switch parsers between success and failure. Stderr stays the channel
//! (bash convention; redirect with `2>&1` if you want both streams).
//!
//! ```text
//! { "error": "<message>", "kind": "<kind>", "hint": "<hint>" }
//! ```
//!
//! `kind` and `hint` are optional and omitted from the JSON/YAML output
//! when absent. The kind taxonomy is intentionally small and open-ended:
//! `validation`, `not-found`, `config`, `io`, `internal`. New kinds can be
//! added without breaking consumers (they're hints, not contracts).

use crate::infra::driving::cli::OutputFormat;

#[derive(Debug, Clone, serde::Serialize)]
pub(crate) struct CliError {
    pub error: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub kind: Option<&'static str>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub hint: Option<String>,
}

impl CliError {
    pub fn new(message: impl Into<String>) -> Self {
        Self {
            error: message.into(),
            kind: None,
            hint: None,
        }
    }

    pub fn kind(mut self, kind: &'static str) -> Self {
        self.kind = Some(kind);
        self
    }

    pub fn hint(mut self, hint: impl Into<String>) -> Self {
        self.hint = Some(hint.into());
        self
    }

    /// Build a "<subject> not found" error pointing the user at the
    /// matching `cartu <list_command>` to discover existing IDs. Used
    /// by every `show` / `update` / `edit` site whose target id can
    /// be mis-typed or stale.
    pub fn not_found(subject: impl Into<String>, list_command: &str) -> Self {
        Self::new(format!("{} not found", subject.into()))
            .kind("not-found")
            .hint(format!("Run `cartu {list_command}` to see existing IDs."))
    }
}

/// Print the error to stderr and exit with the given code (non-zero).
///
/// Format depends on the requested output: human (`error: ...` + optional
/// `  hint: ...`), JSON, or YAML.
pub(crate) fn die(err: CliError, output: OutputFormat, code: i32) -> ! {
    match output {
        OutputFormat::Json => {
            let s = serde_json::to_string(&err)
                .unwrap_or_else(|_| format!(r#"{{"error":"{}"}}"#, err.error.replace('"', "\\\"")));
            eprintln!("{s}");
        }
        OutputFormat::Yaml => {
            let s =
                serde_yaml::to_string(&err).unwrap_or_else(|_| format!("error: {}\n", err.error));
            eprint!("{s}");
        }
        OutputFormat::Human => {
            eprintln!("error: {}", err.error);
            if let Some(h) = &err.hint {
                eprintln!("  hint: {h}");
            }
        }
    }
    std::process::exit(code);
}

/// Convenience: die with exit code 1 (the common case).
pub(crate) fn die1(err: CliError, output: OutputFormat) -> ! {
    die(err, output, 1)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn cli_error_serialises_to_flat_json_envelope() {
        let err = CliError::new("unknown status 'bogus'")
            .kind("validation")
            .hint("Known statuses: open, in-progress, closed");
        let json = serde_json::to_string(&err).unwrap();
        assert!(json.contains(r#""error":"unknown status 'bogus'""#));
        assert!(json.contains(r#""kind":"validation""#));
        assert!(json.contains(r#""hint":"Known statuses"#));
    }

    #[test]
    fn cli_error_omits_optional_fields_when_absent() {
        let err = CliError::new("io failure");
        let json = serde_json::to_string(&err).unwrap();
        assert_eq!(json, r#"{"error":"io failure"}"#);
    }

    #[test]
    fn cli_error_yaml_is_human_readable() {
        let err = CliError::new("not found").kind("not-found");
        let yaml = serde_yaml::to_string(&err).unwrap();
        assert!(yaml.contains("error: not found"));
        assert!(yaml.contains("kind: not-found"));
    }
}