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        // The human path carries the SAME structured recovery payload the
91        // JSON envelope does — the engine computed it either way, and
92        // dropping it here is what sent an agent probing five rel-types in
93        // sequence when `details.allowed_source_types` had the answer
94        // (backlog, model-truth campaign). Rendered as an indented pretty
95        // block after the one-line header, so the first line stays the
96        // documented `memstead: ERROR [<CODE>]: <message>` shape.
97        let mut line = format!("memstead: ERROR [{code}]: {message}");
98        if let Some(d) = details
99            && !d.is_null()
100            && let Ok(pretty) = serde_json::to_string_pretty(d)
101        {
102            line.push_str("\ndetails:");
103            for l in pretty.lines() {
104                line.push_str("\n  ");
105                line.push_str(l);
106            }
107        }
108        (ErrorStream::Stderr, line)
109    }
110}
111
112/// Print a typed CLI error in the documented surface shape, routed per
113/// [`render_cli_error`]: the structured `--json` envelope to stdout, the
114/// human markdown form to stderr. The exit code (set by the caller from
115/// [`ExitKind`]) signals failure independently of the stream.
116pub fn print_cli_error(
117    code: &str,
118    message: &str,
119    _kind: ExitKind,
120    json_mode: bool,
121    details: Option<&serde_json::Value>,
122) {
123    let (stream, line) = render_cli_error(code, message, json_mode, details);
124    match stream {
125        ErrorStream::Stdout => println!("{line}"),
126        ErrorStream::Stderr => eprintln!("{line}"),
127    }
128}
129
130/// Print markdown (or JSON when requested) to stdout.
131pub fn print_markdown(markdown: &str) {
132    println!("{markdown}");
133}
134
135/// Print a serializable value as pretty JSON to stdout.
136pub fn print_json<T: Serialize>(value: &T) -> anyhow::Result<()> {
137    let s = serde_json::to_string_pretty(value)?;
138    println!("{s}");
139    Ok(())
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    /// Under `--json` the error envelope routes to stdout (so
147    /// `… --json | jq -r .code` captures it) and carries the typed `code`.
148    #[test]
149    fn json_error_routes_to_stdout_with_code() {
150        let details = serde_json::json!({ "id": "test--missing" });
151        let (stream, line) =
152            render_cli_error("ENTITY_NOT_FOUND", "not found", true, Some(&details));
153        assert_eq!(stream, ErrorStream::Stdout);
154        let parsed: serde_json::Value = serde_json::from_str(&line).expect("valid JSON line");
155        assert_eq!(parsed["code"], "ENTITY_NOT_FOUND");
156        assert_eq!(parsed["details"]["id"], "test--missing");
157    }
158
159    /// The human markdown error form stays on stderr (no `--json`).
160    #[test]
161    fn markdown_error_routes_to_stderr() {
162        let (stream, line) = render_cli_error("ENTITY_NOT_FOUND", "not found", false, None);
163        assert_eq!(stream, ErrorStream::Stderr);
164        assert!(line.starts_with("memstead: ERROR [ENTITY_NOT_FOUND]: "));
165    }
166
167    /// The human form renders the structured recovery payload too — the
168    /// engine computed it, and the agent on the text channel needs it as
169    /// much as the `--json` consumer does. Header line stays the documented
170    /// shape; details follow as an indented block.
171    #[test]
172    fn markdown_error_carries_details_block() {
173        let details = serde_json::json!({ "allowed_source_types": ["spec", "contract"] });
174        let (stream, line) =
175            render_cli_error("INVALID_REL_SHAPE", "bad edge", false, Some(&details));
176        assert_eq!(stream, ErrorStream::Stderr);
177        let mut lines = line.lines();
178        assert_eq!(
179            lines.next(),
180            Some("memstead: ERROR [INVALID_REL_SHAPE]: bad edge")
181        );
182        assert_eq!(lines.next(), Some("details:"));
183        assert!(line.contains("allowed_source_types"));
184        assert!(line.contains("contract"));
185    }
186}