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}