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::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#[derive(Debug, Clone, Serialize)]
51pub struct Envelope<T: Serialize> {
52    pub schema_version: String,
53    pub ok: bool,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub data: Option<T>,
56    #[serde(skip_serializing_if = "Vec::is_empty", default)]
57    pub warnings: Vec<String>,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub error: Option<EnvelopeError>,
60}
61
62impl<T: Serialize> Envelope<T> {
63    /// Build a successful envelope.
64    pub fn success(schema_version: impl Into<String>, data: T) -> Self {
65        Self {
66            schema_version: schema_version.into(),
67            ok: true,
68            data: Some(data),
69            warnings: Vec::new(),
70            error: None,
71        }
72    }
73
74    /// Build a failure envelope with no payload.
75    pub fn failure(schema_version: impl Into<String>, error: EnvelopeError) -> Self {
76        Self {
77            schema_version: schema_version.into(),
78            ok: false,
79            data: None,
80            warnings: Vec::new(),
81            error: Some(error),
82        }
83    }
84
85    /// Append a single warning to the envelope.
86    pub fn with_warning(mut self, warning: impl Into<String>) -> Self {
87        self.warnings.push(warning.into());
88        self
89    }
90
91    /// Append multiple warnings to the envelope.
92    pub fn with_warnings<I, S>(mut self, warnings: I) -> Self
93    where
94        I: IntoIterator<Item = S>,
95        S: Into<String>,
96    {
97        self.warnings.extend(warnings.into_iter().map(|w| w.into()));
98        self
99    }
100}
101
102/// Structured error rendered inside the JSON envelope's `error` field.
103#[derive(Debug, Clone, Serialize)]
104pub struct EnvelopeError {
105    pub code: String,
106    pub message: String,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub hint: Option<String>,
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub details: Option<serde_json::Value>,
111}
112
113impl EnvelopeError {
114    /// Build an error with a code and message.
115    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
116        Self {
117            code: code.into(),
118            message: message.into(),
119            hint: None,
120            details: None,
121        }
122    }
123
124    /// Attach an optional human-readable hint to the error.
125    pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
126        self.hint = Some(hint.into());
127        self
128    }
129
130    /// Attach optional machine-readable structured detail to the error (e.g. the offending payload path).
131    pub fn with_details(mut self, details: serde_json::Value) -> Self {
132        self.details = Some(details);
133        self
134    }
135}
136
137/// Build the canonical `cli.<binary>.<command>.v<N>` schema-version string.
138pub fn schema_version_for(binary: &str, command: &str, version: u32) -> String {
139    format!("cli.{binary}.{command}.v{version}")
140}
141
142/// BSD sysexits-aligned exit-code constants used by every workspace binary.
143///
144/// The full table is captured in `docs/specs/cli-output-contract-v1.md`.
145pub mod exit {
146    /// Successful termination.
147    pub const SUCCESS: i32 = 0;
148    /// Generic runtime error (the historic catch-all for "something went wrong at runtime").
149    pub const RUNTIME: i32 = 1;
150    /// `EX_USAGE` — command-line syntax error.
151    pub const USAGE: i32 = 64;
152    /// `EX_DATAERR` — input data is malformed or otherwise invalid.
153    pub const DATA: i32 = 65;
154    /// `EX_UNAVAILABLE` — a required service or resource is unavailable.
155    pub const UNAVAILABLE: i32 = 69;
156    /// `EX_SOFTWARE` — internal software error (an invariant was violated).
157    pub const SOFTWARE: i32 = 70;
158}
159
160/// Emit a parse-error / unknown-subcommand failure through the shared contract.
161///
162/// When `format` is [`OutputFormat::Json`] the helper writes a single-line JSON
163/// envelope (schema `cli.<binary>.error.v1`) to stdout. In text mode it writes
164/// the historical `error: <msg>` line to stderr. Both branches return
165/// [`exit::USAGE`] so callers can do `std::process::exit(emit_parse_error(...))`.
166pub fn emit_parse_error(binary: &str, format: OutputFormat, code: &str, message: &str) -> i32 {
167    emit_parse_error_to(
168        &mut io::stdout().lock(),
169        &mut io::stderr().lock(),
170        binary,
171        format,
172        code,
173        message,
174    )
175}
176
177/// Test-friendly variant of [`emit_parse_error`] that writes to caller-provided sinks.
178pub fn emit_parse_error_to<W1: Write, W2: Write>(
179    stdout: &mut W1,
180    stderr: &mut W2,
181    binary: &str,
182    format: OutputFormat,
183    code: &str,
184    message: &str,
185) -> i32 {
186    match format {
187        OutputFormat::Json => {
188            let envelope: Envelope<()> = Envelope::failure(
189                schema_version_for(binary, "error", 1),
190                EnvelopeError::new(code, message),
191            );
192            // Single-line JSON so log scrapers see one record per error.
193            let serialized =
194                serde_json::to_string(&envelope).unwrap_or_else(|_| String::from("{\"ok\":false}"));
195            let _ = writeln!(stdout, "{serialized}");
196        }
197        OutputFormat::Text => {
198            let _ = writeln!(stderr, "error: {message}");
199        }
200    }
201    exit::USAGE
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use clap::ValueEnum;
208    use pretty_assertions::assert_eq;
209
210    #[test]
211    fn output_format_round_trips_through_value_enum() {
212        let text = OutputFormat::from_str("text", false).expect("text variant");
213        let json = OutputFormat::from_str("json", false).expect("json variant");
214        assert_eq!(text, OutputFormat::Text);
215        assert_eq!(json, OutputFormat::Json);
216        assert!(json.is_json());
217        assert!(text.is_text());
218        assert_eq!(OutputFormat::default(), OutputFormat::Text);
219    }
220
221    #[test]
222    fn envelope_success_serializes_snake_case() {
223        #[derive(Serialize)]
224        struct Payload {
225            item_count: u32,
226        }
227        let envelope = Envelope::success(
228            schema_version_for("cli-template", "status", 1),
229            Payload { item_count: 3 },
230        );
231        let json = serde_json::to_string(&envelope).expect("serialize envelope");
232        assert_eq!(
233            json,
234            "{\"schema_version\":\"cli.cli-template.status.v1\",\"ok\":true,\"data\":{\"item_count\":3}}"
235        );
236    }
237
238    #[test]
239    fn envelope_success_includes_warnings_when_present() {
240        let envelope: Envelope<()> = Envelope {
241            schema_version: schema_version_for("memo", "apply", 1),
242            ok: true,
243            data: None,
244            warnings: Vec::new(),
245            error: None,
246        }
247        .with_warning("entry-42 skipped: missing body");
248        let json = serde_json::to_string(&envelope).expect("serialize envelope");
249        assert_eq!(
250            json,
251            "{\"schema_version\":\"cli.memo.apply.v1\",\"ok\":true,\"warnings\":[\"entry-42 skipped: missing body\"]}"
252        );
253    }
254
255    #[test]
256    fn envelope_failure_serializes_error_only() {
257        let envelope: Envelope<()> = Envelope::failure(
258            schema_version_for("cli-template", "error", 1),
259            EnvelopeError::new("parse-error", "missing required argument <name>")
260                .with_hint("see --help"),
261        );
262        let json = serde_json::to_string(&envelope).expect("serialize envelope");
263        assert_eq!(
264            json,
265            "{\"schema_version\":\"cli.cli-template.error.v1\",\"ok\":false,\"error\":{\"code\":\"parse-error\",\"message\":\"missing required argument <name>\",\"hint\":\"see --help\"}}"
266        );
267    }
268
269    #[test]
270    fn exit_constants_match_bsd_sysexits() {
271        assert_eq!(exit::SUCCESS, 0);
272        assert_eq!(exit::RUNTIME, 1);
273        assert_eq!(exit::USAGE, 64);
274        assert_eq!(exit::DATA, 65);
275        assert_eq!(exit::UNAVAILABLE, 69);
276        assert_eq!(exit::SOFTWARE, 70);
277    }
278
279    #[test]
280    fn schema_version_for_builds_canonical_string() {
281        assert_eq!(schema_version_for("memo", "list", 1), "cli.memo.list.v1");
282        assert_eq!(
283            schema_version_for("cli-template", "status", 2),
284            "cli.cli-template.status.v2"
285        );
286    }
287}