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//!
15//! In `--json` mode, errors emit the documented `{code, message,
16//! details}` envelope — same shape agents consume over MCP — to
17//! **stdout**, so the documented `memstead <sub> … --json | jq -r .code`
18//! recipe retrieves the typed code on the error path without a `2>&1`
19//! redirect (success responses already go to stdout; the structured
20//! error joins them there). The human-facing markdown error path
21//! (no `--json`) stays on stderr and begins `memstead: ERROR [<CODE>]:
22//! <message>` so consumers reading only the text channel recover the
23//! typed code with one regex.
24
25use serde::Serialize;
26
27/// Exit-code kind for CLI errors.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29#[repr(u8)]
30pub enum ExitKind {
31    Generic = 1,
32    NotFound = 3,
33    HashMismatch = 4,
34    Validation = 5,
35}
36
37/// Which standard stream a rendered CLI error is written to.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum ErrorStream {
40    Stdout,
41    Stderr,
42}
43
44/// Decide the target stream and serialized line for a CLI error. Pure —
45/// the routing decision is unit-testable without capturing process
46/// streams. Under `--json` the `{code, message, details}` envelope goes
47/// to **stdout** so the documented `memstead <sub> … --json | jq -r .code`
48/// recipe works on the error path (a stdout-only pipe); the human
49/// markdown form goes to stderr. `code` is the stable
50/// `UPPER_SNAKE_CASE` token (matching the wire envelope agents see over
51/// MCP); `details` carries the structured recovery payload under the
52/// `details` key.
53pub fn render_cli_error(
54    code: &str,
55    message: &str,
56    json_mode: bool,
57    details: Option<&serde_json::Value>,
58) -> (ErrorStream, String) {
59    if json_mode {
60        let envelope = match details {
61            Some(d) => serde_json::json!({
62                "code": code,
63                "message": message,
64                "details": d,
65            }),
66            None => serde_json::json!({
67                "code": code,
68                "message": message,
69            }),
70        };
71        (
72            ErrorStream::Stdout,
73            serde_json::to_string(&envelope).unwrap_or_default(),
74        )
75    } else {
76        (
77            ErrorStream::Stderr,
78            format!("memstead: ERROR [{code}]: {message}"),
79        )
80    }
81}
82
83/// Print a typed CLI error in the documented surface shape, routed per
84/// [`render_cli_error`]: the structured `--json` envelope to stdout, the
85/// human markdown form to stderr. The exit code (set by the caller from
86/// [`ExitKind`]) signals failure independently of the stream.
87pub fn print_cli_error(
88    code: &str,
89    message: &str,
90    _kind: ExitKind,
91    json_mode: bool,
92    details: Option<&serde_json::Value>,
93) {
94    let (stream, line) = render_cli_error(code, message, json_mode, details);
95    match stream {
96        ErrorStream::Stdout => println!("{line}"),
97        ErrorStream::Stderr => eprintln!("{line}"),
98    }
99}
100
101/// Print markdown (or JSON when requested) to stdout.
102pub fn print_markdown(markdown: &str) {
103    println!("{markdown}");
104}
105
106/// Print a serializable value as pretty JSON to stdout.
107pub fn print_json<T: Serialize>(value: &T) -> anyhow::Result<()> {
108    let s = serde_json::to_string_pretty(value)?;
109    println!("{s}");
110    Ok(())
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    /// Under `--json` the error envelope routes to stdout (so
118    /// `… --json | jq -r .code` captures it) and carries the typed `code`.
119    #[test]
120    fn json_error_routes_to_stdout_with_code() {
121        let details = serde_json::json!({ "id": "test--missing" });
122        let (stream, line) =
123            render_cli_error("ENTITY_NOT_FOUND", "not found", true, Some(&details));
124        assert_eq!(stream, ErrorStream::Stdout);
125        let parsed: serde_json::Value = serde_json::from_str(&line).expect("valid JSON line");
126        assert_eq!(parsed["code"], "ENTITY_NOT_FOUND");
127        assert_eq!(parsed["details"]["id"], "test--missing");
128    }
129
130    /// The human markdown error form stays on stderr (no `--json`).
131    #[test]
132    fn markdown_error_routes_to_stderr() {
133        let (stream, line) = render_cli_error("ENTITY_NOT_FOUND", "not found", false, None);
134        assert_eq!(stream, ErrorStream::Stderr);
135        assert!(line.starts_with("memstead: ERROR [ENTITY_NOT_FOUND]: "));
136    }
137}