Skip to main content

usage/spec/
exit_code.rs

1//! What a command's exit status means.
2//!
3//! ```kdl
4//! exit_code 0 "all checks passed"
5//! exit_code 1 "a check failed"
6//! exit_code 2 "configuration error"
7//! ```
8//!
9//! Declared at the root for the CLI-wide convention and on a command for what it adds or
10//! refines. A man page conventionally carries an `EXIT STATUS` section and this project's
11//! renderer had no way to fill one; an agent reading the spec through `usage mcp` had no
12//! way to tell "one check failed" from "the tool broke".
13//!
14//! Folding is per code and nearest-wins, so `exit_code 1 "a check failed"` on a command
15//! *refines* a CLI-wide `exit_code 1 "error"` rather than replacing the whole table. The
16//! alternative — a command that redeclares anything owns the full set — would make every
17//! command restate `0` and `130`.
18
19use crate::kdl::{KdlEntry, KdlNode, KdlValue};
20use serde::Serialize;
21
22use crate::error::Result;
23use crate::spec::cmd::SpecCommand;
24use crate::spec::context::ParsingContext;
25use crate::spec::helpers::{string_entry, NodeHelper};
26use crate::spec::Spec;
27
28/// One documented exit status.
29#[derive(Debug, Default, Clone, Serialize)]
30#[non_exhaustive]
31pub struct SpecExitCode {
32    pub code: i64,
33    pub help: String,
34}
35
36impl SpecExitCode {
37    pub fn new(code: i64, help: impl Into<String>) -> Self {
38        Self {
39            code,
40            help: help.into(),
41        }
42    }
43
44    pub(crate) fn parse(ctx: &ParsingContext, node: &NodeHelper) -> Result<Self> {
45        node.ensure_arg_len(2..=2)?;
46        let entry = node.arg(0)?;
47        let Some(code) = entry.value.as_integer() else {
48            bail_parse!(ctx, entry.entry.span(), "an exit code must be a number");
49        };
50        // A shell reports `status & 0xff`, so 256 and 0 are the same thing to anyone
51        // reading it, and a spec that claims otherwise documents something that cannot be
52        // observed. Windows is the counterexample — it really does return values wider
53        // than a byte — so this is a range check rather than a `u8`.
54        if !(0..=255).contains(&code) {
55            bail_parse!(
56                ctx,
57                entry.entry.span(),
58                "exit code {code} is outside 0-255; a POSIX shell reports the low byte, so \
59                 anything higher is indistinguishable from {}",
60                code & 0xff
61            );
62        }
63        let help = node.arg(1)?.ensure_string()?;
64        if help.is_empty() {
65            bail_parse!(
66                ctx,
67                node.span(),
68                "exit code {code} needs a description; an undocumented code is what the \
69                 declaration exists to replace"
70            );
71        }
72        Ok(SpecExitCode {
73            code: code as i64,
74            help,
75        })
76    }
77}
78
79impl From<&SpecExitCode> for KdlNode {
80    fn from(exit_code: &SpecExitCode) -> KdlNode {
81        let mut node = KdlNode::new("exit_code");
82        node.push(KdlEntry::new(KdlValue::Integer(exit_code.code as i128)));
83        node.push(string_entry(None, &exit_code.help));
84        node
85    }
86}
87
88/// The exit codes in effect for a command, CLI-wide declarations folded in.
89///
90/// Nearest wins per code, and the root's order is preserved so a table reads the same way
91/// on every page. A command's own codes append in declaration order.
92///
93/// Folded on read rather than at parse time, following `unknown_flags`: folding early
94/// would write the root's codes into every command block on re-emission.
95pub fn effective_exit_codes(spec: &Spec, path: &[SpecCommand]) -> Vec<SpecExitCode> {
96    effective_exit_codes_ref(spec, path.iter())
97}
98
99/// Reference-based form used by tree walkers that already hold the command chain.
100pub fn effective_exit_codes_ref<'a>(
101    spec: &Spec,
102    path: impl IntoIterator<Item = &'a SpecCommand>,
103) -> Vec<SpecExitCode> {
104    let mut out: Vec<SpecExitCode> = spec.exit_codes.clone();
105    for cmd in path {
106        for code in &cmd.exit_codes {
107            match out.iter_mut().find(|e| e.code == code.code) {
108                Some(existing) => *existing = code.clone(),
109                None => out.push(code.clone()),
110            }
111        }
112    }
113    out
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use crate::error::UsageErr;
120
121    fn parse(src: &str) -> Spec {
122        src.parse().expect("the fixture should parse")
123    }
124
125    fn error(src: &str) -> String {
126        match src.parse::<Spec>().expect_err("should be rejected") {
127            UsageErr::InvalidInput(msg, ..) => msg,
128            other => other.to_string(),
129        }
130    }
131
132    #[test]
133    fn a_command_refines_the_cli_wide_table_rather_than_replacing_it() {
134        let spec = parse(
135            r#"
136name "ex"
137exit_code 0 "ok"
138exit_code 1 "error"
139exit_code 130 "interrupted"
140cmd "check" {
141    exit_code 1 "a check failed"
142    exit_code 2 "configuration error"
143}
144"#,
145        );
146        let check = spec.cmd.subcommands["check"].clone();
147        let codes: Vec<(i64, String)> = effective_exit_codes(&spec, &[check])
148            .into_iter()
149            .map(|e| (e.code, e.help))
150            .collect();
151        // 1 is refined in place, 0 and 130 come down untouched, 2 appends. The
152        // alternative — a command that says anything owns the whole table — would make
153        // every command restate 0 and 130.
154        assert_eq!(
155            codes,
156            vec![
157                (0, "ok".to_string()),
158                (1, "a check failed".to_string()),
159                (130, "interrupted".to_string()),
160                (2, "configuration error".to_string()),
161            ]
162        );
163    }
164
165    #[test]
166    fn a_code_outside_a_byte_says_what_a_shell_would_report() {
167        let err = error("name \"ex\"\nexit_code 256 \"nope\"\n");
168        assert!(err.contains("outside 0-255"), "{err}");
169        assert!(err.contains("indistinguishable from 0"), "{err}");
170    }
171
172    #[test]
173    fn a_code_needs_a_description() {
174        assert!(error("name \"ex\"\nexit_code 1 \"\"\n").contains("needs a description"));
175    }
176
177    #[test]
178    fn codes_survive_a_round_trip() {
179        let src = "name \"ex\"\nexit_code 0 ok\ncmd \"go\" {\n    exit_code 3 \"broke\"\n}\n";
180        let spec = parse(src);
181        let again = parse(&spec.to_string());
182        assert_eq!(spec.to_string(), again.to_string());
183        assert_eq!(again.exit_codes[0].code, 0);
184        assert_eq!(again.cmd.subcommands["go"].exit_codes[0].help, "broke");
185    }
186}