Skip to main content

usage/spec/
flagset.rs

1//! Named sets of flag declarations, and the `use` node that pulls one in.
2//!
3//! mise's checked-in spec is 5,592 lines for 711 flags, and most of that count is
4//! the same handful of declarations written again under the next command. The
5//! derive has an answer for this — `flatten` — and a hand-written spec had none.
6//!
7//! A flagset is authoring sugar, resolved while the file is read:
8//!
9//! ```kdl
10//! flagset "output" {
11//!     flag "-v --verbose" help="Print more"
12//!     flag "--json" help="JSON output"
13//! }
14//!
15//! cmd "build" {
16//!     use "output"
17//!     flag "--release"
18//! }
19//! ```
20//!
21//! By the time anything downstream sees the spec, `build` holds three ordinary
22//! flags in that order. Nothing else in the model, in help, in completions or in
23//! the generated parsers learns a new concept, which is the point: the same rule
24//! the derive follows, that a spec is the semantic model, means reuse has to
25//! disappear into what it stands for rather than becoming vocabulary every
26//! consumer must implement.
27//!
28//! The cost of that choice is that it is one-way. A spec parsed and re-emitted
29//! contains the expanded flags, not the `flagset` and `use` nodes that produced
30//! them, the same way `include` does not survive a round trip.
31
32use std::collections::HashMap;
33use std::path::{Path, PathBuf};
34
35use indexmap::IndexMap;
36use miette::SourceSpan;
37use serde::Serialize;
38
39use crate::error::UsageErr;
40use crate::spec::context::ParsingContext;
41use crate::spec::helpers::NodeHelper;
42use crate::spec::spec_flag_forms_overlap;
43use crate::{SpecCommand, SpecFlag};
44
45/// A named, reusable set of flag declarations.
46///
47/// Declared at the root of a spec, never inside a command: a set that one command
48/// can see and its sibling cannot is a scoping rule to explain for no benefit.
49#[derive(Debug, Clone, Serialize)]
50#[non_exhaustive]
51pub struct SpecFlagSet {
52    pub name: String,
53    /// Flags declared directly in the set.
54    pub flags: Vec<SpecFlag>,
55    /// Sets this one composes, so a big set can be assembled from small ones.
56    ///
57    /// Emptied while the file is read, like a command's: what it named becomes part of
58    /// [`Self::flags`], so a file that includes this one inherits a set with nothing left to
59    /// resolve.
60    #[serde(skip_serializing_if = "Vec::is_empty")]
61    pub uses: Vec<SpecUse>,
62    /// The node, for an error raised while flattening the set rather than at one `use`.
63    #[serde(skip)]
64    pub(crate) span: SourceSpan,
65    /// The file this set was declared in, resolved so two routes to one file agree.
66    ///
67    /// A name may be declared once, and what makes that one rule rather than two is that a
68    /// *declaration* is what is counted: a shared file reaching a spec through two includes
69    /// is one declaration arriving twice, not two of them.
70    #[serde(skip)]
71    pub(crate) declared_in: PathBuf,
72}
73
74/// One `use` node: the flagsets whose declarations belong where it stands.
75#[derive(Debug, Clone, Serialize)]
76#[non_exhaustive]
77pub struct SpecUse {
78    /// The sets named, in the order they were written.
79    pub names: Vec<String>,
80    /// Where the expansion belongs in the owner's flag list.
81    ///
82    /// Help order is spec order, so a `use` between two flags has to expand
83    /// between them rather than at the end of the list.
84    pub at: usize,
85    /// Kept for the error a bad name produces, which is reported after the whole
86    /// file is read rather than at the node.
87    #[serde(skip)]
88    pub(crate) span: SourceSpan,
89}
90
91impl Default for SpecFlagSet {
92    fn default() -> Self {
93        Self {
94            name: String::new(),
95            flags: vec![],
96            uses: vec![],
97            // Nowhere to point: a set that was not read from a file has no node, and the
98            // errors this span serves can only come from one that was.
99            span: (0, 0).into(),
100            declared_in: PathBuf::new(),
101        }
102    }
103}
104
105/// Where a file's declarations come from, as a name two routes to that file share.
106///
107/// A diamond of includes reaches one shared file as `a/../common.usage.kdl` from one side and
108/// `b/../common.usage.kdl` from the other. Those are the same declaration, and comparing the
109/// paths as written would call them two.
110pub(crate) fn declaring_file(file: &Path) -> PathBuf {
111    // A spec parsed from a string has no file, and canonicalizing nothing fails: the empty
112    // path is then the honest answer, and it cannot collide with a real one.
113    std::fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf())
114}
115
116impl SpecFlagSet {
117    pub(crate) fn parse(ctx: &ParsingContext, node: &NodeHelper) -> Result<Self, UsageErr> {
118        node.ensure_arg_len(1..=1)?;
119        let mut set = Self {
120            name: node.arg(0)?.ensure_string()?,
121            flags: vec![],
122            uses: vec![],
123            span: node.span(),
124            declared_in: declaring_file(&ctx.file),
125        };
126        if let Some((k, v)) = node.props().first() {
127            bail_parse!(ctx, v.entry.span(), "unsupported flagset prop {k}");
128        }
129        for child in node.children() {
130            match child.name() {
131                "flag" => set.flags.push(SpecFlag::parse(ctx, &child)?),
132                "use" => set.uses.push(SpecUse::parse(ctx, &child, set.flags.len())?),
133                // Positionals are deliberately not here. A set of flags is reusable
134                // because a flag is identified by its spelling wherever it lands; a
135                // positional is identified by its position, so the same set spliced
136                // into two commands with different arguments means two different
137                // things. `flatten` on the derive side has the field order of one
138                // struct to go by and this has nothing.
139                "arg" => bail_parse!(
140                    ctx,
141                    child.node.name().span(),
142                    "a flagset holds flags, not arguments: declare the argument on \
143                     each command that takes it"
144                ),
145                k => bail_parse!(ctx, child.node.name().span(), "unsupported flagset key {k}"),
146            }
147        }
148        Ok(set)
149    }
150}
151
152impl SpecUse {
153    pub(crate) fn parse(
154        ctx: &ParsingContext,
155        node: &NodeHelper,
156        at: usize,
157    ) -> Result<Self, UsageErr> {
158        node.ensure_arg_len(1..)?;
159        if let Some((k, v)) = node.props().first() {
160            bail_parse!(ctx, v.entry.span(), "unsupported use prop {k}");
161        }
162        if !node.children().is_empty() {
163            bail_parse!(
164                ctx,
165                node.span(),
166                "`use` names flagsets and holds nothing: declare the flags in the \
167                 flagset itself"
168            );
169        }
170        Ok(Self {
171            names: node
172                .args()
173                .map(|a| a.ensure_string())
174                .collect::<Result<Vec<_>, _>>()?,
175            at,
176            span: node.span(),
177        })
178    }
179}
180
181/// Replace every `use` in the file with the flags it names.
182///
183/// Runs once, after the whole file is read, so a `use` may name a set declared below it or
184/// brought in by an `include`. What it cannot see is a set declared in a file that includes
185/// *this* one: each file resolves its own `use` nodes, and an unresolved one is an error
186/// there rather than a value carried up to be resolved by whoever reads the file next.
187///
188/// Which is why the sets are flattened first, before any command is looked at, even though
189/// nothing may use them. Resolving a set only when a command asked for one left an included
190/// file's `use` to be answered by the file that included it — with the wrong flagsets in
191/// scope, and an error pointing into the wrong source.
192pub(crate) fn expand(
193    ctx: &ParsingContext,
194    cmd: &mut SpecCommand,
195    flagsets: &mut IndexMap<String, SpecFlagSet>,
196) -> Result<(), UsageErr> {
197    let mut cache = {
198        let mut resolver = Resolver {
199            ctx,
200            flagsets,
201            cache: HashMap::new(),
202            stack: vec![],
203        };
204        for (name, set) in resolver.flagsets {
205            resolver.resolve(name, set.span)?;
206        }
207        resolver.cache
208    };
209    // A set that used another is now the flags of both. Written back so that what travels
210    // through an `include` is a set and not a question.
211    for (name, set) in flagsets.iter_mut() {
212        if let Some(flags) = cache.get(name) {
213            set.flags = flags.clone();
214        }
215        set.uses.clear();
216    }
217    let mut resolver = Resolver {
218        ctx,
219        flagsets,
220        cache: core::mem::take(&mut cache),
221        stack: vec![],
222    };
223    expand_cmd(cmd, &mut resolver)
224}
225
226fn expand_cmd(cmd: &mut SpecCommand, resolver: &mut Resolver) -> Result<(), UsageErr> {
227    // Taken rather than read: the expansion _is_ the flags now, and leaving the
228    // request behind would let a second pass double it.
229    let uses = std::mem::take(&mut cmd.uses);
230    splice(&mut cmd.flags, &uses, resolver)?;
231    for sub in cmd.subcommands.values_mut() {
232        expand_cmd(sub, resolver)?;
233    }
234    Ok(())
235}
236
237/// Insert what each `use` names, at the position where it stood.
238fn splice(
239    flags: &mut Vec<SpecFlag>,
240    uses: &[SpecUse],
241    resolver: &mut Resolver,
242) -> Result<(), UsageErr> {
243    let mut inserted = 0;
244    for u in uses {
245        let mut at = (u.at + inserted).min(flags.len());
246        for name in &u.names {
247            for flag in resolver.resolve(name, u.span)? {
248                // The nearer declaration owns the spelling, as it does when a
249                // subcommand redeclares a global: a command that says `use "output"`
250                // and then declares its own `--json` gets its own, and a set
251                // reachable twice through composition contributes once.
252                if flags.iter().any(|f| spec_flag_forms_overlap(f, &flag)) {
253                    continue;
254                }
255                flags.insert(at, flag);
256                at += 1;
257                inserted += 1;
258            }
259        }
260    }
261    Ok(())
262}
263
264struct Resolver<'a> {
265    ctx: &'a ParsingContext,
266    flagsets: &'a IndexMap<String, SpecFlagSet>,
267    /// A set composed by several commands is resolved once.
268    cache: HashMap<String, Vec<SpecFlag>>,
269    /// The chain currently being resolved, so a cycle is reported as the path
270    /// that closes it rather than as a stack overflow.
271    stack: Vec<String>,
272}
273
274impl Resolver<'_> {
275    fn resolve(&mut self, name: &str, span: SourceSpan) -> Result<Vec<SpecFlag>, UsageErr> {
276        if let Some(flags) = self.cache.get(name) {
277            return Ok(flags.clone());
278        }
279        if self.stack.iter().any(|seen| seen == name) {
280            let ctx = self.ctx;
281            let path = self
282                .stack
283                .iter()
284                .map(String::as_str)
285                .chain([name])
286                .collect::<Vec<_>>()
287                .join(" -> ");
288            bail_parse!(ctx, span, "flagset cycle: {path}");
289        }
290        let flagsets = self.flagsets;
291        let Some(set) = flagsets.get(name) else {
292            let ctx = self.ctx;
293            let known = flagsets.keys().cloned().collect::<Vec<_>>().join(", ");
294            let hint = match known.is_empty() {
295                true => "no flagsets are declared".to_string(),
296                false => format!("declared: {known}"),
297            };
298            bail_parse!(ctx, span, "unknown flagset \"{name}\" ({hint})");
299        };
300        self.stack.push(name.to_string());
301        let mut flags = set.flags.clone();
302        let result = splice(&mut flags, &set.uses, self);
303        self.stack.pop();
304        result?;
305        self.cache.insert(name.to_string(), flags.clone());
306        Ok(flags)
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use crate::Spec;
313    use insta::assert_snapshot;
314
315    fn parse(input: &str) -> Spec {
316        Spec::parse(&Default::default(), input).unwrap()
317    }
318
319    /// The message a bad spec produced. `UsageErr::InvalidInput` renders as a miette
320    /// diagnostic, so `to_string` is the headline rather than what went wrong.
321    fn err(input: &str) -> String {
322        match Spec::parse(&Default::default(), input).unwrap_err() {
323            crate::error::UsageErr::InvalidInput(msg, _, _) => msg,
324            err => panic!("unexpected error: {err:?}"),
325        }
326    }
327
328    #[test]
329    fn a_set_expands_where_the_use_stands() {
330        // The `use` is between two flags, so the set's flags land between them: help order
331        // is spec order, and a set that always appended would reorder the command.
332        let spec = parse(
333            r#"
334bin "ex"
335flagset "output" {
336    flag "-v --verbose" help="Print more"
337    flag "--json" help="JSON output"
338}
339cmd "build" {
340    flag "--release"
341    use "output"
342    flag "--target" {
343        arg "<triple>"
344    }
345}
346        "#,
347        );
348        assert_snapshot!(spec, @r#"
349        name ex
350        bin ex
351        cmd build {
352            flag --release
353            flag "-v --verbose" help="Print more"
354            flag --json help="JSON output"
355            flag --target {
356                arg <triple>
357            }
358        }
359        "#);
360    }
361
362    #[test]
363    fn one_use_names_several_sets_and_the_root_can_use_them_too() {
364        let spec = parse(
365            r#"
366bin "ex"
367use "logging" "output"
368flagset "logging" {
369    flag "-v --verbose" global=#true
370}
371flagset "output" {
372    flag "--json"
373}
374        "#,
375        );
376        // Declared below the `use` that names them: a spec is read whole before it is
377        // resolved, so declaration order is the author's business.
378        assert_snapshot!(spec, @r#"
379        name ex
380        bin ex
381        flag "-v --verbose" global=#true
382        flag --json
383        "#);
384    }
385
386    #[test]
387    fn a_set_composes_other_sets_and_a_diamond_contributes_once() {
388        let spec = parse(
389            r#"
390bin "ex"
391flagset "common" {
392    flag "-v --verbose"
393}
394flagset "output" {
395    use "common"
396    flag "--json"
397}
398flagset "input" {
399    use "common"
400    flag "--stdin"
401}
402cmd "run" {
403    use "output" "input"
404}
405        "#,
406        );
407        assert_snapshot!(spec, @r#"
408        name ex
409        bin ex
410        cmd run {
411            flag "-v --verbose"
412            flag --json
413            flag --stdin
414        }
415        "#);
416    }
417
418    #[test]
419    fn the_commands_own_declaration_wins() {
420        // The same rule a redeclared global follows: the nearer declaration owns the
421        // spelling. A command that wants one flag of a set said differently keeps the set.
422        let spec = parse(
423            r#"
424bin "ex"
425flagset "output" {
426    flag "--json" help="JSON output"
427    flag "-q --quiet"
428}
429cmd "build" {
430    use "output"
431    flag "--json" help="build's own JSON, with a schema"
432}
433        "#,
434        );
435        assert_snapshot!(spec, @r#"
436        name ex
437        bin ex
438        cmd build {
439            flag "-q --quiet"
440            flag --json help="build's own JSON, with a schema"
441        }
442        "#);
443    }
444
445    #[test]
446    fn a_short_form_collision_counts_as_the_same_flag() {
447        // Overlap is per-form, as it is everywhere else: `-j` is taken even though the
448        // long names differ.
449        let spec = parse(
450            r#"
451bin "ex"
452flagset "common" {
453    flag "-j --jobs" {
454        arg "<n>"
455    }
456}
457cmd "build" {
458    use "common"
459    flag "-j --job-count" {
460        arg "<n>"
461    }
462}
463        "#,
464        );
465        assert_snapshot!(spec, @r#"
466        name ex
467        bin ex
468        cmd build {
469            flag "-j --job-count" {
470                arg <n>
471            }
472        }
473        "#);
474    }
475
476    #[test]
477    fn a_set_reaches_every_depth_of_the_tree() {
478        let spec = parse(
479            r#"
480bin "ex"
481flagset "common" {
482    flag "-v --verbose"
483}
484cmd "remote" {
485    use "common"
486    cmd "add" {
487        use "common"
488        arg "<name>"
489    }
490}
491        "#,
492        );
493        assert_snapshot!(spec, @r#"
494        name ex
495        bin ex
496        cmd remote {
497            flag "-v --verbose"
498            cmd add {
499                flag "-v --verbose"
500                arg <name>
501            }
502        }
503        "#);
504    }
505
506    #[test]
507    fn an_included_file_can_hold_the_shared_sets() {
508        // The reason `include` exists is a file of declarations shared by a CLI's specs,
509        // and flagsets are exactly that kind of declaration.
510        let dir = tempfile::tempdir().unwrap();
511        let common = dir.path().join("common.usage.kdl");
512        let root = dir.path().join("ex.usage.kdl");
513        std::fs::write(
514            &common,
515            "flagset \"common\" {\n    flag \"-v --verbose\"\n}\n",
516        )
517        .unwrap();
518        std::fs::write(
519            &root,
520            "bin \"ex\"\ninclude file=\"./common.usage.kdl\"\ncmd \"build\" {\n    use \"common\"\n}\n",
521        )
522        .unwrap();
523
524        let spec = Spec::parse_file(&root).unwrap();
525
526        assert_snapshot!(spec, @r#"
527        name ex
528        bin ex
529        cmd build {
530            flag "-v --verbose"
531        }
532        "#);
533    }
534
535    #[test]
536    fn a_set_is_resolved_by_the_file_that_wrote_it() {
537        // The composition an included file writes is answered by the included file. Letting
538        // the includer answer it would make what a file means depend on who read it, and
539        // would report the mistake against the wrong source.
540        let dir = tempfile::tempdir().unwrap();
541        let common = dir.path().join("common.usage.kdl");
542        let root = dir.path().join("ex.usage.kdl");
543        std::fs::write(&common, "flagset \"child\" {\n    use \"parent-only\"\n}\n").unwrap();
544        std::fs::write(
545            &root,
546            "bin \"ex\"\ninclude file=\"./common.usage.kdl\"\nflagset \"parent-only\" {\n                 flag \"--from-parent\"\n}\ncmd \"build\" {\n    use \"child\"\n}\n",
547        )
548        .unwrap();
549
550        let err = Spec::parse_file(&root).unwrap_err();
551        let crate::error::UsageErr::InvalidInput(msg, _, source) = err else {
552            panic!("unexpected error: {err:?}");
553        };
554        assert!(
555            msg.contains("unknown flagset \"parent-only\" (declared: child)"),
556            "{msg}"
557        );
558        // Named against the file that wrote the `use`, which is the half a lazy resolve got
559        // wrong even where it happened to refuse.
560        assert!(
561            source.name().ends_with("common.usage.kdl"),
562            "{:?}",
563            source.name()
564        );
565    }
566
567    #[test]
568    fn a_use_goes_with_the_flags_an_include_replaced() {
569        // An included file that declares root flags owns the root's flags — that is what
570        // `include` has always meant, and why the merge drops groups with them. A `use` is a
571        // declaration of flags, so it goes the same way: keeping it would splice the set into
572        // the incoming list at a position from a list that is gone.
573        let dir = tempfile::tempdir().unwrap();
574        let included = dir.path().join("overrides.usage.kdl");
575        let root = dir.path().join("ex.usage.kdl");
576        std::fs::write(&included, "flag \"--from-include\"\n").unwrap();
577        std::fs::write(
578            &root,
579            "bin \"ex\"\nflagset \"common\" {\n    flag \"-v --verbose\"\n}\nflag \"--own\"\n             use \"common\"\ninclude file=\"./overrides.usage.kdl\"\n",
580        )
581        .unwrap();
582
583        let spec = Spec::parse_file(&root).unwrap();
584
585        assert_snapshot!(spec, @r#"
586        name ex
587        bin ex
588        flag --from-include
589        "#);
590    }
591
592    #[test]
593    fn a_use_survives_an_include_that_declares_no_flags() {
594        // The ordinary shape: a file of shared declarations, and a spec that uses them. The
595        // rule above must not reach this one.
596        let dir = tempfile::tempdir().unwrap();
597        let included = dir.path().join("common.usage.kdl");
598        let root = dir.path().join("ex.usage.kdl");
599        std::fs::write(
600            &included,
601            "flagset \"common\" {\n    flag \"-v --verbose\"\n}\n",
602        )
603        .unwrap();
604        std::fs::write(
605            &root,
606            "bin \"ex\"\nuse \"common\"\ninclude file=\"./common.usage.kdl\"\n",
607        )
608        .unwrap();
609
610        let spec = Spec::parse_file(&root).unwrap();
611
612        assert_snapshot!(spec, @r#"
613        name ex
614        bin ex
615        flag "-v --verbose"
616        "#);
617    }
618
619    #[test]
620    fn a_set_nothing_uses_is_still_resolved() {
621        // Every set is flattened whether or not a command asks for one, so a mistake inside
622        // an unused set is reported rather than waiting for the day something uses it.
623        let msg = err("flagset \"a\" {\n    use \"missing\"\n}\n");
624        assert!(msg.contains("unknown flagset \"missing\""), "{msg}");
625    }
626
627    #[test]
628    fn an_included_set_may_compose_one_from_its_own_file() {
629        let dir = tempfile::tempdir().unwrap();
630        let common = dir.path().join("common.usage.kdl");
631        let root = dir.path().join("ex.usage.kdl");
632        std::fs::write(
633            &common,
634            "flagset \"logging\" {\n    flag \"-v --verbose\"\n}\nflagset \"common\" {\n                 use \"logging\"\n    flag \"--config\"\n}\n",
635        )
636        .unwrap();
637        std::fs::write(
638            &root,
639            "bin \"ex\"\ninclude file=\"./common.usage.kdl\"\ncmd \"build\" {\n    use \"common\"\n}\n",
640        )
641        .unwrap();
642
643        let spec = Spec::parse_file(&root).unwrap();
644
645        assert_snapshot!(spec, @r#"
646        name ex
647        bin ex
648        cmd build {
649            flag "-v --verbose"
650            flag --config
651        }
652        "#);
653    }
654
655    #[test]
656    fn the_flags_a_set_brought_parse_like_any_other() {
657        // The expansion is the whole feature: nothing downstream of the parse knows a
658        // flagset was involved.
659        let spec = parse(
660            r#"
661bin "ex"
662flagset "common" {
663    flag "-j --jobs" {
664        arg "<n>"
665    }
666}
667cmd "build" {
668    use "common"
669}
670        "#,
671        );
672        let words = ["ex", "build", "--jobs", "4"].map(String::from);
673        let parsed = crate::parse(&spec, &words).unwrap();
674        assert_eq!(parsed.as_env().get("usage_jobs").unwrap(), "4");
675    }
676
677    #[test]
678    fn a_global_from_a_set_is_inherited_like_any_other() {
679        // Expansion happens before anything reads the spec, so a set's flags take part in
680        // every rule an inline declaration would: `global` here, and equally `conflicts`,
681        // `group` membership, or a completer keyed on the flag's value name.
682        let spec = parse(
683            r#"
684bin "ex"
685flagset "logging" {
686    flag "-v --verbose" global=#true
687}
688use "logging"
689cmd "build"
690        "#,
691        );
692        let words = ["ex", "build", "--verbose"].map(String::from);
693        let parsed = crate::parse(&spec, &words).unwrap();
694        assert_eq!(parsed.as_env().get("usage_verbose").unwrap(), "true");
695    }
696
697    #[test]
698    fn an_unknown_set_says_what_is_declared() {
699        let msg = err(r#"
700bin "ex"
701flagset "output" {
702    flag "--json"
703}
704cmd "build" {
705    use "outupt"
706}
707        "#);
708        assert!(
709            msg.contains("unknown flagset \"outupt\" (declared: output)"),
710            "{msg}"
711        );
712    }
713
714    #[test]
715    fn a_use_with_no_sets_at_all_says_so() {
716        let msg = err("bin \"ex\"\ncmd \"build\" {\n    use \"output\"\n}\n");
717        assert!(
718            msg.contains("unknown flagset \"output\" (no flagsets are declared)"),
719            "{msg}"
720        );
721    }
722
723    #[test]
724    fn a_cycle_is_reported_as_the_path_that_closes_it() {
725        let msg = err(r#"
726bin "ex"
727flagset "a" {
728    use "b"
729}
730flagset "b" {
731    use "a"
732}
733cmd "build" {
734    use "a"
735}
736        "#);
737        assert!(msg.contains("flagset cycle: a -> b -> a"), "{msg}");
738    }
739
740    #[test]
741    fn a_set_that_uses_itself_is_the_same_error() {
742        let msg = err("bin \"ex\"\nflagset \"a\" {\n    use \"a\"\n}\nuse \"a\"\n");
743        assert!(msg.contains("flagset cycle: a -> a"), "{msg}");
744    }
745
746    #[test]
747    fn a_name_may_be_declared_once() {
748        let msg = err(r#"
749flagset "output" {
750    flag "--json"
751}
752flagset "output" {
753    flag "--yaml"
754}
755        "#);
756        assert!(msg.contains("a flagset may be declared only once"), "{msg}");
757    }
758
759    /// The message from a spec read off disk, so an include can take part.
760    fn err_file(root: &std::path::Path) -> String {
761        match Spec::parse_file(root).unwrap_err() {
762            crate::error::UsageErr::InvalidInput(msg, _, _) => msg,
763            err => panic!("unexpected error: {err:?}"),
764        }
765    }
766
767    #[test]
768    fn a_name_an_include_also_declares_is_refused_whichever_side_wrote_it_first() {
769        // Declared twice is declared twice, and an `include` does not make it a choice.
770        // Extending the map would have let the incoming set take the name — but only when
771        // the `include` stood below the declaration, since a `flagset` written after an
772        // `include` already hit the once-only check. So which set answered a `use` came
773        // down to where in the file the `include` was written.
774        let dir = tempfile::tempdir().unwrap();
775        let common = dir.path().join("common.usage.kdl");
776        std::fs::write(&common, "flagset \"output\" {\n    flag \"--yaml\"\n}\n").unwrap();
777        let own = "flagset \"output\" {\n    flag \"--json\"\n}\n";
778        let include = "include file=\"./common.usage.kdl\"\n";
779
780        let after = dir.path().join("after.usage.kdl");
781        std::fs::write(&after, format!("bin \"ex\"\n{own}{include}")).unwrap();
782        let msg = err_file(&after);
783        assert!(
784            msg.contains("a flagset may be declared only once")
785                && msg.contains("common.usage.kdl")
786                && msg.contains("\"output\""),
787            "{msg}"
788        );
789
790        // The other order was already refused, and still says so.
791        let before = dir.path().join("before.usage.kdl");
792        std::fs::write(&before, format!("bin \"ex\"\n{include}{own}")).unwrap();
793        let msg = err_file(&before);
794        assert!(msg.contains("a flagset may be declared only once"), "{msg}");
795    }
796
797    #[test]
798    fn a_shared_file_may_reach_a_spec_by_two_routes() {
799        // The shape the feature asks for. Each file resolves its own `use` nodes, so every
800        // file whose commands name the shared sets includes the file that declares them —
801        // and a spec that includes two such files sees the shared set arrive twice. That is
802        // one declaration by two routes, not two declarations, so the once-only rule has
803        // nothing to say about it.
804        let dir = tempfile::tempdir().unwrap();
805        std::fs::create_dir(dir.path().join("cmds")).unwrap();
806        std::fs::write(
807            dir.path().join("common.usage.kdl"),
808            "flagset \"common\" {\n    flag \"-v --verbose\"\n}\n",
809        )
810        .unwrap();
811        for cmd in ["build", "test"] {
812            // Reached as `cmds/../common.usage.kdl` from here and as `./common.usage.kdl`
813            // from the root: the same file, spelled two ways.
814            let body = format!(
815                "include file=\"../common.usage.kdl\"\ncmd \"{cmd}\" {{\n    use \"common\"\n}}\n"
816            );
817            std::fs::write(
818                dir.path().join("cmds").join(format!("{cmd}.usage.kdl")),
819                body,
820            )
821            .unwrap();
822        }
823        let root = dir.path().join("ex.usage.kdl");
824        std::fs::write(
825            &root,
826            "bin \"ex\"\ninclude file=\"./common.usage.kdl\"\ninclude file=\"./cmds/build.usage.kdl\"\ninclude file=\"./cmds/test.usage.kdl\"\n",
827        )
828        .unwrap();
829
830        let spec = Spec::parse_file(&root).unwrap();
831
832        assert_snapshot!(spec, @r#"
833        name ex
834        bin ex
835        cmd build {
836            flag "-v --verbose"
837        }
838        cmd test {
839            flag "-v --verbose"
840        }
841        "#);
842    }
843
844    #[test]
845    fn two_includes_may_not_declare_the_same_name() {
846        // Neither file is the nearer declaration, so there is nothing to prefer: a CLI
847        // whose shared files have grown a collision hears about it here rather than at
848        // whichever command happened to use the name.
849        let dir = tempfile::tempdir().unwrap();
850        for (file, flag) in [("a.usage.kdl", "--json"), ("b.usage.kdl", "--yaml")] {
851            let body = format!("flagset \"output\" {{\n    flag \"{flag}\"\n}}\n");
852            std::fs::write(dir.path().join(file), body).unwrap();
853        }
854        let root = dir.path().join("ex.usage.kdl");
855        std::fs::write(
856            &root,
857            "bin \"ex\"\ninclude file=\"./a.usage.kdl\"\ninclude file=\"./b.usage.kdl\"\n",
858        )
859        .unwrap();
860
861        let msg = err_file(&root);
862        assert!(
863            msg.contains("a flagset may be declared only once") && msg.contains("b.usage.kdl"),
864            "{msg}"
865        );
866    }
867
868    #[test]
869    fn a_set_holds_flags_and_says_so_about_arguments() {
870        let msg = err("flagset \"output\" {\n    arg \"<file>\"\n}\n");
871        assert!(
872            msg.contains("a flagset holds flags, not arguments"),
873            "{msg}"
874        );
875    }
876
877    #[test]
878    fn a_set_rejects_what_it_has_no_meaning_for() {
879        let msg = err("flagset \"output\" {\n    cmd \"nested\"\n}\n");
880        assert!(msg.contains("unsupported flagset key cmd"), "{msg}");
881        let msg = err("flagset \"output\" help=\"a set\" {\n    flag \"--json\"\n}\n");
882        assert!(msg.contains("unsupported flagset prop help"), "{msg}");
883    }
884
885    #[test]
886    fn a_use_names_sets_and_nothing_else() {
887        let msg = err("use \"output\" {\n    flag \"--json\"\n}\n");
888        assert!(
889            msg.contains("`use` names flagsets and holds nothing"),
890            "{msg}"
891        );
892        let msg = err("flagset \"o\" {\n    flag \"--json\"\n}\nuse from=\"o\"\n");
893        assert!(msg.contains("expected 1.. arguments, got 0"), "{msg}");
894    }
895}