Skip to main content

usage/spec/
output.rs

1//! What a command writes, and how a consumer should read it.
2//!
3//! A spec has always described a command's inputs exhaustively and its outputs not at
4//! all. Across the jdx.dev fleet that gap is 123 hand-written flag declarations in three
5//! incompatible spellings — mise and pitchfork say `-J --json`, hk says
6//! `--format=human|json|jsonl`, aube says both `--json` and
7//! `--reporter=default|append-only|ndjson|silent` — and not one of them says what the
8//! JSON contains.
9//!
10//! ```kdl
11//! cmd "check" {
12//!     output "human" default=#true help="Human-readable report"
13//!     output "json" media_type="application/json" framing="json" help="One report object" { schema #"""{…}"""# }
14//!     output "xml" media_type="application/xml" help="One XML document"
15//!     output "jsonl" framing="jsonl" help="One event per line" { schema #"""{…}"""# }
16//!     select "--format"
17//! }
18//! ```
19//!
20//! # Name, media type, and framing are not the same thing
21//!
22//! The positional is the token a *user* types, `media_type` identifies the content, and
23//! [`SpecOutput::framing`] says how a *consumer* reads the stream. They are separate because
24//! XML and prose are both read to the end despite having different media types, while aube spells its line-delimited output
25//! `ndjson` and hk spells the identical wire format `jsonl`: a generated SDK that keyed
26//! off the name would offer `exec_ndjson()` for one and `exec_jsonl()` for the other, and
27//! a caller would be back to knowing which CLI they were talking to — the thing this
28//! exists to delete.
29//!
30//! # Two ways to ask, one model
31//!
32//! A command-level `select "--format"` names a flag whose *value* picks an output. An
33//! `output "json" select="--json"` names a boolean flag whose *presence* picks that one.
34//! Both are common in the wild and both lower here; [`SpecOutput::select_argv`] answers
35//! "which words pick this" so no consumer has to know which spelling was used.
36//!
37//! # Selection is resolved, not just recorded
38//!
39//! [`resolve_selectors`] runs once after the whole document is read and fills the
40//! selecting flag's `choices` from the output names. That is what lets completion, the
41//! docs renderers, the fig exporter and the SDK choice types all work without any of them
42//! learning about outputs. Two consequences worth knowing before reading a re-emitted
43//! spec, both documented on `docs/spec/reference/output.md`:
44//!
45//! - the choices appear in the output even though the source did not write them, the same
46//!   way `include` and `flagset` expansion do not survive a round trip;
47//! - a `select` naming an inherited global gets a narrowed copy of that flag inside the
48//!   command, because two commands under one global rarely produce the same outputs.
49//!
50//! Resolution is idempotent: a second pass sees choices that already match and validates
51//! them instead of rewriting, so a spec that has been through it round-trips unchanged.
52
53use std::collections::BTreeSet;
54use std::path::Path;
55
56use crate::kdl::{KdlDocument, KdlEntry, KdlNode};
57use serde::Serialize;
58use strum::{Display as StrumDisplay, EnumString};
59
60use crate::error::{Result, UsageErr};
61use crate::spec::choices::{SpecChoice, SpecChoices};
62use crate::spec::cmd::SpecCommand;
63use crate::spec::context::ParsingContext;
64use crate::spec::helpers::{string_entry, NodeHelper};
65use crate::spec::{is_false, Spec};
66use crate::SpecFlag;
67
68/// Selection is resolved after the whole document is read, so there is no node span left
69/// to point at. Same shape as the view checks, for the same reason.
70fn invalid(msg: String) -> UsageErr {
71    UsageErr::InvalidOutput(msg)
72}
73
74/// The wire format of what a command writes to stdout.
75///
76/// This is the machine contract, distinct from the name a user types for it. It is what
77/// decides a consumer's *shape*: [`Framing::Json`] is read to EOF and parsed once, so a
78/// generated client returns a value; [`Framing::Jsonl`] arrives a line at a time and may
79/// never end, so a generated client has to return an iterator and must not buffer.
80#[derive(
81    Debug, Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, EnumString, StrumDisplay, Serialize,
82)]
83#[strum(serialize_all = "snake_case")]
84#[serde(rename_all = "snake_case")]
85pub enum Framing {
86    /// Human-readable text. Nothing may be assumed about its structure, and it is the
87    /// default because an output that says nothing about its framing is prose.
88    #[default]
89    Text,
90    /// One JSON document, read to end of stream.
91    Json,
92    /// One JSON document per line. Line-delimited, unbounded, and read incrementally —
93    /// `ndjson` is the same thing under another name.
94    Jsonl,
95}
96
97impl Framing {
98    pub fn as_str(&self) -> &'static str {
99        match self {
100            Framing::Text => "text",
101            Framing::Json => "json",
102            Framing::Jsonl => "jsonl",
103        }
104    }
105
106    /// Whether a consumer has to read this incrementally rather than to the end.
107    pub fn is_streaming(&self) -> bool {
108        matches!(self, Framing::Jsonl)
109    }
110}
111
112/// The values `framing=` accepts, for error messages.
113pub(crate) const FRAMING_VALUES: &str = "text, json, jsonl";
114
115/// How a command's output is asked for.
116#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
117#[serde(tag = "kind", rename_all = "snake_case")]
118pub enum Selector {
119    /// A flag whose value names the output: `--format jsonl`.
120    Value { flag: String, value: String },
121    /// A boolean flag whose presence picks it: `--json`.
122    Present { flag: String },
123}
124
125impl Selector {
126    /// The words that pick this output, ready to append to an argv.
127    pub fn argv(&self) -> Vec<String> {
128        match self {
129            Selector::Value { flag, value } => vec![flag.clone(), value.clone()],
130            Selector::Present { flag } => vec![flag.clone()],
131        }
132    }
133
134    /// The flag doing the selecting, with its leading dashes.
135    pub fn flag(&self) -> &str {
136        match self {
137            Selector::Value { flag, .. } | Selector::Present { flag } => flag,
138        }
139    }
140}
141
142/// One thing a command can write.
143#[derive(Debug, Default, Clone, Serialize)]
144#[non_exhaustive]
145pub struct SpecOutput {
146    /// The token a user types for it, e.g. `human`, `json`, `ndjson`.
147    pub name: String,
148    /// The media type of the bytes, independent of their stream framing.
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub media_type: Option<String>,
151    /// The wire format, which is what a consumer keys off.
152    #[serde(skip_serializing_if = "is_text")]
153    pub framing: Framing,
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub help: Option<String>,
156    /// A JSON Schema for what is written, carried verbatim.
157    ///
158    /// Opaque on purpose: usage-lib has no runtime JSON dependency and is not going to
159    /// grow one to hold a string it never inspects. Consumers that want it parsed —
160    /// today that is only the MCP server — parse it themselves and fall back to the raw
161    /// text, so a malformed schema degrades rather than failing a spec.
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub schema: Option<String>,
164    /// What the command writes when nothing selects otherwise.
165    #[serde(skip_serializing_if = "is_false")]
166    pub default: bool,
167    /// Declared in order to be taken away: a command that inherits a CLI-wide output it
168    /// cannot produce redeclares the name with `hide=#true`. The same spelling as a
169    /// hidden `choice` or `alias`.
170    #[serde(skip_serializing_if = "is_false")]
171    pub hide: bool,
172    /// A boolean flag whose presence picks this output, for the `--json` spelling.
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub select: Option<String>,
175}
176
177fn is_text(framing: &Framing) -> bool {
178    *framing == Framing::Text
179}
180
181impl SpecOutput {
182    pub fn new(name: impl Into<String>) -> Self {
183        Self {
184            name: name.into(),
185            ..Default::default()
186        }
187    }
188
189    pub fn framing(mut self, framing: Framing) -> Self {
190        self.framing = framing;
191        self
192    }
193
194    pub fn media_type(mut self, media_type: impl Into<String>) -> Self {
195        self.media_type = Some(media_type.into());
196        self
197    }
198
199    pub fn help(mut self, help: impl Into<String>) -> Self {
200        self.help = Some(help.into());
201        self
202    }
203
204    pub fn schema(mut self, schema: impl Into<String>) -> Self {
205        self.schema = Some(schema.into());
206        self
207    }
208
209    pub fn default_output(mut self) -> Self {
210        self.default = true;
211        self
212    }
213
214    /// How this output is asked for, given the command it belongs to.
215    ///
216    /// [`None`] when no flag selects it. This is valid for an always-produced output;
217    /// generated per-output SDK methods require a selector and therefore omit it.
218    pub fn select_argv(&self, cmd: &SpecCommand) -> Option<Selector> {
219        self.select_argv_with(cmd.select.as_deref())
220    }
221
222    pub(crate) fn select_argv_with(&self, command_select: Option<&str>) -> Option<Selector> {
223        if let Some(flag) = &self.select {
224            return Some(Selector::Present { flag: flag.clone() });
225        }
226        command_select.map(|flag| Selector::Value {
227            flag: flag.to_string(),
228            value: self.name.clone(),
229        })
230    }
231
232    pub(crate) fn parse(ctx: &ParsingContext, node: &NodeHelper) -> Result<Self> {
233        node.ensure_arg_len(1..=1)?;
234        let mut output = SpecOutput::new(node.arg(0)?.ensure_string()?);
235        for (k, v) in node.props() {
236            match k {
237                "media_type" => output.media_type = Some(v.ensure_string()?),
238                "framing" => output.framing = parse_framing(ctx, v.ensure_string()?, v.entry)?,
239                "help" => output.help = Some(v.ensure_string()?),
240                "schema" => output.schema = Some(v.ensure_string()?),
241                "default" => output.default = v.ensure_bool()?,
242                "hide" => output.hide = v.ensure_bool()?,
243                "select" => output.select = Some(v.ensure_string()?),
244                k => bail_parse!(ctx, v.entry.span(), "unsupported output key {k}"),
245            }
246        }
247        for child in node.children() {
248            match child.name() {
249                // A schema is the one field long enough to want its own line, and long
250                // text already spells itself this way: `long_help`, `help_md` and the
251                // before/after help blocks are all child nodes for the same reason.
252                "schema" => parse_schema(ctx, &child, &mut output)?,
253                "help" => output.help = Some(child.arg(0)?.ensure_string()?),
254                k => bail_parse!(
255                    ctx,
256                    child.node.name().span(),
257                    "unsupported output value key {k}"
258                ),
259            }
260        }
261        if output.name.is_empty() {
262            bail_parse!(ctx, node.span(), "an output needs a name");
263        }
264        Ok(output)
265    }
266}
267
268fn parse_schema(ctx: &ParsingContext, node: &NodeHelper, output: &mut SpecOutput) -> Result<()> {
269    if let Some(file) = node.get("file") {
270        node.ensure_arg_len(0..=0)?;
271        for (key, value) in node.props() {
272            if key != "file" {
273                bail_parse!(ctx, value.entry.span(), "unsupported schema key {key}");
274            }
275        }
276        let declared = file.ensure_string()?;
277        let path = Path::new(&declared);
278        let path = if path.is_relative() {
279            ctx.file
280                .parent()
281                .filter(|_| !ctx.file.as_os_str().is_empty())
282                .ok_or_else(|| {
283                    ctx.build_err(
284                        "relative schema files require a source file".into(),
285                        node.span(),
286                    )
287                })?
288                .join(path)
289        } else {
290            path.to_path_buf()
291        };
292        output.schema = Some(
293            std::fs::read_to_string(&path).map_err(|err| UsageErr::FileError(err, path.clone()))?,
294        );
295        ctx.record_source(path);
296    } else {
297        node.ensure_arg_len(1..=1)?;
298        if let Some((key, value)) = node.props().into_iter().next() {
299            bail_parse!(ctx, value.entry.span(), "unsupported schema key {key}");
300        }
301        output.schema = Some(node.arg(0)?.ensure_string()?);
302    }
303    Ok(())
304}
305
306fn parse_framing(ctx: &ParsingContext, raw: String, entry: &KdlEntry) -> Result<Framing> {
307    raw.parse().map_err(|_| {
308        ctx.build_err(
309            format!("unsupported framing {raw}, expected one of: {FRAMING_VALUES}"),
310            entry.span(),
311        )
312    })
313}
314
315impl From<&SpecOutput> for KdlNode {
316    fn from(output: &SpecOutput) -> KdlNode {
317        let mut node = KdlNode::new("output");
318        node.push(string_entry(None, &output.name));
319        if let Some(media_type) = &output.media_type {
320            node.push(string_entry(Some("media_type"), media_type));
321        }
322        if output.framing != Framing::Text {
323            node.push(string_entry(Some("framing"), output.framing.as_str()));
324        }
325        if let Some(help) = &output.help {
326            node.push(string_entry(Some("help"), help));
327        }
328        if output.default {
329            node.push(KdlEntry::new_prop("default", true));
330        }
331        if output.hide {
332            node.push(KdlEntry::new_prop("hide", true));
333        }
334        if let Some(select) = &output.select {
335            node.push(string_entry(Some("select"), select));
336        }
337        if let Some(schema) = &output.schema {
338            let mut schema_node = KdlNode::new("schema");
339            schema_node.push(string_entry(None, schema));
340            let mut children = KdlDocument::new();
341            children.nodes_mut().push(schema_node);
342            node.set_children(children);
343        }
344        node
345    }
346}
347
348/// The outputs in effect for a command, CLI-wide declarations folded in.
349///
350/// Nearest wins, by name: a command redeclaring an inherited output refines it rather
351/// than adding a second entry with the same token. `hide=#true` is how one is taken away,
352/// so hidden entries are dropped after the fold rather than before it — a command can
353/// only hide what it has already inherited.
354///
355/// Folded on read rather than at parse time, following `unknown_flags`. Folding early
356/// would write the root's outputs into every command block on re-emission, which is a
357/// spec nobody wrote.
358pub fn effective_outputs(spec: &Spec, path: &[SpecCommand]) -> Vec<SpecOutput> {
359    effective_outputs_ref(spec, path.iter())
360}
361
362/// Reference-based form used by tree walkers that already hold the command chain.
363pub fn effective_outputs_ref<'a>(
364    spec: &Spec,
365    path: impl IntoIterator<Item = &'a SpecCommand>,
366) -> Vec<SpecOutput> {
367    let mut out: Vec<SpecOutput> = spec.outputs.clone();
368    let mut selected = &spec.cmd;
369    let mut inherited_globals = Vec::new();
370    for cmd in path {
371        inherited_globals.extend(selected.flags.iter().filter(|flag| flag.global));
372        selected = cmd;
373        for output in &cmd.outputs {
374            match out.iter_mut().find(|o| o.name == output.name) {
375                Some(existing) => *existing = output.clone(),
376                None => out.push(output.clone()),
377            }
378        }
379    }
380    out.retain(|o| !o.hide);
381    for output in &mut out {
382        if output.select.as_deref().is_some_and(|name| {
383            !selected.flags.iter().any(|flag| flag_named(flag, name))
384                && !inherited_globals.iter().any(|flag| flag_named(flag, name))
385        }) {
386            output.select = None;
387        }
388    }
389    out
390}
391
392/// The value-taking selector in effect for a command, if it reaches that command.
393pub fn effective_select(spec: &Spec, path: &[SpecCommand]) -> Option<String> {
394    let refs = path.iter().collect::<Vec<_>>();
395    effective_select_ref(spec, &refs)
396}
397
398/// Reference-based form used by tree walkers that already hold the command chain.
399pub fn effective_select_ref(spec: &Spec, path: &[&SpecCommand]) -> Option<String> {
400    let mut select = spec.select.clone();
401    for cmd in path {
402        if let Some(own) = &cmd.select {
403            select = Some(own.clone());
404        }
405    }
406    let selected = path.last().copied().unwrap_or(&spec.cmd);
407    select.filter(|name| selected.flags.iter().any(|flag| flag_named(flag, name)))
408}
409
410/// The flag a command's `select` names, and whether it was found locally.
411///
412/// The whole flag list is searched, not just the local one, because the ergonomic
413/// declaration is one global `--format` at the root and per-command `output` nodes under
414/// it.
415fn find_selector<'a>(
416    cmd: &'a SpecCommand,
417    inherited: &'a [SpecFlag],
418    name: &str,
419) -> Option<(&'a SpecFlag, bool)> {
420    if let Some(flag) = cmd.flags.iter().find(|f| flag_named(f, name)) {
421        return Some((flag, true));
422    }
423    inherited
424        .iter()
425        .find(|f| flag_named(f, name))
426        .map(|f| (f, false))
427}
428
429fn flag_named(flag: &SpecFlag, name: &str) -> bool {
430    let bare = name.trim_start_matches('-');
431    flag.long.iter().any(|l| l == bare)
432        || flag.short.iter().any(|s| s.to_string() == bare)
433        || flag.name == bare
434}
435
436/// Fill each selecting flag's `choices` from the outputs it picks among.
437///
438/// Runs once over the whole tree after the document is read, because a `select` may name
439/// a flag declared on an ancestor and a command being parsed cannot see its ancestors
440/// yet.
441pub(crate) fn resolve_selectors(spec: &mut Spec) -> Result<()> {
442    let root_outputs = spec.outputs.clone();
443    let root_select = spec.select.clone();
444    resolve_cmd(
445        &mut spec.cmd,
446        &[],
447        &root_outputs,
448        root_select.as_deref(),
449        true,
450    )
451}
452
453fn resolve_cmd(
454    cmd: &mut SpecCommand,
455    inherited_flags: &[SpecFlag],
456    inherited_outputs: &[SpecOutput],
457    inherited_select: Option<&str>,
458    is_root: bool,
459) -> Result<()> {
460    // What this command actually offers, and how it is asked for, both inherited unless
461    // it says otherwise.
462    let mut outputs: Vec<SpecOutput> = inherited_outputs.to_vec();
463    for output in &cmd.outputs {
464        match outputs.iter_mut().find(|o| o.name == output.name) {
465            Some(existing) => *existing = output.clone(),
466            None => outputs.push(output.clone()),
467        }
468    }
469    outputs.retain(|o| !o.hide);
470
471    // The globals a child inherits are taken *before* this command narrows anything, so a
472    // child narrows the flag as written rather than as this command left it. Without
473    // that, a root that declares outputs of its own hands every subcommand a `--format`
474    // whose choices are the root's, and the subcommand's own outputs read as a
475    // disagreement with a hand-written list.
476    let mut available: Vec<SpecFlag> = inherited_flags.to_vec();
477    available.extend(cmd.flags.iter().filter(|f| f.global).cloned());
478
479    // A `select` inherits only as far as the flag it names does. A non-global `--format`
480    // on `install` says nothing about `install from`, and the author wrote the `select` on
481    // the parent — so it is dropped here rather than reported. A `select` written *on*
482    // this command is a different matter: that one has to name something.
483    let select = match cmd.select.as_deref() {
484        Some(own) => Some(own.to_owned()),
485        None => inherited_select
486            .filter(|name| find_selector(cmd, inherited_flags, name).is_some())
487            .map(str::to_owned),
488    };
489
490    check_declarations(cmd, &outputs)?;
491    if let Some(name) = &select {
492        if !outputs.is_empty() {
493            materialize(cmd, inherited_flags, name, &outputs)?;
494        }
495    }
496    let local_outputs = if is_root {
497        inherited_outputs
498    } else {
499        &cmd.outputs
500    };
501    check_boolean_selectors(cmd, inherited_flags, &outputs, local_outputs)?;
502
503    for sub in cmd.subcommands.values_mut() {
504        resolve_cmd(sub, &available, &outputs, select.as_deref(), false)?;
505    }
506    Ok(())
507}
508
509/// The checks that hold whether or not anything selects the outputs.
510fn check_declarations(cmd: &SpecCommand, outputs: &[SpecOutput]) -> Result<()> {
511    let defaults: Vec<&str> = outputs
512        .iter()
513        .filter(|o| o.default)
514        .map(|o| o.name.as_str())
515        .collect();
516    if defaults.len() > 1 {
517        return Err(invalid(format!(
518            "`{}` has more than one default output ({}); only one can be what runs when \
519             nothing selects otherwise",
520            cmd.name,
521            defaults.join(", ")
522        )));
523    }
524    Ok(())
525}
526
527/// Fill the selecting flag's choices, or check the ones already written.
528fn materialize(
529    cmd: &mut SpecCommand,
530    inherited: &[SpecFlag],
531    name: &str,
532    outputs: &[SpecOutput],
533) -> Result<()> {
534    let Some((found, local)) = find_selector(cmd, inherited, name) else {
535        let declared: Vec<String> = cmd
536            .flags
537            .iter()
538            .chain(inherited)
539            .map(|f| f.usage())
540            .collect();
541        return Err(invalid(format!(
542            "select `{name}` on `{}` names no flag here or above it (declared: {})",
543            cmd.name,
544            if declared.is_empty() {
545                "none".to_string()
546            } else {
547                declared.join(", ")
548            }
549        )));
550    };
551
552    if found.arg.is_none() {
553        return Err(invalid(format!(
554            "select `{name}` on `{}` names a flag that takes no value, so it cannot carry an \
555             output name; a boolean picks one output with `output … select=\"{name}\"`",
556            cmd.name
557        )));
558    }
559
560    // An inherited flag is copied down before it is narrowed. Two commands under one
561    // global `--format` rarely produce the same outputs, and writing the union onto the
562    // shared flag would offer each of them values only the other accepts.
563    let mut flag = found.clone();
564    let arg = flag.arg.as_mut().expect("checked just above");
565    if let Some(existing) = &arg.choices {
566        // Written by hand, and richer than a list of names: per-choice help, aliases,
567        // hidden values. Overwriting would delete all of that, so this only checks that
568        // the two agree about which values exist.
569        let declared: BTreeSet<String> = existing.values().into_iter().collect();
570        let expected: BTreeSet<String> = outputs.iter().map(|o| o.name.clone()).collect();
571        if declared != expected {
572            let mut detail = Vec::new();
573            let only_flag = difference(&declared, &expected);
574            let only_output = difference(&expected, &declared);
575            if !only_flag.is_empty() {
576                detail.push(format!("the flag accepts {only_flag} with no output"));
577            }
578            if !only_output.is_empty() {
579                detail.push(format!("no choice offers {only_output}"));
580            }
581            return Err(invalid(format!(
582                "`{}` selects outputs with `{name}`, but its choices disagree: {}",
583                cmd.name,
584                detail.join(", ")
585            )));
586        }
587        return Ok(());
588    }
589
590    arg.choices = Some(SpecChoices {
591        choices: outputs.iter().map(|o| o.name.clone()).collect(),
592        // Only where there is something to say. `choices` is the authoritative list and
593        // `details` is looked up against it, so an entry per output would emit a `choice`
594        // block per value to carry nothing — the shorthand `choices human json` form is
595        // what a spec with no per-value help should keep.
596        details: outputs
597            .iter()
598            .filter(|o| o.help.is_some())
599            .map(|o| SpecChoice {
600                value: o.name.clone(),
601                help: o.help.clone(),
602                ..Default::default()
603            })
604            .collect(),
605        ..Default::default()
606    });
607
608    if local {
609        if let Some(slot) = cmd.flags.iter_mut().find(|f| flag_named(f, name)) {
610            *slot = flag;
611        }
612    } else {
613        cmd.flags.push(flag);
614    }
615    Ok(())
616}
617
618fn difference(a: &BTreeSet<String>, b: &BTreeSet<String>) -> String {
619    a.difference(b)
620        .map(|v| format!("`{v}`"))
621        .collect::<Vec<_>>()
622        .join(", ")
623}
624
625/// An `output … select="--json"` has to name a flag that exists and takes no value.
626fn check_boolean_selectors(
627    cmd: &SpecCommand,
628    inherited: &[SpecFlag],
629    outputs: &[SpecOutput],
630    local_outputs: &[SpecOutput],
631) -> Result<()> {
632    for output in outputs.iter().filter(|o| o.select.is_some()) {
633        let name = output.select.as_deref().expect("filtered");
634        let Some((flag, _)) = find_selector(cmd, inherited, name) else {
635            if local_outputs.iter().any(|local| local.name == output.name) {
636                return Err(invalid(format!(
637                    "output `{}` on `{}` is selected by `{name}`, which names no flag here or \
638                     above it",
639                    output.name, cmd.name
640                )));
641            }
642            continue;
643        };
644        if flag.arg.is_some() {
645            return Err(invalid(format!(
646                "output `{}` on `{}` is selected by `{name}`, which takes a value; a flag that \
647                 carries an output name belongs on the command as `select \"{name}\"`",
648                output.name, cmd.name
649            )));
650        }
651    }
652    Ok(())
653}
654
655/// Whether anything at all is declared, so a consumer can skip the whole concept.
656pub fn has_outputs(spec: &Spec, path: &[SpecCommand]) -> bool {
657    !spec.outputs.is_empty() || path.iter().any(|c| !c.outputs.is_empty())
658}
659
660#[cfg(test)]
661mod tests {
662    use super::*;
663    use insta::assert_snapshot;
664
665    fn parse(src: &str) -> Spec {
666        src.parse().expect("the fixture should parse")
667    }
668
669    /// The message, not the `Display`: a spanned parse error renders its text as a source
670    /// label, so `to_string()` on one is just "Invalid usage config".
671    fn error(src: &str) -> String {
672        match src.parse::<Spec>().expect_err("should be rejected") {
673            UsageErr::InvalidInput(msg, ..) => msg,
674            other => other.to_string(),
675        }
676    }
677
678    #[test]
679    fn framing_defaults_to_text_and_names_stay_the_users() {
680        let spec = parse(
681            r#"
682name "ex"
683cmd "ls" {
684    output "human" default=#true
685    output "ndjson" framing="jsonl"
686    output "checkstyle" media_type="application/xml"
687}
688"#,
689        );
690        let ls = &spec.cmd.subcommands["ls"];
691        assert_eq!(ls.outputs[0].framing, Framing::Text);
692        assert_eq!(ls.outputs[1].framing, Framing::Jsonl);
693        // The point of the split: the token a user types is not the contract a consumer
694        // reads. aube calls this `ndjson` and hk calls it `jsonl`.
695        assert_eq!(ls.outputs[1].name, "ndjson");
696        assert!(ls.outputs[1].framing.is_streaming());
697        assert_eq!(ls.outputs[2].framing, Framing::Text);
698        assert_eq!(ls.outputs[2].media_type.as_deref(), Some("application/xml"));
699    }
700
701    #[test]
702    fn an_unknown_framing_names_the_ones_that_exist() {
703        assert!(error(
704            r#"
705name "ex"
706cmd "ls" { output "x" framing="protobuf" }
707"#
708        )
709        .contains("expected one of: text, json, jsonl"));
710    }
711
712    #[test]
713    fn a_select_fills_the_flags_choices() {
714        let spec = parse(
715            r#"
716name "ex"
717cmd "ls" {
718    flag "--format <FMT>"
719    output "human" help="A table"
720    output "json" framing="json"
721    select "--format"
722}
723"#,
724        );
725        let choices = spec.cmd.subcommands["ls"].flags[0]
726            .arg
727            .as_ref()
728            .unwrap()
729            .choices
730            .as_ref()
731            .unwrap();
732        assert_eq!(choices.values(), vec!["human", "json"]);
733        // Output help becomes choice help, which is what a shell shows beside a candidate.
734        assert_eq!(choices.details[0].help.as_deref(), Some("A table"));
735    }
736
737    #[test]
738    fn a_global_selector_is_narrowed_per_command() {
739        // One `--format` at the root, two commands that write different things. Writing
740        // the union onto the shared flag would offer `ls` a value only `render` accepts,
741        // so each gets a copy holding just its own.
742        let spec = parse(
743            r#"
744name "ex"
745flag "--format <FMT>" global=#true
746cmd "ls"     { output "human"; output "json" framing="json"; select "--format" }
747cmd "render" { output "svg";   output "png"; select "--format" }
748"#,
749        );
750        let choices_of = |cmd: &str| {
751            spec.cmd.subcommands[cmd]
752                .flags
753                .iter()
754                .find(|f| f.long.iter().any(|l| l == "format"))
755                .and_then(|f| f.arg.as_ref())
756                .and_then(|a| a.choices.as_ref())
757                .map(|c| c.values())
758        };
759        assert_eq!(choices_of("ls"), Some(vec!["human".into(), "json".into()]));
760        assert_eq!(choices_of("render"), Some(vec!["svg".into(), "png".into()]));
761        // The root's own copy is left alone: it is the flag both narrowings came from.
762        assert!(spec.cmd.flags[0].arg.as_ref().unwrap().choices.is_none());
763    }
764
765    #[test]
766    fn choices_written_by_hand_are_checked_rather_than_replaced() {
767        // A hand-written block carries per-choice help and aliases that a list of output
768        // names does not, so agreement is checked and nothing is overwritten.
769        let spec = parse(
770            r#"
771name "ex"
772cmd "ls" {
773    flag "--format <FMT>" {
774        arg "<FMT>" { choices { choice "human" help="Kept"; choice "json" } }
775    }
776    output "human"
777    output "json" framing="json"
778    select "--format"
779}
780"#,
781        );
782        let choices = spec.cmd.subcommands["ls"].flags[0]
783            .arg
784            .as_ref()
785            .unwrap()
786            .choices
787            .as_ref()
788            .unwrap();
789        assert_eq!(choices.details[0].help.as_deref(), Some("Kept"));
790    }
791
792    #[test]
793    fn choices_that_disagree_with_the_outputs_are_an_error() {
794        let err = error(
795            r#"
796name "ex"
797cmd "ls" {
798    flag "--format <FMT>" { arg "<FMT>" { choices "human" "yaml" } }
799    output "human"
800    output "json" framing="json"
801    select "--format"
802}
803"#,
804        );
805        assert!(err.contains("`yaml` with no output"), "{err}");
806        assert!(err.contains("no choice offers `json`"), "{err}");
807    }
808
809    #[test]
810    fn a_select_naming_no_flag_says_what_is_declared() {
811        let err = error(
812            r#"
813name "ex"
814cmd "ls" {
815    flag "--verbose"
816    output "json" framing="json"
817    select "--format"
818}
819"#,
820        );
821        assert!(err.contains("names no flag here or above it"), "{err}");
822        assert!(err.contains("--verbose"), "{err}");
823    }
824
825    #[test]
826    fn a_select_naming_a_boolean_points_at_the_other_spelling() {
827        let err = error(
828            r#"
829name "ex"
830cmd "ls" {
831    flag "--format"
832    output "json" framing="json"
833    select "--format"
834}
835"#,
836        );
837        assert!(err.contains("takes no value"), "{err}");
838        assert!(err.contains("output … select=\"--format\""), "{err}");
839    }
840
841    #[test]
842    fn a_boolean_selector_picks_one_output() {
843        let spec = parse(
844            r#"
845name "ex"
846cmd "ls" {
847    flag "--json"
848    output "text" default=#true
849    output "json" framing="json" select="--json"
850}
851"#,
852        );
853        let ls = &spec.cmd.subcommands["ls"];
854        let json = ls.outputs.iter().find(|o| o.name == "json").unwrap();
855        assert_eq!(
856            json.select_argv(ls).map(|s| s.argv()),
857            Some(vec!["--json".to_string()])
858        );
859        // Nothing selects `text` on its own; it is what runs when `--json` is absent.
860        let text = ls.outputs.iter().find(|o| o.name == "text").unwrap();
861        assert_eq!(text.select_argv(ls), None);
862    }
863
864    #[test]
865    fn an_inherited_boolean_selector_stops_with_its_non_global_flag() {
866        let spec = parse(
867            r#"
868name "ex"
869flag "--json"
870output "text" default=#true
871output "json" framing="json" select="--json"
872cmd "nested"
873"#,
874        );
875        let nested = spec.cmd.subcommands["nested"].clone();
876        let outputs = effective_outputs(&spec, &[nested]);
877        assert_eq!(
878            outputs
879                .iter()
880                .find(|output| output.name == "json")
881                .and_then(|output| output.select.as_deref()),
882            None
883        );
884    }
885
886    #[test]
887    fn an_inherited_boolean_selector_reaches_nested_commands_with_its_global_flag() {
888        let spec = parse(
889            r#"
890name "ex"
891flag "--json" global=#true
892output "text" default=#true
893output "json" framing="json" select="--json"
894cmd "outer" {
895    cmd "inner"
896}
897"#,
898        );
899        let outer = &spec.cmd.subcommands["outer"];
900        let inner = &outer.subcommands["inner"];
901        let outputs = effective_outputs_ref(&spec, [outer, inner]);
902        assert_eq!(
903            outputs
904                .iter()
905                .find(|output| output.name == "json")
906                .and_then(|output| output.select.as_deref()),
907            Some("--json")
908        );
909    }
910
911    #[test]
912    fn a_root_boolean_selector_still_has_to_name_a_flag() {
913        let err = error(
914            r#"
915name "ex"
916output "json" framing="json" select="--json"
917"#,
918        );
919        assert!(err.contains("names no flag here or above it"), "{err}");
920    }
921
922    #[test]
923    fn a_boolean_selector_naming_a_value_flag_points_back() {
924        let err = error(
925            r#"
926name "ex"
927cmd "ls" {
928    flag "--format <FMT>"
929    output "json" framing="json" select="--format"
930}
931"#,
932        );
933        assert!(err.contains("which takes a value"), "{err}");
934        assert!(err.contains("`select \"--format\"`"), "{err}");
935    }
936
937    #[test]
938    fn two_defaults_are_an_error() {
939        let err = error(
940            r#"
941name "ex"
942cmd "ls" { output "a" default=#true; output "b" default=#true }
943"#,
944        );
945        assert!(err.contains("more than one default output (a, b)"), "{err}");
946    }
947
948    #[test]
949    fn cli_wide_outputs_are_inherited_and_can_be_taken_away() {
950        let spec = parse(
951            r#"
952name "ex"
953output "human" default=#true
954output "json" framing="json"
955select "--format"
956flag "--format <FMT>" global=#true
957cmd "stream" {
958    output "human" hide=#true
959    output "jsonl" framing="jsonl"
960}
961"#,
962        );
963        let stream = spec.cmd.subcommands["stream"].clone();
964        let names: Vec<String> = effective_outputs(&spec, std::slice::from_ref(&stream))
965            .into_iter()
966            .map(|o| o.name)
967            .collect();
968        // `human` was inherited and then hidden; `json` still comes down from the root.
969        assert_eq!(names, vec!["json", "jsonl"]);
970        let choices = stream
971            .flags
972            .iter()
973            .find(|f| f.long.iter().any(|l| l == "format"))
974            .and_then(|f| f.arg.as_ref())
975            .and_then(|a| a.choices.as_ref())
976            .map(|c| c.values());
977        assert_eq!(choices, Some(vec!["json".into(), "jsonl".into()]));
978    }
979
980    #[test]
981    fn resolution_is_idempotent_so_a_spec_round_trips() {
982        let src = r#"
983name "ex"
984flag "--format <FMT>" global=#true
985cmd "ls" {
986    output "human" default=#true help="A table"
987    output "json" framing="json"
988    select "--format"
989}
990"#;
991        let once = parse(src).to_string();
992        let twice = parse(&once).to_string();
993        // The second pass sees choices that already match and validates them instead of
994        // rewriting, which is what keeps a re-emitted spec stable.
995        assert_eq!(once, twice);
996        assert_snapshot!(once, @r#"
997        name ex
998        flag --format global=#true {
999            arg <FMT>
1000        }
1001        cmd ls {
1002            flag --format global=#true {
1003                arg <FMT> {
1004                    choices {
1005                        choice human help="A table"
1006                        choice json
1007                    }
1008                }
1009            }
1010            output human help="A table" default=#true
1011            output json framing=json
1012            select "--format"
1013        }
1014        "#);
1015    }
1016
1017    #[test]
1018    fn a_schema_survives_a_round_trip_with_its_newlines() {
1019        let src = "name \"ex\"\ncmd \"ls\" {\n    output \"json\" framing=\"json\" {\n        schema \"{\\n  \\\"type\\\": \\\"object\\\"\\n}\"\n    }\n}\n";
1020        let spec = parse(src);
1021        let schema = spec.cmd.subcommands["ls"].outputs[0]
1022            .schema
1023            .clone()
1024            .unwrap();
1025        assert_eq!(schema, "{\n  \"type\": \"object\"\n}");
1026        let again = parse(&spec.to_string());
1027        assert_eq!(
1028            again.cmd.subcommands["ls"].outputs[0].schema.as_deref(),
1029            Some(schema.as_str())
1030        );
1031    }
1032
1033    #[test]
1034    fn an_external_schema_is_relative_to_the_kdl_that_declares_it() {
1035        let dir = tempfile::tempdir().unwrap();
1036        let shared = dir.path().join("shared");
1037        std::fs::create_dir(&shared).unwrap();
1038        let root = dir.path().join("root.usage.kdl");
1039        let included = shared.join("outputs.usage.kdl");
1040        let schema_file = shared.join("report.schema.json");
1041        let schema = "{\n  \"type\": \"object\"\n}\n";
1042        std::fs::write(&schema_file, schema).unwrap();
1043        std::fs::write(
1044            &included,
1045            "output \"json\" framing=\"json\" { schema file=\"report.schema.json\" }\n",
1046        )
1047        .unwrap();
1048        std::fs::write(
1049            &root,
1050            "name \"ex\"\ninclude file=\"shared/outputs.usage.kdl\"\n",
1051        )
1052        .unwrap();
1053
1054        let spec = Spec::parse_file(&root).unwrap();
1055        assert_eq!(spec.outputs[0].schema.as_deref(), Some(schema));
1056        assert!(spec.sources.contains(&schema_file));
1057
1058        // Re-emission embeds what was loaded, just as it expands an include, so the result
1059        // does not depend on the original adjacent file.
1060        let emitted = spec.to_string();
1061        assert!(!emitted.contains("report.schema.json"));
1062        assert_eq!(parse(&emitted).outputs[0].schema.as_deref(), Some(schema));
1063    }
1064
1065    #[test]
1066    fn an_included_selector_resolves_against_the_parent_document() {
1067        let dir = tempfile::tempdir().unwrap();
1068        let root = dir.path().join("root.usage.kdl");
1069        let included = dir.path().join("outputs.usage.kdl");
1070        std::fs::write(
1071            &included,
1072            "output \"text\" default=#true\noutput \"json\" framing=\"json\"\nselect \"--format\"\n",
1073        )
1074        .unwrap();
1075        std::fs::write(
1076            &root,
1077            "name \"ex\"\ninclude file=\"outputs.usage.kdl\"\nflag \"--format <FORMAT>\"\n",
1078        )
1079        .unwrap();
1080
1081        let spec = Spec::parse_file(&root).unwrap();
1082        let choices = spec.cmd.flags[0]
1083            .arg
1084            .as_ref()
1085            .and_then(|arg| arg.choices.as_ref())
1086            .map(|choices| choices.values());
1087        assert_eq!(choices, Some(vec!["text".into(), "json".into()]));
1088    }
1089
1090    #[test]
1091    fn a_relative_schema_needs_a_source_file() {
1092        let err = error("name \"ex\"\noutput \"json\" { schema file=\"report.schema.json\" }\n");
1093        assert_eq!(err, "relative schema files require a source file");
1094    }
1095
1096    #[test]
1097    fn an_unreadable_schema_names_its_resolved_path() {
1098        let dir = tempfile::tempdir().unwrap();
1099        let root = dir.path().join("root.usage.kdl");
1100        let missing = dir.path().join("missing.schema.json");
1101        std::fs::write(
1102            &root,
1103            "name \"ex\"\noutput \"json\" { schema file=\"missing.schema.json\" }\n",
1104        )
1105        .unwrap();
1106
1107        match Spec::parse_file(&root).unwrap_err() {
1108            UsageErr::FileError(_, file) => assert_eq!(file, missing),
1109            err => panic!("unexpected error: {err:?}"),
1110        }
1111    }
1112}