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}