Skip to main content

memstead_cli/
output.rs

1//! Output rendering and exit codes.
2//!
3//! Exit-code table — process status only; the JSON body carries the
4//! stable `UPPER_SNAKE_CASE` code under `code`:
5//!
6//! | Code | Meaning |
7//! |------|---------|
8//! | 0 | Success |
9//! | 1 | Generic error (IO, engine init, parse) |
10//! | 2 | Usage / invalid arguments (emitted by clap) |
11//! | 3 | Not found (entity / mem / resource missing) |
12//! | 4 | Hash mismatch (optimistic-lock violation) |
13//! | 5 | Validation error (schema, relation type, etc.) |
14//! | 6 | Findings present (a completed measurement found something) |
15//!
16//! Code 6 is the odd one and deliberately so: 1–5 all mean *the command
17//! failed*, 6 means *the command succeeded and you should care about the
18//! answer*. It exists so a CI job can branch on three outcomes —
19//! completed-and-clean, completed-with-findings, failed — without parsing
20//! output. Only opt-in gate modes return it (`projection verify
21//! --fail-on-findings`); no operational path may, or the distinction it
22//! exists to draw collapses. The full report is always rendered before it
23//! fires.
24//!
25//! In `--json` mode, errors emit the documented `{code, message,
26//! details}` envelope — same shape agents consume over MCP — to
27//! **stdout**, so the documented `memstead <sub> … --json | jq -r .code`
28//! recipe retrieves the typed code on the error path without a `2>&1`
29//! redirect (success responses already go to stdout; the structured
30//! error joins them there). The human-facing markdown error path
31//! (no `--json`) stays on stderr and begins `memstead: ERROR [<CODE>]:
32//! <message>` so consumers reading only the text channel recover the
33//! typed code with one regex.
34
35use serde::Serialize;
36
37/// Exit-code kind for CLI errors.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39#[repr(u8)]
40pub enum ExitKind {
41    Generic = 1,
42    NotFound = 3,
43    HashMismatch = 4,
44    Validation = 5,
45    /// A completed run whose result the caller asked to be gated on —
46    /// **not** a failure. Reserved for explicit opt-in gate modes; never
47    /// returned for an operational error.
48    Findings = 6,
49}
50
51/// Which standard stream a rendered CLI error is written to.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum ErrorStream {
54    Stdout,
55    Stderr,
56}
57
58/// Decide the target stream and serialized line for a CLI error. Pure —
59/// the routing decision is unit-testable without capturing process
60/// streams. Under `--json` the `{code, message, details}` envelope goes
61/// to **stdout** so the documented `memstead <sub> … --json | jq -r .code`
62/// recipe works on the error path (a stdout-only pipe); the human
63/// markdown form goes to stderr. `code` is the stable
64/// `UPPER_SNAKE_CASE` token (matching the wire envelope agents see over
65/// MCP); `details` carries the structured recovery payload under the
66/// `details` key.
67pub fn render_cli_error(
68    code: &str,
69    message: &str,
70    json_mode: bool,
71    details: Option<&serde_json::Value>,
72) -> (ErrorStream, String) {
73    if json_mode {
74        let envelope = match details {
75            Some(d) => serde_json::json!({
76                "code": code,
77                "message": message,
78                "details": d,
79            }),
80            None => serde_json::json!({
81                "code": code,
82                "message": message,
83            }),
84        };
85        (
86            ErrorStream::Stdout,
87            serde_json::to_string(&envelope).unwrap_or_default(),
88        )
89    } else {
90        (
91            ErrorStream::Stderr,
92            format!("memstead: ERROR [{code}]: {message}"),
93        )
94    }
95}
96
97/// Print a typed CLI error in the documented surface shape, routed per
98/// [`render_cli_error`]: the structured `--json` envelope to stdout, the
99/// human markdown form to stderr. The exit code (set by the caller from
100/// [`ExitKind`]) signals failure independently of the stream.
101pub fn print_cli_error(
102    code: &str,
103    message: &str,
104    _kind: ExitKind,
105    json_mode: bool,
106    details: Option<&serde_json::Value>,
107) {
108    let (stream, line) = render_cli_error(code, message, json_mode, details);
109    match stream {
110        ErrorStream::Stdout => println!("{line}"),
111        ErrorStream::Stderr => eprintln!("{line}"),
112    }
113}
114
115/// Print markdown (or JSON when requested) to stdout.
116pub fn print_markdown(markdown: &str) {
117    println!("{markdown}");
118}
119
120/// Print a serializable value as pretty JSON to stdout.
121pub fn print_json<T: Serialize>(value: &T) -> anyhow::Result<()> {
122    let s = serde_json::to_string_pretty(value)?;
123    println!("{s}");
124    Ok(())
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    /// Under `--json` the error envelope routes to stdout (so
132    /// `… --json | jq -r .code` captures it) and carries the typed `code`.
133    #[test]
134    fn json_error_routes_to_stdout_with_code() {
135        let details = serde_json::json!({ "id": "test--missing" });
136        let (stream, line) =
137            render_cli_error("ENTITY_NOT_FOUND", "not found", true, Some(&details));
138        assert_eq!(stream, ErrorStream::Stdout);
139        let parsed: serde_json::Value = serde_json::from_str(&line).expect("valid JSON line");
140        assert_eq!(parsed["code"], "ENTITY_NOT_FOUND");
141        assert_eq!(parsed["details"]["id"], "test--missing");
142    }
143
144    /// The human markdown error form stays on stderr (no `--json`).
145    #[test]
146    fn markdown_error_routes_to_stderr() {
147        let (stream, line) = render_cli_error("ENTITY_NOT_FOUND", "not found", false, None);
148        assert_eq!(stream, ErrorStream::Stderr);
149        assert!(line.starts_with("memstead: ERROR [ENTITY_NOT_FOUND]: "));
150    }
151}