Skip to main content

nils_common/
cli_contract.rs

1//! Workspace-wide CLI output contract primitives.
2//!
3//! Every binary in the `nils-cli` workspace renders machine-readable output
4//! through the [`Envelope`] type and signals failure through the BSD sysexits
5//! constants in the [`exit`] module. The durable spec lives at
6//! `docs/specs/cli-output-contract-v1.md`; `crates/cli-template` is the
7//! reference implementation.
8//!
9//! The crate-level boundary rule (see `crates/nils-common/README.md`) still
10//! applies — these primitives expose structured data and constants; user-facing
11//! warning/error text and exit-code mapping live in caller adapters.
12
13use std::io::{self, Write};
14
15use serde::{Deserialize, Serialize};
16
17/// Canonical output-format flag value for every workspace CLI.
18///
19/// Binaries surface this enum via `clap`'s `value_enum`, typically as
20/// `--format text|json`. Pre-contract `--json` boolean flags may remain as
21/// hidden aliases for one minor cycle (see the contract spec).
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
23#[clap(rename_all = "lower")]
24pub enum OutputFormat {
25    /// Human-readable text output (default).
26    #[default]
27    Text,
28    /// Single-record JSON envelope (snake_case).
29    Json,
30}
31
32impl OutputFormat {
33    /// Returns `true` when the caller asked for machine-readable JSON.
34    pub fn is_json(self) -> bool {
35        matches!(self, Self::Json)
36    }
37
38    /// Returns `true` when the caller is rendering text.
39    pub fn is_text(self) -> bool {
40        matches!(self, Self::Text)
41    }
42}
43
44/// Envelope shared by every JSON-emitting subcommand.
45///
46/// The shape is intentionally narrow: `schema_version` pins the wire contract,
47/// `ok` is a boolean success flag, `data` carries the per-subcommand payload,
48/// `warnings` collects non-fatal diagnostics (so JSON consumers see what text
49/// mode would print to stderr), and `error` carries a structured failure.
50/// Deserialization accepts additive fields so same-version producers can add
51/// metadata without breaking consumers; callers still validate the required
52/// schema version, success state, and command-specific payload fields.
53#[derive(Debug, Clone, Deserialize, Serialize)]
54pub struct Envelope<T: Serialize> {
55    pub schema_version: String,
56    pub ok: bool,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub data: Option<T>,
59    #[serde(skip_serializing_if = "Vec::is_empty", default)]
60    pub warnings: Vec<String>,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub error: Option<EnvelopeError>,
63}
64
65impl<T: Serialize> Envelope<T> {
66    /// Build a successful envelope.
67    pub fn success(schema_version: impl Into<String>, data: T) -> Self {
68        Self {
69            schema_version: schema_version.into(),
70            ok: true,
71            data: Some(data),
72            warnings: Vec::new(),
73            error: None,
74        }
75    }
76
77    /// Build a failure envelope with no payload.
78    pub fn failure(schema_version: impl Into<String>, error: EnvelopeError) -> Self {
79        Self {
80            schema_version: schema_version.into(),
81            ok: false,
82            data: None,
83            warnings: Vec::new(),
84            error: Some(error),
85        }
86    }
87
88    /// Append a single warning to the envelope.
89    pub fn with_warning(mut self, warning: impl Into<String>) -> Self {
90        self.warnings.push(warning.into());
91        self
92    }
93
94    /// Append multiple warnings to the envelope.
95    pub fn with_warnings<I, S>(mut self, warnings: I) -> Self
96    where
97        I: IntoIterator<Item = S>,
98        S: Into<String>,
99    {
100        self.warnings.extend(warnings.into_iter().map(|w| w.into()));
101        self
102    }
103}
104
105/// Structured error rendered inside the JSON envelope's `error` field.
106#[derive(Debug, Clone, Deserialize, Serialize)]
107pub struct EnvelopeError {
108    pub code: String,
109    pub message: String,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub hint: Option<String>,
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub details: Option<serde_json::Value>,
114}
115
116impl EnvelopeError {
117    /// Build an error with a code and message.
118    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
119        Self {
120            code: code.into(),
121            message: message.into(),
122            hint: None,
123            details: None,
124        }
125    }
126
127    /// Attach an optional human-readable hint to the error.
128    pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
129        self.hint = Some(hint.into());
130        self
131    }
132
133    /// Attach optional machine-readable structured detail to the error (e.g. the offending payload path).
134    pub fn with_details(mut self, details: serde_json::Value) -> Self {
135        self.details = Some(details);
136        self
137    }
138}
139
140/// Build the canonical `cli.<binary>.<command>.v<N>` schema-version string.
141pub fn schema_version_for(binary: &str, command: &str, version: u32) -> String {
142    format!("cli.{binary}.{command}.v{version}")
143}
144
145/// BSD sysexits-aligned exit-code constants used by every workspace binary.
146///
147/// The full table is captured in `docs/specs/cli-output-contract-v1.md`.
148pub mod exit {
149    /// Successful termination.
150    pub const SUCCESS: i32 = 0;
151    /// Generic runtime error (the historic catch-all for "something went wrong at runtime").
152    pub const RUNTIME: i32 = 1;
153    /// `EX_USAGE` — command-line syntax error.
154    pub const USAGE: i32 = 64;
155    /// `EX_DATAERR` — input data is malformed or otherwise invalid.
156    pub const DATA: i32 = 65;
157    /// `EX_UNAVAILABLE` — a required service or resource is unavailable.
158    pub const UNAVAILABLE: i32 = 69;
159    /// `EX_SOFTWARE` — internal software error (an invariant was violated).
160    pub const SOFTWARE: i32 = 70;
161}
162
163/// Emit a parse-error / unknown-subcommand failure through the shared contract.
164///
165/// When `format` is [`OutputFormat::Json`] the helper writes a single-line JSON
166/// envelope (schema `cli.<binary>.error.v1`) to stdout. In text mode it writes
167/// the historical `error: <msg>` line to stderr. Both branches return
168/// [`exit::USAGE`] so callers can do `std::process::exit(emit_parse_error(...))`.
169pub fn emit_parse_error(binary: &str, format: OutputFormat, code: &str, message: &str) -> i32 {
170    emit_parse_error_to(
171        &mut io::stdout().lock(),
172        &mut io::stderr().lock(),
173        binary,
174        format,
175        code,
176        message,
177    )
178}
179
180/// Test-friendly variant of [`emit_parse_error`] that writes to caller-provided sinks.
181pub fn emit_parse_error_to<W1: Write, W2: Write>(
182    stdout: &mut W1,
183    stderr: &mut W2,
184    binary: &str,
185    format: OutputFormat,
186    code: &str,
187    message: &str,
188) -> i32 {
189    match format {
190        OutputFormat::Json => {
191            let envelope: Envelope<()> = Envelope::failure(
192                schema_version_for(binary, "error", 1),
193                EnvelopeError::new(code, message),
194            );
195            // Single-line JSON so log scrapers see one record per error.
196            let serialized =
197                serde_json::to_string(&envelope).unwrap_or_else(|_| String::from("{\"ok\":false}"));
198            let _ = writeln!(stdout, "{serialized}");
199        }
200        OutputFormat::Text => {
201            let _ = writeln!(stderr, "error: {message}");
202        }
203    }
204    exit::USAGE
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use clap::ValueEnum;
211    use pretty_assertions::assert_eq;
212
213    #[test]
214    fn output_format_round_trips_through_value_enum() {
215        let text = OutputFormat::from_str("text", false).expect("text variant");
216        let json = OutputFormat::from_str("json", false).expect("json variant");
217        assert_eq!(text, OutputFormat::Text);
218        assert_eq!(json, OutputFormat::Json);
219        assert!(json.is_json());
220        assert!(text.is_text());
221        assert_eq!(OutputFormat::default(), OutputFormat::Text);
222    }
223
224    #[test]
225    fn envelope_success_serializes_snake_case() {
226        #[derive(Serialize)]
227        struct Payload {
228            item_count: u32,
229        }
230        let envelope = Envelope::success(
231            schema_version_for("cli-template", "status", 1),
232            Payload { item_count: 3 },
233        );
234        let json = serde_json::to_string(&envelope).expect("serialize envelope");
235        assert_eq!(
236            json,
237            "{\"schema_version\":\"cli.cli-template.status.v1\",\"ok\":true,\"data\":{\"item_count\":3}}"
238        );
239    }
240
241    #[test]
242    fn envelope_success_includes_warnings_when_present() {
243        let envelope: Envelope<()> = Envelope {
244            schema_version: schema_version_for("memo", "apply", 1),
245            ok: true,
246            data: None,
247            warnings: Vec::new(),
248            error: None,
249        }
250        .with_warning("entry-42 skipped: missing body");
251        let json = serde_json::to_string(&envelope).expect("serialize envelope");
252        assert_eq!(
253            json,
254            "{\"schema_version\":\"cli.memo.apply.v1\",\"ok\":true,\"warnings\":[\"entry-42 skipped: missing body\"]}"
255        );
256    }
257
258    #[test]
259    fn envelope_failure_serializes_error_only() {
260        let envelope: Envelope<()> = Envelope::failure(
261            schema_version_for("cli-template", "error", 1),
262            EnvelopeError::new("parse-error", "missing required argument <name>")
263                .with_hint("see --help"),
264        );
265        let json = serde_json::to_string(&envelope).expect("serialize envelope");
266        assert_eq!(
267            json,
268            "{\"schema_version\":\"cli.cli-template.error.v1\",\"ok\":false,\"error\":{\"code\":\"parse-error\",\"message\":\"missing required argument <name>\",\"hint\":\"see --help\"}}"
269        );
270    }
271
272    #[test]
273    fn envelope_deserialization_accepts_additive_metadata() {
274        let envelope: Envelope<serde_json::Value> = serde_json::from_str(
275            r#"{
276                "schema_version":"cli.agent-hook.setup.v1",
277                "ok":true,
278                "data":{"product":"codex","future_result_metadata":true},
279                "warnings":[],
280                "error":null,
281                "future_envelope_metadata":{"source":"newer-producer"}
282            }"#,
283        )
284        .expect("same-version additive metadata remains compatible");
285
286        assert!(envelope.ok);
287        assert_eq!(envelope.data.expect("data")["product"], "codex");
288    }
289
290    #[test]
291    fn exit_constants_match_bsd_sysexits() {
292        assert_eq!(exit::SUCCESS, 0);
293        assert_eq!(exit::RUNTIME, 1);
294        assert_eq!(exit::USAGE, 64);
295        assert_eq!(exit::DATA, 65);
296        assert_eq!(exit::UNAVAILABLE, 69);
297        assert_eq!(exit::SOFTWARE, 70);
298    }
299
300    #[test]
301    fn schema_version_for_builds_canonical_string() {
302        assert_eq!(schema_version_for("memo", "list", 1), "cli.memo.list.v1");
303        assert_eq!(
304            schema_version_for("cli-template", "status", 2),
305            "cli.cli-template.status.v2"
306        );
307    }
308}