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 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::SpecFlag;
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::{NodeHelper, string_entry};
65use crate::spec::{Spec, is_false};
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        && !outputs.is_empty()
495    {
496        materialize(cmd, inherited_flags, name, &outputs)?;
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!(
706            error(
707                r#"
708name "ex"
709cmd "ls" { output "x" framing="protobuf" }
710"#
711            )
712            .contains("expected one of: text, json, jsonl")
713        );
714    }
715
716    #[test]
717    fn a_select_fills_the_flags_choices() {
718        let spec = parse(
719            r#"
720name "ex"
721cmd "ls" {
722    flag "--format <FMT>"
723    output "human" help="A table"
724    output "json" framing="json"
725    select "--format"
726}
727"#,
728        );
729        let choices = spec.cmd.subcommands["ls"].flags[0]
730            .arg
731            .as_ref()
732            .unwrap()
733            .choices
734            .as_ref()
735            .unwrap();
736        assert_eq!(choices.values(), vec!["human", "json"]);
737        // Output help becomes choice help, which is what a shell shows beside a candidate.
738        assert_eq!(choices.details[0].help.as_deref(), Some("A table"));
739    }
740
741    #[test]
742    fn a_global_selector_is_narrowed_per_command() {
743        // One `--format` at the root, two commands that write different things. Writing
744        // the union onto the shared flag would offer `ls` a value only `render` accepts,
745        // so each gets a copy holding just its own.
746        let spec = parse(
747            r#"
748name "ex"
749flag "--format <FMT>" global=#true
750cmd "ls"     { output "human"; output "json" framing="json"; select "--format" }
751cmd "render" { output "svg";   output "png"; select "--format" }
752"#,
753        );
754        let choices_of = |cmd: &str| {
755            spec.cmd.subcommands[cmd]
756                .flags
757                .iter()
758                .find(|f| f.long.iter().any(|l| l == "format"))
759                .and_then(|f| f.arg.as_ref())
760                .and_then(|a| a.choices.as_ref())
761                .map(|c| c.values())
762        };
763        assert_eq!(choices_of("ls"), Some(vec!["human".into(), "json".into()]));
764        assert_eq!(choices_of("render"), Some(vec!["svg".into(), "png".into()]));
765        // The root's own copy is left alone: it is the flag both narrowings came from.
766        assert!(spec.cmd.flags[0].arg.as_ref().unwrap().choices.is_none());
767    }
768
769    #[test]
770    fn choices_written_by_hand_are_checked_rather_than_replaced() {
771        // A hand-written block carries per-choice help and aliases that a list of output
772        // names does not, so agreement is checked and nothing is overwritten.
773        let spec = parse(
774            r#"
775name "ex"
776cmd "ls" {
777    flag "--format <FMT>" {
778        arg "<FMT>" { choices { choice "human" help="Kept"; choice "json" } }
779    }
780    output "human"
781    output "json" framing="json"
782    select "--format"
783}
784"#,
785        );
786        let choices = spec.cmd.subcommands["ls"].flags[0]
787            .arg
788            .as_ref()
789            .unwrap()
790            .choices
791            .as_ref()
792            .unwrap();
793        assert_eq!(choices.details[0].help.as_deref(), Some("Kept"));
794    }
795
796    #[test]
797    fn choices_that_disagree_with_the_outputs_are_an_error() {
798        let err = error(
799            r#"
800name "ex"
801cmd "ls" {
802    flag "--format <FMT>" { arg "<FMT>" { choices "human" "yaml" } }
803    output "human"
804    output "json" framing="json"
805    select "--format"
806}
807"#,
808        );
809        assert!(err.contains("`yaml` with no output"), "{err}");
810        assert!(err.contains("no choice offers `json`"), "{err}");
811    }
812
813    #[test]
814    fn a_select_naming_no_flag_says_what_is_declared() {
815        let err = error(
816            r#"
817name "ex"
818cmd "ls" {
819    flag "--verbose"
820    output "json" framing="json"
821    select "--format"
822}
823"#,
824        );
825        assert!(err.contains("names no flag here or above it"), "{err}");
826        assert!(err.contains("--verbose"), "{err}");
827    }
828
829    #[test]
830    fn a_select_naming_a_boolean_points_at_the_other_spelling() {
831        let err = error(
832            r#"
833name "ex"
834cmd "ls" {
835    flag "--format"
836    output "json" framing="json"
837    select "--format"
838}
839"#,
840        );
841        assert!(err.contains("takes no value"), "{err}");
842        assert!(err.contains("output … select=\"--format\""), "{err}");
843    }
844
845    #[test]
846    fn a_boolean_selector_picks_one_output() {
847        let spec = parse(
848            r#"
849name "ex"
850cmd "ls" {
851    flag "--json"
852    output "text" default=#true
853    output "json" framing="json" select="--json"
854}
855"#,
856        );
857        let ls = &spec.cmd.subcommands["ls"];
858        let json = ls.outputs.iter().find(|o| o.name == "json").unwrap();
859        assert_eq!(
860            json.select_argv(ls).map(|s| s.argv()),
861            Some(vec!["--json".to_string()])
862        );
863        // Nothing selects `text` on its own; it is what runs when `--json` is absent.
864        let text = ls.outputs.iter().find(|o| o.name == "text").unwrap();
865        assert_eq!(text.select_argv(ls), None);
866    }
867
868    #[test]
869    fn an_inherited_boolean_selector_stops_with_its_non_global_flag() {
870        let spec = parse(
871            r#"
872name "ex"
873flag "--json"
874output "text" default=#true
875output "json" framing="json" select="--json"
876cmd "nested"
877"#,
878        );
879        let nested = spec.cmd.subcommands["nested"].clone();
880        let outputs = effective_outputs(&spec, &[nested]);
881        assert_eq!(
882            outputs
883                .iter()
884                .find(|output| output.name == "json")
885                .and_then(|output| output.select.as_deref()),
886            None
887        );
888    }
889
890    #[test]
891    fn an_inherited_boolean_selector_reaches_nested_commands_with_its_global_flag() {
892        let spec = parse(
893            r#"
894name "ex"
895flag "--json" global=#true
896output "text" default=#true
897output "json" framing="json" select="--json"
898cmd "outer" {
899    cmd "inner"
900}
901"#,
902        );
903        let outer = &spec.cmd.subcommands["outer"];
904        let inner = &outer.subcommands["inner"];
905        let outputs = effective_outputs_ref(&spec, [outer, inner]);
906        assert_eq!(
907            outputs
908                .iter()
909                .find(|output| output.name == "json")
910                .and_then(|output| output.select.as_deref()),
911            Some("--json")
912        );
913    }
914
915    #[test]
916    fn a_root_boolean_selector_still_has_to_name_a_flag() {
917        let err = error(
918            r#"
919name "ex"
920output "json" framing="json" select="--json"
921"#,
922        );
923        assert!(err.contains("names no flag here or above it"), "{err}");
924    }
925
926    #[test]
927    fn a_boolean_selector_naming_a_value_flag_points_back() {
928        let err = error(
929            r#"
930name "ex"
931cmd "ls" {
932    flag "--format <FMT>"
933    output "json" framing="json" select="--format"
934}
935"#,
936        );
937        assert!(err.contains("which takes a value"), "{err}");
938        assert!(err.contains("`select \"--format\"`"), "{err}");
939    }
940
941    #[test]
942    fn two_defaults_are_an_error() {
943        let err = error(
944            r#"
945name "ex"
946cmd "ls" { output "a" default=#true; output "b" default=#true }
947"#,
948        );
949        assert!(err.contains("more than one default output (a, b)"), "{err}");
950    }
951
952    #[test]
953    fn cli_wide_outputs_are_inherited_and_can_be_taken_away() {
954        let spec = parse(
955            r#"
956name "ex"
957output "human" default=#true
958output "json" framing="json"
959select "--format"
960flag "--format <FMT>" global=#true
961cmd "stream" {
962    output "human" hide=#true
963    output "jsonl" framing="jsonl"
964}
965"#,
966        );
967        let stream = spec.cmd.subcommands["stream"].clone();
968        let names: Vec<String> = effective_outputs(&spec, std::slice::from_ref(&stream))
969            .into_iter()
970            .map(|o| o.name)
971            .collect();
972        // `human` was inherited and then hidden; `json` still comes down from the root.
973        assert_eq!(names, vec!["json", "jsonl"]);
974        let choices = stream
975            .flags
976            .iter()
977            .find(|f| f.long.iter().any(|l| l == "format"))
978            .and_then(|f| f.arg.as_ref())
979            .and_then(|a| a.choices.as_ref())
980            .map(|c| c.values());
981        assert_eq!(choices, Some(vec!["json".into(), "jsonl".into()]));
982    }
983
984    #[test]
985    fn resolution_is_idempotent_so_a_spec_round_trips() {
986        let src = r#"
987name "ex"
988flag "--format <FMT>" global=#true
989cmd "ls" {
990    output "human" default=#true help="A table"
991    output "json" framing="json"
992    select "--format"
993}
994"#;
995        let once = parse(src).to_string();
996        let twice = parse(&once).to_string();
997        // The second pass sees choices that already match and validates them instead of
998        // rewriting, which is what keeps a re-emitted spec stable.
999        assert_eq!(once, twice);
1000        assert_snapshot!(once, @r#"
1001        name ex
1002        flag --format global=#true {
1003            arg <FMT>
1004        }
1005        cmd ls {
1006            flag --format global=#true {
1007                arg <FMT> {
1008                    choices {
1009                        choice human help="A table"
1010                        choice json
1011                    }
1012                }
1013            }
1014            output human help="A table" default=#true
1015            output json framing=json
1016            select "--format"
1017        }
1018        "#);
1019    }
1020
1021    #[test]
1022    fn a_schema_survives_a_round_trip_with_its_newlines() {
1023        let src = "name \"ex\"\ncmd \"ls\" {\n    output \"json\" framing=\"json\" {\n        schema \"{\\n  \\\"type\\\": \\\"object\\\"\\n}\"\n    }\n}\n";
1024        let spec = parse(src);
1025        let schema = spec.cmd.subcommands["ls"].outputs[0]
1026            .schema
1027            .clone()
1028            .unwrap();
1029        assert_eq!(schema, "{\n  \"type\": \"object\"\n}");
1030        let again = parse(&spec.to_string());
1031        assert_eq!(
1032            again.cmd.subcommands["ls"].outputs[0].schema.as_deref(),
1033            Some(schema.as_str())
1034        );
1035    }
1036
1037    #[test]
1038    fn an_external_schema_is_relative_to_the_kdl_that_declares_it() {
1039        let dir = tempfile::tempdir().unwrap();
1040        let shared = dir.path().join("shared");
1041        std::fs::create_dir(&shared).unwrap();
1042        let root = dir.path().join("root.usage.kdl");
1043        let included = shared.join("outputs.usage.kdl");
1044        let schema_file = shared.join("report.schema.json");
1045        let schema = "{\n  \"type\": \"object\"\n}\n";
1046        std::fs::write(&schema_file, schema).unwrap();
1047        std::fs::write(
1048            &included,
1049            "output \"json\" framing=\"json\" { schema file=\"report.schema.json\" }\n",
1050        )
1051        .unwrap();
1052        std::fs::write(
1053            &root,
1054            "name \"ex\"\ninclude file=\"shared/outputs.usage.kdl\"\n",
1055        )
1056        .unwrap();
1057
1058        let spec = Spec::parse_file(&root).unwrap();
1059        assert_eq!(spec.outputs[0].schema.as_deref(), Some(schema));
1060        assert!(spec.sources.contains(&schema_file));
1061
1062        // Re-emission embeds what was loaded, just as it expands an include, so the result
1063        // does not depend on the original adjacent file.
1064        let emitted = spec.to_string();
1065        assert!(!emitted.contains("report.schema.json"));
1066        assert_eq!(parse(&emitted).outputs[0].schema.as_deref(), Some(schema));
1067    }
1068
1069    #[test]
1070    fn an_included_selector_resolves_against_the_parent_document() {
1071        let dir = tempfile::tempdir().unwrap();
1072        let root = dir.path().join("root.usage.kdl");
1073        let included = dir.path().join("outputs.usage.kdl");
1074        std::fs::write(
1075            &included,
1076            "output \"text\" default=#true\noutput \"json\" framing=\"json\"\nselect \"--format\"\n",
1077        )
1078        .unwrap();
1079        std::fs::write(
1080            &root,
1081            "name \"ex\"\ninclude file=\"outputs.usage.kdl\"\nflag \"--format <FORMAT>\"\n",
1082        )
1083        .unwrap();
1084
1085        let spec = Spec::parse_file(&root).unwrap();
1086        let choices = spec.cmd.flags[0]
1087            .arg
1088            .as_ref()
1089            .and_then(|arg| arg.choices.as_ref())
1090            .map(|choices| choices.values());
1091        assert_eq!(choices, Some(vec!["text".into(), "json".into()]));
1092    }
1093
1094    #[test]
1095    fn a_relative_schema_needs_a_source_file() {
1096        let err = error("name \"ex\"\noutput \"json\" { schema file=\"report.schema.json\" }\n");
1097        assert_eq!(err, "relative schema files require a source file");
1098    }
1099
1100    #[test]
1101    fn an_unreadable_schema_names_its_resolved_path() {
1102        let dir = tempfile::tempdir().unwrap();
1103        let root = dir.path().join("root.usage.kdl");
1104        let missing = dir.path().join("missing.schema.json");
1105        std::fs::write(
1106            &root,
1107            "name \"ex\"\noutput \"json\" { schema file=\"missing.schema.json\" }\n",
1108        )
1109        .unwrap();
1110
1111        match Spec::parse_file(&root).unwrap_err() {
1112            UsageErr::FileError(_, file) => assert_eq!(file, missing),
1113            err => panic!("unexpected error: {err:?}"),
1114        }
1115    }
1116}