Skip to main content

consortium_cli/
grammar.rs

1//! CLI grammar: one introspected source of truth for the command surface.
2//!
3//! Ports the lazaret `nu/grammar.nu` idea (INTERFACE / dump / violations /
4//! check) onto clap: every signature fact comes from each bin's
5//! `Args::command()` via `clap::CommandFactory` — never a hand-maintained
6//! list. `check` fails on any new violation and on any baseline entry that
7//! no longer reproduces, so the baseline can only shrink.
8//!
9//! Rules enforced by [`violations`]:
10//!
11//! - `global-flags` — introspected bins expose exactly the pinned root
12//!   long-option vocabulary ([`PINNED_ROOT_LONGS`]); missing and
13//!   unexpected spellings both violate.
14//! - `selector` — selector (node/set-selection) options match
15//!   [`PINNED_SELECTORS`] exactly per `(bin, subcommand)`; missing and
16//!   unexpected selectors both violate. molt pins an empty set; cast
17//!   subcommands are strict whitelists.
18//! - `registry` — every bin maps to an existing source file and ships a
19//!   `tests/<bin>_tests.rs`.
20//! - `compat` — claw/molt/pinch short flags equal the frozen upstream
21//!   ClusterShell sets (parity is pinned by the `consortium-tests` oracle).
22//! - `typed` — every introspected argument carries help text.
23
24use std::borrow::Cow;
25use std::collections::BTreeSet;
26use std::path::Path;
27
28// Build-time `[[bin]]` registry facts (name, declared path, source/test
29// presence) generated by build.rs from Cargo.toml, so runtime checks
30// never stat the build source tree.
31include!(concat!(env!("OUT_DIR"), "/grammar_registry.rs"));
32
33/// One build-time `[[bin]]` fact generated into the included file.
34pub(crate) struct RegistryFact {
35    pub name: &'static str,
36    pub path: &'static str,
37    pub source_exists: bool,
38    pub tests_exist: bool,
39}
40
41use anyhow::Context;
42use clap::{Arg, Command, CommandFactory};
43use serde::{Deserialize, Serialize};
44
45/// Module kinds. `legacy`: upstream ClusterShell parity surface (frozen);
46/// `orchestration`: new surface, free to evolve. cascade-copy is
47/// registry-checked but not introspected (its `Args` stay private in
48/// `src/bin/`); the cascade CLI surface lives in `cast` and is
49/// introspected through it.
50pub const KINDS: &[(&str, &[&str])] = &[
51    ("legacy", &["claw", "molt", "pinch"]),
52    ("orchestration", &["cast", "cascade_copy"]),
53    ("tooling", &["consortium_grammar"]),
54];
55
56/// Shared output vocabulary every introspected bin must expose at its root
57/// (from `output::OutputArgs`).
58pub const SHARED_FLAGS: &[&str] = &["verbose", "color", "format"];
59
60/// Exact selector (node/set-selection) long-option vocabulary per
61/// `(bin, subcommand)`; `""` denotes the root command. Both directions
62/// are enforced: a pinned selector missing from the live grammar and a
63/// selector-like option outside the pinned set both violate. molt (stdin
64/// aggregator) pins an empty set. `cast` subcommands are strict: any
65/// non-shared long option outside the pinned set violates, so arbitrary
66/// unknown selectors (e.g. `cast eval --zone`) are rejected even though
67/// they are absent from the cross-bin vocabulary.
68pub const PINNED_SELECTORS: &[(&str, &str, &[&str])] = &[
69    (
70        "claw",
71        "",
72        &[
73            "nodes",
74            "exclude",
75            "all",
76            "group",
77            "exclude-group",
78            "pick",
79            "hostfile",
80        ],
81    ),
82    (
83        "pinch",
84        "",
85        &[
86            "exclude",
87            "intersection",
88            "xor",
89            "all",
90            "group",
91            "pick",
92            "slice",
93            "index",
94        ],
95    ),
96    ("molt", "", &[]),
97    ("cast", "eval", &["on", "tag"]),
98    ("cast", "status", &["on", "tag"]),
99    ("cast", "build", &["on", "tag", "builders", "fanout"]),
100    (
101        "cast",
102        "deploy",
103        &[
104            "on",
105            "tag",
106            "builders",
107            "fanout",
108            "cascade",
109            "cascade-fanout",
110            "activate-timeout",
111        ],
112    ),
113    ("cast", "health", &[]),
114    ("cast", "tree", &["max-depth", "no-color"]),
115    ("cast", "verify", &["fanout", "nodes"]),
116    (
117        "cast",
118        "live",
119        &[
120            "nodes",
121            "strategy",
122            "order",
123            "fanout",
124            "seeds",
125            "seed-fraction",
126            "closure-mb",
127            "bandwidth",
128            "uplinks",
129            "seed",
130            "failure-rate",
131            "failure-seed",
132            "no-watch",
133            "per-round-delay",
134            "max-depth",
135            "no-color",
136        ],
137    ),
138];
139
140/// Frozen upstream short flags (clush/clubak/cluset compat). The shared
141/// `OutputArgs` shorts (`-v`, `-F`) are consortium additions and are
142/// excluded from this comparison; long-only upstream flags are unaffected.
143pub const PINNED_SHORT_FLAGS: &[(&str, &str)] = &[
144    ("claw", "wxagXfltuRobLNqS"),
145    ("molt", "bLSTG"),
146    ("pinch", "ceflrxiXagSRIO"),
147];
148
149/// `[[bin]]` entries parsed from a Cargo.toml manifest, in declaration
150/// order: `(name, path)` with `path` relative to the manifest directory
151/// (Cargo's default `src/bin/<name>.rs` when `path` is omitted).
152pub fn manifest_bins(manifest: &Path) -> anyhow::Result<Vec<(String, String)>> {
153    let raw = std::fs::read_to_string(manifest)?;
154    let value: toml::Value = toml::from_str(&raw)?;
155    let bins = value
156        .get("bin")
157        .and_then(|b| b.as_array())
158        .map(|a| a.as_slice())
159        .unwrap_or(&[]);
160    Ok(bins
161        .iter()
162        .filter_map(|b| {
163            let name = b.get("name")?.as_str()?.to_string();
164            let path = b
165                .get("path")
166                .and_then(|p| p.as_str())
167                .map(|p| p.to_string())
168                .unwrap_or_else(|| format!("src/bin/{name}.rs"));
169            Some((name, path))
170        })
171        .collect())
172}
173
174/// Every classified bin across [`KINDS`].
175fn kind_bins() -> impl Iterator<Item = &'static str> {
176    KINDS.iter().flat_map(|(_, bins)| bins.iter().copied())
177}
178
179/// Borrowed bin facts; installed checks inspect build-time constants
180/// without copying each name or source path.
181struct DeclaredBin<'a> {
182    name: &'a str,
183    path: &'a str,
184    source_exists: bool,
185    tests_exist: bool,
186}
187
188fn embedded_registry() -> impl Iterator<Item = DeclaredBin<'static>> {
189    REGISTRY_FACTS.iter().map(|f| DeclaredBin {
190        name: f.name,
191        path: f.path,
192        source_exists: f.source_exists,
193        tests_exist: f.tests_exist,
194    })
195}
196
197/// Shared registry validator fed either by embedded build facts
198/// (production, no filesystem access) or by live `manifest_bins` + fs
199/// checks (temporary-manifest tests): every declared bin must be
200/// classified in [`KINDS`] (hyphenated names fold to underscores) and
201/// ship its declared source plus `tests/<bin>_tests.rs`; every [`KINDS`]
202/// bin must have a matching declaration.
203fn registry_findings<'a>(
204    declared: anyhow::Result<impl IntoIterator<Item = DeclaredBin<'a>>>,
205) -> Vec<Violation> {
206    let mut v = Vec::new();
207    let declared = match declared {
208        Ok(d) => d,
209        Err(e) => {
210            v.push(Violation {
211                rule: "registry".to_string(),
212                subcommand: String::new(),
213                bin: String::new(),
214                arg: "manifest".to_string(),
215                detail: format!("unreadable Cargo.toml: {e}"),
216            });
217            return v;
218        }
219    };
220    let mut declared_underscored: BTreeSet<Cow<'a, str>> = BTreeSet::new();
221    for b in declared {
222        let us = if b.name.contains('-') {
223            Cow::Owned(b.name.replace('-', "_"))
224        } else {
225            Cow::Borrowed(b.name)
226        };
227        if !kind_bins().any(|bin| bin == us.as_ref()) {
228            v.push(Violation {
229                rule: "registry".to_string(),
230                subcommand: String::new(),
231                bin: b.name.to_string(),
232                arg: "kind".to_string(),
233                detail: format!("bin '{}' is not classified in KINDS", b.name),
234            });
235        }
236        if !b.source_exists {
237            v.push(Violation {
238                rule: "registry".to_string(),
239                subcommand: String::new(),
240                bin: b.name.to_string(),
241                arg: "source".to_string(),
242                detail: format!("missing declared source {}", b.path),
243            });
244        }
245        if !b.tests_exist {
246            v.push(Violation {
247                rule: "registry".to_string(),
248                subcommand: String::new(),
249                bin: b.name.to_string(),
250                arg: "tests".to_string(),
251                detail: format!("missing tests/{us}_tests.rs"),
252            });
253        }
254        declared_underscored.insert(us);
255    }
256    for bin in kind_bins() {
257        if !declared_underscored.contains(bin) {
258            v.push(Violation {
259                rule: "registry".to_string(),
260                subcommand: String::new(),
261                bin: bin.to_string(),
262                arg: "manifest".to_string(),
263                detail: format!("KINDS bin '{bin}' has no [[bin]] in Cargo.toml"),
264            });
265        }
266    }
267    v
268}
269
270/// Registry rule evaluated live against a Cargo.toml manifest (whose
271/// directory is the crate root holding `src/` and `tests/`). Used by
272/// temporary-manifest tests; production uses the embedded build facts.
273pub fn registry_violations(manifest: &Path) -> Vec<Violation> {
274    let root = manifest.parent().unwrap_or_else(|| Path::new("."));
275    let bins = match manifest_bins(manifest) {
276        Ok(bins) => bins,
277        Err(error) => {
278            return registry_findings(Err::<std::iter::Empty<DeclaredBin<'static>>, _>(error));
279        }
280    };
281    registry_findings(Ok(bins.iter().map(|(name, path)| {
282        let us = name.replace('-', "_");
283        let tests = format!("{us}_tests.rs");
284        DeclaredBin {
285            source_exists: root.join(path).exists(),
286            tests_exist: root.join("tests").join(&tests).exists(),
287            name: name.as_str(),
288            path: path.as_str(),
289        }
290    })))
291}
292
293/// One introspected argument (or a `registry` placeholder row for the
294/// non-introspected cascade bins).
295#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
296pub struct Row {
297    pub bin: String,
298    pub subcommand: String,
299    pub arg: String,
300    /// `positional` | `flag` | `switch` | `registry`
301    pub kind: String,
302    pub short: Option<char>,
303    /// Root long-option spelling as clap renders it (`get_long()`); None
304    /// for positionals and registry placeholders.
305    pub long: Option<String>,
306    pub r#type: String,
307    pub default: String,
308    pub help: String,
309}
310
311/// One rule violation, addressable by `rule bin subcommand arg`. The
312/// `subcommand` distinguishes the same argument ID on different
313/// subcommands (e.g. `cast build --tag` vs `cast deploy --tag`);
314/// root-level violations use the explicit [`ROOT`] marker.
315#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
316pub struct Violation {
317    pub rule: String,
318    pub bin: String,
319    pub subcommand: String,
320    pub arg: String,
321    pub detail: String,
322}
323
324/// Unambiguous root marker in baseline keys; no subcommand may be named
325/// this, so `rule bin (root) arg` can never collide with a subcommand key.
326pub const ROOT: &str = "(root)";
327
328/// Stable join key for baseline comparison.
329pub fn key_of(v: &Violation) -> String {
330    let sub = if v.subcommand.is_empty() {
331        ROOT
332    } else {
333        &v.subcommand
334    };
335    format!("{} {} {} {}", v.rule, v.bin, sub, v.arg)
336}
337
338fn arg_kind(a: &Arg) -> &'static str {
339    if a.is_positional() {
340        "positional"
341    } else if a.get_num_args().map(|n| n.takes_values()).unwrap_or(false) {
342        "flag"
343    } else {
344        "switch"
345    }
346}
347
348fn walk(cmd: &Command, bin: &str, subcommand: &str, rows: &mut Vec<Row>) {
349    for a in cmd.get_arguments() {
350        let r#type = a
351            .get_value_names()
352            .and_then(|n| n.first())
353            .map(|n| n.to_string())
354            .unwrap_or_default();
355        let default = a
356            .get_default_values()
357            .iter()
358            .map(|v| v.to_string_lossy().to_string())
359            .collect::<Vec<_>>()
360            .join(" ");
361        rows.push(Row {
362            bin: bin.to_string(),
363            subcommand: subcommand.to_string(),
364            arg: a.get_id().to_string(),
365            kind: arg_kind(a).to_string(),
366            short: a.get_short(),
367            long: a.get_long().map(|l| l.to_string()),
368            r#type,
369            default,
370            help: a.get_help().map(|h| h.to_string()).unwrap_or_default(),
371        });
372    }
373    for sc in cmd.get_subcommands() {
374        walk(sc, bin, sc.get_name(), rows);
375    }
376}
377
378/// One row per exported argument across all bins, straight from clap —
379/// the same data a human reads off `--help`, as a table.
380pub fn dump() -> Vec<Row> {
381    let mut rows = Vec::new();
382    let commands = [
383        ("claw", crate::claw::Args::command()),
384        ("molt", crate::molt::Args::command()),
385        ("pinch", crate::pinch::Args::command()),
386        ("cast", crate::cast::Args::command()),
387    ];
388    for (bin, cmd) in &commands {
389        walk(cmd, bin, "", &mut rows);
390    }
391    // Registry placeholders for classified bins without clap Args.
392    for bin in kind_bins().filter(|b| !commands.iter().any(|(name, _)| name == b)) {
393        rows.push(Row {
394            bin: (*bin).to_string(),
395            subcommand: String::new(),
396            arg: String::new(),
397            kind: "registry".to_string(),
398            short: None,
399            long: None,
400            r#type: String::new(),
401            default: String::new(),
402            help: "registered bin (not introspected)".to_string(),
403        });
404    }
405    rows
406}
407
408/// Exact reviewed root long-option vocabulary per introspected bin.
409/// `global-flags` rejects both missing and unexpected spellings; clap's
410/// autogenerated `help`/`version` are excluded.
411pub const PINNED_ROOT_LONGS: &[(&str, &[&str])] = &[
412    (
413        "claw",
414        &[
415            "nodes",
416            "exclude",
417            "all",
418            "group",
419            "exclude-group",
420            "pick",
421            "hostfile",
422            "fanout",
423            "user",
424            "connect-timeout",
425            "command-timeout",
426            "worker",
427            "options",
428            "remote",
429            "topology",
430            "dshbak",
431            "line",
432            "label",
433            "quiet",
434            "maxrc",
435            "axis",
436            "verbose",
437            "color",
438            "format",
439            "copy",
440            "rcopy",
441            "dest",
442        ],
443    ),
444    (
445        "molt",
446        &[
447            "dshbak",
448            "line",
449            "separator",
450            "tree",
451            "groupbase",
452            "axis",
453            "interpret-keys",
454            "verbose",
455            "color",
456            "format",
457        ],
458    ),
459    (
460        "pinch",
461        &[
462            "count",
463            "expand",
464            "fold",
465            "list",
466            "regroup",
467            "groupsources",
468            "exclude",
469            "intersection",
470            "xor",
471            "all",
472            "group",
473            "separator",
474            "rangeset",
475            "autostep",
476            "split",
477            "contiguous",
478            "pick",
479            "slice",
480            "index",
481            "output-format",
482            "verbose",
483            "color",
484            "format",
485        ],
486    ),
487    (
488        "cast",
489        &[
490            "config",
491            "flake",
492            "user",
493            "nix-args",
494            "darwin-nix-args",
495            "nixos-nix-args",
496            "verbose",
497            "color",
498            "format",
499        ],
500    ),
501];
502
503/// `global-flags` rule against exact root long spellings: every pinned
504/// long must be present and no unexpected long may appear. Compares
505/// `Row.long` (not arg IDs), so a renamed spelling for an existing
506/// argument ID violates both as missing and as unexpected.
507fn global_flags(rows: &[Row], out: &mut Vec<Violation>) {
508    for (bin, pinned) in PINNED_ROOT_LONGS {
509        let want: BTreeSet<&str> = pinned.iter().copied().collect();
510        let got: BTreeSet<&str> = rows
511            .iter()
512            .filter(|r| {
513                r.bin == *bin
514                    && r.subcommand.is_empty()
515                    && r.kind != "registry"
516                    && r.arg != "help"
517                    && r.arg != "version"
518            })
519            .filter_map(|r| r.long.as_deref())
520            .collect();
521        for missing in want.difference(&got) {
522            out.push(Violation {
523                rule: "global-flags".to_string(),
524                subcommand: String::new(),
525                bin: (*bin).to_string(),
526                arg: (*missing).to_string(),
527                detail: "pinned root long option missing".to_string(),
528            });
529        }
530        for unexpected in got.difference(&want) {
531            out.push(Violation {
532                rule: "global-flags".to_string(),
533                subcommand: String::new(),
534                bin: (*bin).to_string(),
535                arg: (*unexpected).to_string(),
536                detail: "unexpected root long option".to_string(),
537            });
538        }
539    }
540}
541
542/// Long options that are shared OutputArgs or clap built-ins, never
543/// selectors.
544const NON_SELECTOR_LONGS: &[&str] = &["verbose", "color", "format", "help", "version"];
545
546/// `selector` rule over exact `(bin, subcommand)` sets. A candidate is
547/// any root/subcommand row with a long spelling outside
548/// [`NON_SELECTOR_LONGS`]. Candidates in the cross-bin vocabulary but not
549/// pinned at their location violate as unexpected; on `cast` (strict)
550/// any unpinned candidate violates. Pinned selectors absent from the
551/// live grammar violate as missing. Rows are keyed per subcommand, so
552/// `cast build --on` and `cast deploy --on` are distinct.
553fn selectors(rows: &[Row], out: &mut Vec<Violation>) {
554    // Cross-bin "selector-like" vocabulary: selection names from every
555    // pinned set, minus execution options shared across commands
556    // (fanout, builders, cascade...). molt --on must violate even though
557    // `on` is pinned only on cast; claw --fanout must not, because
558    // fanout is delivery, not node selection. `cast` subcommands are
559    // pinned exactly by their own strict whitelists.
560    let exec_options: [&str; 5] = [
561        "fanout",
562        "builders",
563        "cascade",
564        "cascade-fanout",
565        "activate-timeout",
566    ];
567    let vocab: BTreeSet<&str> = PINNED_SELECTORS
568        .iter()
569        .flat_map(|(_, _, longs)| longs.iter().copied())
570        .filter(|l| !exec_options.contains(l))
571        .collect();
572    for (bin, sub, pinned) in PINNED_SELECTORS {
573        let want: BTreeSet<&str> = pinned.iter().copied().collect();
574        let candidates: BTreeSet<&str> = rows
575            .iter()
576            .filter(|r| {
577                r.bin == *bin
578                    && r.subcommand == *sub
579                    && r.kind != "registry"
580                    && r.kind != "positional"
581            })
582            .filter_map(|r| r.long.as_deref())
583            .filter(|l| !NON_SELECTOR_LONGS.contains(l))
584            .collect();
585        for missing in want.difference(&candidates) {
586            out.push(Violation {
587                rule: "selector".to_string(),
588                subcommand: (*sub).to_string(),
589                bin: (*bin).to_string(),
590                arg: (*missing).to_string(),
591                detail: "pinned selector missing from the live grammar".to_string(),
592            });
593        }
594        let strict = *bin == "cast";
595        for unexpected in candidates.difference(&want) {
596            if vocab.contains(unexpected) || strict {
597                out.push(Violation {
598                    rule: "selector".to_string(),
599                    subcommand: (*sub).to_string(),
600                    bin: (*bin).to_string(),
601                    arg: (*unexpected).to_string(),
602                    detail: if strict {
603                        "cast subcommand selector outside the pinned whitelist".to_string()
604                    } else {
605                        "selector-like option not allowed on this command".to_string()
606                    },
607                });
608            }
609        }
610    }
611}
612
613/// Interface rules evaluated against the live grammar.
614pub fn violations() -> Vec<Violation> {
615    let rows = dump();
616    let mut v = Vec::new();
617
618    global_flags(&rows, &mut v);
619
620    selectors(&rows, &mut v);
621
622    // Embedded build-time facts: no stat of the build source tree.
623    v.extend(registry_findings(Ok::<_, anyhow::Error>(
624        embedded_registry(),
625    )));
626
627    for (bin, pinned) in PINNED_SHORT_FLAGS {
628        let got: BTreeSet<char> = rows
629            .iter()
630            .filter(|r| r.bin == *bin && !SHARED_FLAGS.contains(&r.arg.as_str()))
631            .filter_map(|r| r.short)
632            .collect();
633        let want: BTreeSet<char> = pinned.chars().collect();
634        if got != want {
635            v.push(Violation {
636                rule: "compat".to_string(),
637                subcommand: String::new(),
638                bin: (*bin).to_string(),
639                arg: "short-flags".to_string(),
640                detail: format!("frozen upstream set drift: got {got:?} want {want:?}"),
641            });
642        }
643    }
644
645    typed_missing_help(&rows, &mut v);
646
647    v
648}
649
650/// `typed` rule: every introspected argument (excluding `help`/`version`
651/// and non-introspected registry rows) must carry help text. Keyed per
652/// `bin subcommand arg`, so the same argument ID on distinct subcommands
653/// yields distinct violations.
654fn typed_missing_help(rows: &[Row], out: &mut Vec<Violation>) {
655    out.extend(
656        rows.iter()
657            .filter(|r| {
658                r.kind != "registry" && r.help.is_empty() && r.arg != "help" && r.arg != "version"
659            })
660            .map(|r| Violation {
661                rule: "typed".to_string(),
662                subcommand: r.subcommand.clone(),
663                bin: r.bin.clone(),
664                arg: r.arg.clone(),
665                detail: "argument has no help text".to_string(),
666            }),
667    );
668}
669
670/// Pure new-vs-stale comparison over baseline keys: errors when the live
671/// set has keys the baseline lacks (new violations) or the baseline has
672/// keys that no longer reproduce (stale entries — the baseline can only
673/// shrink). Returns the live violation count on success.
674pub fn check_keys(live: &[String], baseline: &[String]) -> anyhow::Result<usize> {
675    let live_set: BTreeSet<&String> = live.iter().collect();
676    let baseline_set: BTreeSet<&String> = baseline.iter().collect();
677    if live_set.len() != live.len() {
678        return Err(anyhow::anyhow!(
679            "grammar check failed: duplicate keys in live set: {live:?}"
680        ));
681    }
682    if baseline_set.len() != baseline.len() {
683        return Err(anyhow::anyhow!(
684            "grammar check failed: duplicate keys in baseline: {baseline:?}"
685        ));
686    }
687    let new: Vec<&String> = live_set.difference(&baseline_set).copied().collect();
688    let stale: Vec<&String> = baseline_set.difference(&live_set).copied().collect();
689    if !new.is_empty() || !stale.is_empty() {
690        return Err(anyhow::anyhow!(
691            "grammar check failed: {new_count} new, {stale_count} stale\nnew: {new:?}\nstale: {stale:?}",
692            new_count = new.len(),
693            stale_count = stale.len()
694        ));
695    }
696    Ok(live.len())
697}
698
699/// Result of comparing the live grammar against a baseline.
700#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
701pub struct CheckReport {
702    pub ok: bool,
703    pub baseline: usize,
704    pub violations: usize,
705}
706
707/// Compare live violations with the baseline file (JSON array of
708/// `rule bin subcommand arg` keys, root level marked [`ROOT`]). Errors on
709/// new, stale, or duplicate entries.
710pub fn check(baseline: &Path) -> anyhow::Result<CheckReport> {
711    let source = std::fs::read_to_string(baseline).with_context(|| {
712        format!(
713            "grammar check failed: cannot read baseline {}",
714            baseline.display()
715        )
716    })?;
717    let base: Vec<String> = serde_json::from_str(&source)?;
718    let live = violations();
719    let keys: Vec<String> = live.iter().map(key_of).collect();
720    let count = check_keys(&keys, &base)?;
721    Ok(CheckReport {
722        ok: true,
723        baseline: base.len(),
724        violations: count,
725    })
726}
727
728#[cfg(test)]
729mod tests {
730    use super::*;
731
732    /// The same argument ID (`on`) on `cast build` vs `cast deploy` with
733    /// missing help must yield two distinct typed violation keys.
734    #[test]
735    fn typed_keys_distinguish_same_arg_across_subcommands() {
736        let mk = |sub: &str| Row {
737            bin: "cast".to_string(),
738            subcommand: sub.to_string(),
739            arg: "on".to_string(),
740            kind: "flag".to_string(),
741            short: None,
742            long: None,
743            r#type: String::new(),
744            default: String::new(),
745            help: String::new(),
746        };
747        let mut v = Vec::new();
748        typed_missing_help(&[mk("build"), mk("deploy")], &mut v);
749        let keys: BTreeSet<String> = v.iter().map(key_of).collect();
750        assert_eq!(
751            keys,
752            BTreeSet::from([
753                "typed cast build on".to_string(),
754                "typed cast deploy on".to_string(),
755            ]),
756            "same arg ID on distinct subcommands must produce two distinct typed keys"
757        );
758    }
759
760    /// The exact-root-long `global-flags` rule: a synthetic new long-only
761    /// flag and a changed spelling for an existing arg ID must violate;
762    /// the current dump must not.
763    #[test]
764    fn global_flags_rejects_missing_and_unexpected_root_longs() {
765        let mut rows = dump();
766        assert!(
767            {
768                let mut v = Vec::new();
769                global_flags(&rows, &mut v);
770                v
771            }
772            .is_empty(),
773            "current tree must satisfy the pinned root long sets"
774        );
775
776        rows.push(Row {
777            bin: "cast".to_string(),
778            subcommand: String::new(),
779            arg: "prefix".to_string(),
780            kind: "switch".to_string(),
781            short: None,
782            long: Some("prefix".to_string()),
783            r#type: String::new(),
784            default: String::new(),
785            help: "synthetic".to_string(),
786        });
787        let keys: BTreeSet<String> = {
788            let mut v = Vec::new();
789            global_flags(&rows, &mut v);
790            v.iter().map(key_of).collect()
791        };
792        assert_eq!(
793            keys,
794            BTreeSet::from(["global-flags cast (root) prefix".to_string()]),
795            "synthetic long-only flag must be rejected as unexpected"
796        );
797
798        // Changed spelling for an existing arg ID: --format becomes
799        // --output-format on cast; both missing and unexpected fire.
800        for r in rows.iter_mut() {
801            if r.bin == "cast" && r.arg == "format" {
802                r.long = Some("output-format".to_string());
803            }
804        }
805        let keys: BTreeSet<String> = {
806            let mut v = Vec::new();
807            global_flags(&rows, &mut v);
808            v.iter().map(key_of).collect()
809        };
810        assert!(
811            keys.contains("global-flags cast (root) format"),
812            "renamed spelling must violate as missing: {keys:?}"
813        );
814        assert!(
815            keys.contains("global-flags cast (root) output-format"),
816            "renamed spelling must violate as unexpected: {keys:?}"
817        );
818    }
819
820    /// Selector rule negatives on synthetic rows: molt --on, cast
821    /// health --on, unknown cast eval --zone; and missing pinned
822    /// selectors for removed cast deploy --tag / claw --nodes. The live
823    /// dump must stay clean.
824    #[test]
825    fn selector_rule_rejects_unknown_and_missing_selectors() {
826        let mut rows = dump();
827        assert!(
828            {
829                let mut v = Vec::new();
830                selectors(&rows, &mut v);
831                v
832            }
833            .is_empty(),
834            "live dump must satisfy the pinned selector sets"
835        );
836
837        let mut push = |bin: &str, sub: &str, arg: &str| {
838            rows.push(Row {
839                bin: bin.to_string(),
840                subcommand: sub.to_string(),
841                arg: arg.to_string(),
842                kind: "flag".to_string(),
843                short: None,
844                long: Some(arg.to_string()),
845                r#type: String::new(),
846                default: String::new(),
847                help: "synthetic".to_string(),
848            })
849        };
850        push("molt", "", "on");
851        push("cast", "health", "on");
852        push("cast", "eval", "zone");
853        let keys: BTreeSet<String> = {
854            let mut v = Vec::new();
855            selectors(&rows, &mut v);
856            v.iter().map(key_of).collect()
857        };
858        assert_eq!(
859            keys,
860            BTreeSet::from([
861                "selector molt (root) on".to_string(),
862                "selector cast health on".to_string(),
863                "selector cast eval zone".to_string(),
864            ]),
865            "unknown selectors must violate: {keys:?}"
866        );
867
868        rows.retain(|r| !(r.bin == "cast" && r.subcommand == "deploy" && r.arg == "tag"));
869        rows.retain(|r| !(r.bin == "claw" && r.subcommand.is_empty() && r.arg == "nodes"));
870        let keys: BTreeSet<String> = {
871            let mut v = Vec::new();
872            selectors(&rows, &mut v);
873            v.iter().map(key_of).collect()
874        };
875        assert!(
876            keys.contains("selector cast deploy tag"),
877            "removed cast deploy --tag must violate as missing: {keys:?}"
878        );
879        assert!(
880            keys.contains("selector claw (root) nodes"),
881            "removed claw --nodes must violate as missing: {keys:?}"
882        );
883    }
884}