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