Skip to main content

safe_chains/registry/
build.rs

1use std::collections::HashMap;
2
3use crate::policy::{FlagTolerance, UnknownTolerance};
4use crate::verdict::SafetyLevel;
5
6use super::types::*;
7
8pub(super) fn build_policy(
9    standalone: Vec<String>,
10    valued: Vec<String>,
11    bare: Option<bool>,
12    max_positional: Option<usize>,
13    tolerate_unknown_short: Option<bool>,
14    tolerate_unknown_long: Option<bool>,
15    numeric_dash: Option<bool>,
16) -> OwnedPolicy {
17    let unknown = match (
18        tolerate_unknown_short.unwrap_or(false),
19        tolerate_unknown_long.unwrap_or(false),
20    ) {
21        (false, false) => UnknownTolerance::Strict,
22        (true, false) => UnknownTolerance::Short,
23        (false, true) => UnknownTolerance::Long,
24        (true, true) => UnknownTolerance::Both,
25    };
26    OwnedPolicy {
27        standalone,
28        valued,
29        bare: bare.unwrap_or(true),
30        max_positional,
31        tolerance: FlagTolerance {
32            unknown,
33            numeric_dash: numeric_dash.unwrap_or(false),
34        },
35    }
36}
37
38fn build_matrix(toml: TomlMatrix) -> MatrixSpec {
39    let actions = toml
40        .actions
41        .into_iter()
42        .map(|(name, action)| {
43            let built = match action {
44                TomlMatrixAction::Policy(policy_key) => MatrixAction {
45                    policy_key,
46                    guard: None,
47                    guard_short: None,
48                },
49                TomlMatrixAction::Detailed(d) => {
50                    MatrixAction {
51                        policy_key: d.policy,
52                        guard: d.guard,
53                        guard_short: d.guard_short,
54                    }
55                }
56            };
57            (name, built)
58        })
59        .collect();
60    MatrixSpec {
61        parents: toml.parents,
62        level: toml.level.into(),
63        actions,
64    }
65}
66
67fn build_handler_policy(toml: TomlHandlerPolicy) -> OwnedPolicy {
68    build_policy(
69        toml.standalone,
70        toml.valued,
71        toml.bare,
72        toml.max_positional,
73        toml.tolerate_unknown_short,
74        toml.tolerate_unknown_long,
75        toml.numeric_dash,
76    )
77}
78
79fn build_verb_chain(toml: TomlVerbChain) -> VerbChainSpec {
80    VerbChainSpec {
81        level: toml.level.unwrap_or(TomlLevel::Inert).into(),
82        separator: toml.separator.unwrap_or_else(|| "then".to_string()),
83        main_standalone: toml.main_standalone,
84        main_valued: toml.main_valued,
85        main_variadic: toml.main_variadic,
86        verbs: toml.verbs.into_iter().collect(),
87    }
88}
89
90fn build_fallback(parent: &str, toml: TomlFallback) -> FallbackSpec {
91    let policy = build_policy(
92        toml.standalone,
93        toml.valued,
94        toml.bare,
95        toml.max_positional,
96        toml.tolerate_unknown_short,
97        toml.tolerate_unknown_long,
98        toml.numeric_dash,
99    );
100    let level: SafetyLevel = toml.level.unwrap_or(TomlLevel::Inert).into();
101    let positional_shape = toml.positional_shape.as_deref().map(|name| {
102        crate::policy::PositionalShape::from_name(name).unwrap_or_else(|| {
103            panic!(
104                "{}: unknown fallback positional_shape `{}` (known: path)",
105                parent, name
106            )
107        })
108    });
109    let executor = toml.executor.as_deref().map(|name| {
110        ExecutorKind::from_name(name)
111            .unwrap_or_else(|| panic!("{parent}: unknown fallback executor `{name}` (known: file, project)"))
112    });
113    FallbackSpec {
114        policy,
115        level,
116        positional_shape,
117        executor,
118        executor_redirect_flag: toml.executor_redirect_flag,
119    }
120}
121
122fn allow_all_policy() -> OwnedPolicy {
123    OwnedPolicy {
124        standalone: Vec::new(),
125        valued: Vec::new(),
126        bare: true,
127        max_positional: None,
128        tolerance: FlagTolerance { unknown: UnknownTolerance::Both, numeric_dash: false },
129    }
130}
131
132fn check_no_legacy_positional_style(name: &str, ps: Option<bool>) {
133    if ps.is_some() {
134        panic!(
135            "command '{name}': `positional_style` was removed. Use \
136             `tolerate_unknown_short = true` for tools with single-dash \
137             flags (pdftotext -help, sample -mayDie). Use \
138             `tolerate_unknown_long = true` ONLY for tools whose \
139             double-dash flag surface is genuinely unbounded (AWS CLI \
140             style); double-dash unknowns silently pass when this is \
141             on, which has caused safety bugs. Most tools need neither."
142        );
143    }
144}
145
146fn filter_candidates(subs: Vec<TomlSub>) -> impl Iterator<Item = TomlSub> {
147    subs.into_iter().filter(|s| !s.candidate.unwrap_or(false))
148}
149
150/// Whether `name` is matched by a `first_arg` pattern (`get-*` prefix glob, or an exact token).
151fn first_arg_matches(name: &str, patterns: &[String]) -> bool {
152    patterns.iter().any(|p| match p.strip_suffix('*') {
153        Some(prefix) => name.starts_with(prefix),
154        None => p == name,
155    })
156}
157
158/// Fail-closed guard for the `candidate`-under-glob footgun. A `candidate = true` sub is REMOVED from
159/// the registry (so an older client sees "not found" and denies) — but if a sibling `first_arg` glob
160/// would MATCH that removed name, the token falls through to the glob and AUTO-APPROVES, silently
161/// inverting the author's intent (a deny becomes an allow). This panics at load when it detects that
162/// shape, directing the author to a `profile`/explicit sub-sub instead. (The AWS blob-readers hit
163/// exactly this: a `candidate` under `get-*` would have auto-approved.)
164fn assert_no_candidate_shadowed_by_glob(parent: &str, subs: &[TomlSub], first_arg: &[String]) {
165    if first_arg.is_empty() {
166        return;
167    }
168    for s in subs {
169        if s.candidate.unwrap_or(false) && first_arg_matches(&s.name, first_arg) {
170            panic!(
171                "'{parent}' sub `{}` is `candidate = true` but its name is MATCHED by the sibling \
172                 first_arg glob {first_arg:?} — it would fall through the filter and AUTO-APPROVE \
173                 (a silent deny→allow inversion). Use `profile`/an explicit sub-sub to deny it, or \
174                 drop it from the glob.",
175                s.name,
176            );
177        }
178    }
179}
180
181/// Builds one SubSpec per alias (canonical name first, then each alias).
182/// All entries share the same kind via Clone — the dispatcher doesn't care
183/// which name the user invoked. `handler_policies` is consulted only by
184/// subs that set `policy = "key"`.
185pub(super) fn build_subs(
186    parent: &str,
187    toml: TomlSub,
188    handler_policies: &std::collections::HashMap<String, OwnedPolicy>,
189) -> Vec<SubSpec> {
190    let aliases = toml.aliases.clone();
191    let canonical = build_sub(parent, toml, handler_policies);
192    let mut out = Vec::with_capacity(1 + aliases.len());
193    for alias in aliases {
194        out.push(SubSpec {
195            name: alias,
196            kind: canonical.kind.clone(),
197            policy_ref: canonical.policy_ref.clone(),
198            profile: canonical.profile.clone(),
199            flags: canonical.flags.clone(),
200            // An alias must inherit the allowlist: an empty list reads as "not yet enumerated" and
201            // would let the alias spelling accept flags its canonical name rejects.
202            allowed_standalone: canonical.allowed_standalone.clone(),
203            allowed_valued: canonical.allowed_valued.clone(),
204            allowed_unknown: canonical.allowed_unknown,
205            eval_safe: canonical.eval_safe,
206            eval_safe_flags: canonical.eval_safe_flags.clone(),
207            eval_safe_flag_values: canonical.eval_safe_flag_values.clone(),
208            eval_safe_required_flags: canonical.eval_safe_required_flags.clone(),
209            network_destination: canonical.network_destination,
210            destination_flag: canonical.destination_flag.clone(),
211        loopback_valued: canonical.loopback_valued.clone(),
212        loopback_effect: canonical.loopback_effect,
213            output_path_flags: canonical.output_path_flags.clone(),
214        });
215    }
216    out.push(canonical);
217    out
218}
219
220/// Enforce the research standard at build time (reading the TOML fields, so provenance is a
221/// production-validated part of the tree, not dead metadata). A sub with a `profile` must name a
222/// real archetype and cite a `fact` + `source`; each escalating `[[command.sub.flag]]` must name a
223/// real archetype (or the `unclassified` fail-closed escape) and cite its own `fact` + `source`. A
224/// mis-authored classification fails CLOSED — the registry panics at load rather than silently
225/// under-recording why a subcommand sits above the auto-approve line.
226fn assert_sub_provenance(parent: &str, toml: &TomlSub) {
227    let cited = |o: &Option<String>| o.as_deref().is_some_and(|s| !s.trim().is_empty());
228    // A `judgment` is optional (the discretionary layer), but if given it must say something.
229    let judged = |o: &Option<String>| o.as_deref().is_none_or(|s| !s.trim().is_empty());
230    if let Some(p) = &toml.profile {
231        assert!(
232            crate::engine::archetype::archetype(p).is_some(),
233            "{parent} sub `{}`: profile `{p}` is not a known archetype (archetypes.toml)",
234            toml.name,
235        );
236        assert!(cited(&toml.fact), "{parent} sub `{}`: `profile` requires a `fact`", toml.name);
237        assert!(cited(&toml.source), "{parent} sub `{}`: `profile` requires a `source`", toml.name);
238        assert!(judged(&toml.judgment), "{parent} sub `{}`: `judgment`, if given, must not be blank", toml.name);
239        // A profiled sub must say what flags it accepts. The engine classifies it from its archetype
240        // without running the legacy flag walk, so an EMPTY list is not "nothing permitted" — before
241        // this was enforced it meant "everything permitted", and `git rebase --exec 'rm -rf /'`
242        // classified as an ordinary rebase. Declaring `tolerate_unknown_*` is the explicit way to say
243        // a surface is genuinely unbounded; saying nothing at all is not an option.
244        assert!(
245            !toml.standalone.is_empty()
246                || !toml.valued.is_empty()
247                || toml.tolerate_unknown_short == Some(true)
248                || toml.tolerate_unknown_long == Some(true)
249                || !toml.flag.is_empty(),
250            "{parent} sub `{}`: a profiled sub must declare its flag surface — list `standalone`/\
251             `valued`, or set `tolerate_unknown_long = true` if it is genuinely unbounded",
252            toml.name,
253        );
254        // A profiled sub is a leaf: the engine classifies it by archetype, and its legacy kind is
255        // forced to deny-all (below) — a nested Branching would sidestep that.
256        assert!(toml.sub.is_empty(), "{parent} sub `{}`: a profiled sub must be a leaf (no nested subs)", toml.name);
257    }
258    // `network_destination` classifies the destination onto the archetype's `locus.provenance`, so
259    // it only has meaning on a profiled sub.
260    assert!(
261        toml.network_destination != Some(true) || toml.profile.is_some(),
262        "{parent} sub `{}`: `network_destination` requires a `profile`",
263        toml.name,
264    );
265    assert!(
266        toml.destination_flag.is_none() || toml.network_destination == Some(true),
267        "{parent} sub `{}`: `destination_flag` requires `network_destination`",
268        toml.name,
269    );
270    // An output-file path is only meaningful on a profiled (`data-export`) sub — the engine gates
271    // that file's write onto the sub's derived profile.
272    assert!(
273        toml.output_path_flags.is_empty() || toml.profile.is_some(),
274        "{parent} sub `{}`: `output_path_flags` requires a `profile`",
275        toml.name,
276    );
277    for f in &toml.flag {
278        assert!(
279            f.classifies == "unclassified" || crate::engine::archetype::archetype(&f.classifies).is_some(),
280            "{parent} sub `{}` flag `{}`: classifies `{}` is not a known archetype",
281            toml.name, f.name, f.classifies,
282        );
283        assert!(cited(&f.fact), "{parent} sub `{}` flag `{}`: requires a `fact`", toml.name, f.name);
284        assert!(cited(&f.source), "{parent} sub `{}` flag `{}`: requires a `source`", toml.name, f.name);
285        assert!(judged(&f.judgment), "{parent} sub `{}` flag `{}`: `judgment`, if given, must not be blank", toml.name, f.name);
286        assert!(
287            !(f.when_absent == Some(true) && f.value_prefix.is_some()),
288            "{parent} sub `{}` flag `{}`: `when_absent` and `value_prefix` are mutually exclusive",
289            toml.name, f.name,
290        );
291    }
292}
293
294/// `loopback_localizes` says "pointed at this machine, clear the facets the destination decides".
295/// Two things must hold or the claim is incoherent:
296///
297/// - it needs a `loopback_valued` flag, since nothing else can establish that the destination IS
298///   this machine;
299/// - a DESTROY archetype may not set it. The emulator claim is unverifiable — `ssh -L
300///   8000:dynamodb.<region>.amazonaws.com:443` makes `localhost:8000` production — and a wrong
301///   guess costs a stray write for a mutate and the data for a delete. Refusing it here rather than
302///   relying on the resolver's runtime skip means no future sub opts out by accident.
303fn assert_loopback_localizes_is_coherent(parent: &str, name: &str, toml: &TomlSub) {
304    // Both the admission gate (`presents_unlisted_flag`) and the delta (`sub_loopback_localizes`)
305    // are reached only through the PROFILED sub walk, so on an unprofiled sub the declaration is
306    // inert — the flag would simply be missing from the legacy lists and deny, with nothing saying
307    // why. Fails closed, but silently, which is its own kind of bug.
308    assert!(
309        toml.loopback_valued.is_empty() || toml.profile.is_some(),
310        "{parent} sub `{name}`: `loopback_valued` needs `profile` — the gate is only consulted on \
311         a profiled sub, so here it would do nothing at all",
312    );
313    if !toml.loopback_localizes.unwrap_or(false) {
314        return;
315    }
316    assert!(
317        !toml.loopback_valued.is_empty(),
318        "{parent} sub `{name}`: `loopback_localizes` requires a `loopback_valued` flag — without \
319         one nothing can establish that the destination is this machine",
320    );
321    // Asked of the archetype's OPERATION facet, not of its name. A name test passes today only
322    // because every destroy archetype happens to be called `*destroy*`; a later `remote-wipe` or
323    // `bucket-purge` carrying `operation = "destroy"` would sail through it and localize. Deciding
324    // by name rather than behavior is the exact mistake this classifier exists to avoid.
325    let profile = toml.profile.as_deref().unwrap_or_default();
326    let operation = crate::engine::archetype::archetype(profile).map(|c| c.operation);
327    assert!(
328        operation != Some(crate::engine::facet::Operation::Destroy),
329        "{parent} sub `{name}`: `profile = \"{profile}\"` carries `operation = destroy` and may \
330         not set `loopback_localizes` — a loopback endpoint cannot be verified (an SSH tunnel makes \
331         localhost mean production), and that is only unrecoverable for destroy",
332    );
333}
334
335pub(super) fn build_sub(
336    parent: &str,
337    mut toml: TomlSub,
338    handler_policies: &std::collections::HashMap<String, OwnedPolicy>,
339) -> SubSpec {
340    check_no_legacy_positional_style(&toml.name, toml.positional_style);
341    let name = toml.name.clone();
342    let policy_ref = toml.policy.clone();
343    let profile = toml.profile.clone();
344    assert_sub_provenance(parent, &toml);
345    assert_no_candidate_shadowed_by_glob(&format!("{parent} {}", toml.name), &toml.sub, &toml.first_arg);
346    let flags = std::mem::take(&mut toml.flag)
347        .into_iter()
348        .map(|f| crate::registry::types::FlagProvenance {
349            name: f.name,
350            classifies: f.classifies,
351            value_prefix: f.value_prefix,
352            when_absent: f.when_absent.unwrap_or(false),
353        })
354        .collect();
355    // A profiled sub is engine-classified and above the auto-approve line. Its LEGACY dispatch is
356    // reached only when the engine ABSTAINS — e.g. a global flag (`git -c …`, `git -C …`) intervenes
357    // before the subcommand, so `sub_archetypes`'s walk stops early. In that case the legacy kind
358    // MUST deny outright (fail-closed), never fall through to a permissive default: force bare off,
359    // no flags, no positionals.
360    // Preserve the DECLARED lists before the legacy kind is zeroed: the engine classifies a profiled
361    // sub from its archetype without ever running the legacy flag walk, so these are the only copy
362    // that survives, and `sub_archetypes` validates against them.
363    let (allowed_standalone, allowed_valued) = (toml.standalone.clone(), toml.valued.clone());
364    let allowed_unknown = match (toml.tolerate_unknown_short.unwrap_or(false), toml.tolerate_unknown_long.unwrap_or(false)) {
365        (false, false) => UnknownTolerance::Strict,
366        (true, false) => UnknownTolerance::Short,
367        (false, true) => UnknownTolerance::Long,
368        (true, true) => UnknownTolerance::Both,
369    };
370    if profile.is_some() {
371        toml.bare = Some(false);
372        toml.standalone = Vec::new();
373        toml.valued = Vec::new();
374        toml.max_positional = Some(0);
375    }
376    let eval_safe = toml.eval_safe.unwrap_or(false);
377    let eval_safe_flags = std::mem::take(&mut toml.eval_safe_flags);
378    let eval_safe_flag_values = std::mem::take(&mut toml.eval_safe_flag_values);
379    let eval_safe_required_flags = std::mem::take(&mut toml.eval_safe_required_flags);
380    let network_destination = toml.network_destination.unwrap_or(false);
381    let destination_flag = toml.destination_flag.clone();
382    let output_path_flags = toml.output_path_flags.clone();
383    let loopback_valued = toml.loopback_valued.clone();
384    let loopback_effect = if toml.loopback_localizes.unwrap_or(false) {
385        LoopbackEffect::Localizes
386    } else {
387        LoopbackEffect::AdmitOnly
388    };
389    assert_loopback_localizes_is_coherent(parent, &name, &toml);
390    let valued_for_check = toml.valued.clone();
391    assert_eval_safe_flags_require_tag(parent, &name, eval_safe, &eval_safe_flags);
392    assert_eval_safe_flag_values_consistent(parent, &name, &eval_safe_flags, &eval_safe_flag_values);
393    assert_eval_safe_valued_flags_declared(parent, &name, &eval_safe_flags, &valued_for_check, &eval_safe_flag_values);
394    assert_eval_safe_required_flags_consistent(parent, &name, &eval_safe_flags, &eval_safe_required_flags);
395    assert_sub_eval_safe_only_on_leaf(parent, &toml);
396    SubSpec {
397        name,
398        kind: build_sub_kind(parent, toml, handler_policies),
399        policy_ref,
400        profile,
401        flags,
402        allowed_standalone,
403        allowed_valued,
404        allowed_unknown,
405        eval_safe,
406        eval_safe_flags,
407        eval_safe_flag_values,
408        eval_safe_required_flags,
409        network_destination,
410        destination_flag,
411        loopback_valued,
412        loopback_effect,
413        output_path_flags,
414    }
415}
416
417fn assert_eval_safe_flag_values_consistent(
418    parent: &str,
419    name: &str,
420    eval_safe_flags: &[String],
421    eval_safe_flag_values: &std::collections::HashMap<String, Vec<String>>,
422) {
423    for (flag, values) in eval_safe_flag_values {
424        if !eval_safe_flags.iter().any(|f| f == flag) {
425            panic!(
426                "command '{parent}' sub `{name}` lists `{flag}` in \
427                 `eval_safe_flag_values` but not in `eval_safe_flags`. \
428                 A value allowlist only takes effect when the flag itself \
429                 is allowed. Add `{flag}` to `eval_safe_flags` or remove \
430                 the value entry."
431            );
432        }
433        for value in values {
434            if value.is_empty() || !value.chars().all(is_bare_literal_char) {
435                panic!(
436                    "command '{parent}' sub `{name}` has eval_safe_flag_values \
437                     for `{flag}` containing value `{value:?}` with characters \
438                     outside `[a-zA-Z0-9_./=-]`. The allowed-value set must \
439                     itself be bare-literal so it can never embed a shell-\
440                     expansion trigger into the substituted invocation."
441                );
442            }
443        }
444    }
445}
446
447fn is_bare_literal_char(c: char) -> bool {
448    c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/' | '=')
449}
450
451fn assert_eval_safe_required_flags_consistent(
452    parent: &str,
453    name: &str,
454    eval_safe_flags: &[String],
455    eval_safe_required_flags: &[String],
456) {
457    for flag in eval_safe_required_flags {
458        if !eval_safe_flags.iter().any(|f| f == flag) {
459            panic!(
460                "command '{parent}' sub `{name}` lists `{flag}` in \
461                 `eval_safe_required_flags` but not in `eval_safe_flags`. \
462                 A required-flag constraint must be a subset of the \
463                 allowed-flag set — otherwise the flag is required AND \
464                 immediately denied. Add `{flag}` to `eval_safe_flags` or \
465                 remove it from `eval_safe_required_flags`."
466            );
467        }
468    }
469}
470
471/// Every valued flag in `eval_safe_flags` must declare its value
472/// posture in `eval_safe_flag_values`: either a concrete value
473/// allowlist (`["env", "fish"]`) OR the explicit-unrestricted form
474/// (`[]`). Without this, a contributor adding a short alias like
475/// `-f` for an already-tagged `--format` would silently widen the
476/// eval-safe surface to any value of `-f` — the v0.196.0 aws
477/// near-miss in disguise.
478fn assert_eval_safe_valued_flags_declared(
479    parent: &str,
480    name: &str,
481    eval_safe_flags: &[String],
482    valued: &[String],
483    eval_safe_flag_values: &std::collections::HashMap<String, Vec<String>>,
484) {
485    for flag in eval_safe_flags {
486        if !valued.iter().any(|v| v == flag) {
487            continue;
488        }
489        if !eval_safe_flag_values.contains_key(flag) {
490            panic!(
491                "command '{parent}' sub `{name}` lists `{flag}` in \
492                 `eval_safe_flags` AND in `valued`, but `{flag}` has no \
493                 entry in `eval_safe_flag_values`. Every valued flag \
494                 tagged eval-safe must declare its value posture: \
495                 either a concrete allowlist of safe values \
496                 (`{flag} = [\"value-a\", \"value-b\"]`) or the \
497                 explicit-unrestricted form (`{flag} = []`) signaling \
498                 the contributor vetted that any value preserves shell-\
499                 init output. Omitting an entry means the walker can't \
500                 tell whether the value-following-flag is supposed to \
501                 be checked, and a future short alias of `{flag}` \
502                 could silently widen the eval-safe surface."
503            );
504        }
505    }
506}
507
508fn assert_eval_safe_flags_require_tag(parent: &str, name: &str, eval_safe: bool, flags: &[String]) {
509    if !flags.is_empty() && !eval_safe {
510        panic!(
511            "command '{parent}' sub `{name}` declares `eval_safe_flags` without \
512             `eval_safe = true`. The flag allowlist only takes effect when the \
513             sub is tagged eval-safe. Add `eval_safe = true` or drop \
514             `eval_safe_flags`."
515        );
516    }
517}
518
519fn assert_sub_eval_safe_only_on_leaf(parent: &str, toml: &TomlSub) {
520    if toml.eval_safe != Some(true) {
521        return;
522    }
523    if !toml.sub.is_empty() {
524        panic!(
525            "command '{parent}' sub `{}` sets `eval_safe = true` AND has \
526             nested [[command.sub.sub]] blocks. eval_safe must be tagged on \
527             a leaf node — move the tag onto the specific sub-sub that emits \
528             shell-init code. Otherwise the walker accepts any unmatched \
529             sub-sub name as a positional, which is counter-intuitive.",
530            toml.name,
531        );
532    }
533    if toml.handler.is_some() {
534        panic!(
535            "command '{parent}' sub `{}` sets `eval_safe = true` AND \
536             `handler = \"...\"`. Handler-based subs run Rust dispatch logic \
537             whose shape the eval walker cannot introspect — eval-safety \
538             requires a declarative leaf the registry can reason about.",
539            toml.name,
540        );
541    }
542    if toml.delegate_after.is_some() || toml.delegate_skip.is_some() {
543        panic!(
544            "command '{parent}' sub `{}` sets `eval_safe = true` AND \
545             delegates to an inner command (delegate_after / delegate_skip). \
546             The inner command's output is unrelated to this sub's vetting \
547             — drop `eval_safe`.",
548            toml.name,
549        );
550    }
551}
552
553fn assert_eval_safe_tagged_command_has_researched_version(toml: &TomlCommand) {
554    let command_tagged = toml.eval_safe == Some(true);
555    let any_sub_tagged = toml_has_any_eval_safe_sub(&toml.sub);
556    if !command_tagged && !any_sub_tagged {
557        return;
558    }
559    if toml.researched_version.is_none() {
560        panic!(
561            "command '{}' has `eval_safe = true` (on the command or a sub) \
562             but no `researched_version`. eval-safe tags pin a per-tag trust \
563             claim against a specific upstream snapshot — add the version \
564             you researched (e.g. `researched_version = \"v2026.5.3\"`) so \
565             the next contributor knows what to diff against.",
566            toml.name,
567        );
568    }
569}
570
571fn toml_has_any_eval_safe_sub(subs: &[TomlSub]) -> bool {
572    subs.iter().any(|s| s.eval_safe == Some(true) || toml_has_any_eval_safe_sub(&s.sub))
573}
574
575fn assert_command_eval_safe_only_on_leaf(toml: &TomlCommand) {
576    if toml.eval_safe != Some(true) {
577        return;
578    }
579    if !toml.sub.is_empty() {
580        panic!(
581            "command '{}' sets `eval_safe = true` at the command level AND \
582             has [[command.sub]] blocks. Move the tag onto the specific \
583             sub that emits shell-init code (e.g. `mise activate`) — \
584             command-level tagging on a structured command is counter-\
585             intuitive.",
586            toml.name,
587        );
588    }
589    // Note: command-level eval_safe IS allowed alongside `handler =
590    // "..."`. The walker reads `spec.eval_safe` directly at the leaf
591    // — it does not need to introspect the handler's dispatch logic.
592    // The contributor takes responsibility for vouching that every
593    // invocation the handler accepts AND that passes the eval_safe_*
594    // flag checks produces shell-init stdout (typically narrowed via
595    // `eval_safe_required_flags`). See fzf's TOML for the canonical
596    // pattern.
597    if toml.wrapper.is_some() {
598        panic!(
599            "command '{}' sets `eval_safe = true` AND `[command.wrapper]`. \
600             Wrappers forward to an inner command — tagging the wrapper \
601             would tag every wrapped invocation. Drop `eval_safe`.",
602            toml.name,
603        );
604    }
605    if toml.deny.unwrap_or(false) {
606        panic!(
607            "command '{}' sets both `deny = true` and `eval_safe = true`. \
608             These are contradictory — deny silently dominates. Drop one.",
609            toml.name,
610        );
611    }
612}
613
614fn build_sub_kind(
615    parent: &str,
616    toml: TomlSub,
617    handler_policies: &std::collections::HashMap<String, OwnedPolicy>,
618) -> DispatchKind {
619    if let Some(handler_name) = toml.handler {
620        return DispatchKind::Custom {
621            handler_name,
622            doc_body: toml.doc_body,
623            subs: Vec::new(),
624            fallback: None,
625            handler_policies: std::collections::HashMap::new(),
626            matrices: Vec::new(),
627        };
628    }
629    if toml.allow_all.unwrap_or(false) {
630        return DispatchKind::Policy {
631            policy: allow_all_policy(),
632            level: toml.level.unwrap_or(TomlLevel::Inert).into(),
633        };
634    }
635    if let Some(sep) = toml.delegate_after {
636        return DispatchKind::DelegateAfterSeparator { separator: sep };
637    }
638    if let Some(skip) = toml.delegate_skip {
639        return DispatchKind::DelegateSkip { skip };
640    }
641    // A `credential_first_arg` gate forces the Branching path even with no sub-subs: only Branching
642    // runs `skip_pre_flags`, so the credential check sees the real resource arg (`get -o yaml secret`),
643    // not a leading flag — `FirstArg` reads `tokens[1]` verbatim and would be bypassed by a flag.
644    if !toml.sub.is_empty() || !toml.credential_first_arg.is_empty() {
645        // A sub may carry BOTH explicit sub-subs AND a fallback `first_arg` glob (a service that
646        // auto-approves its read verbs via `get-*`/`describe-*` but carves specific dangerous actions
647        // out to profiled sub-subs). Dispatch checks the explicit subs FIRST, then the glob
648        // (dispatch.rs), so the carve-outs escalate while the benign reads still glob-match. Mirror
649        // the command-level Branching, which already threads `first_arg` through; the sub level used
650        // to hard-drop it (`Vec::new()`), which made "glob + carve-out" inexpressible.
651        let first_arg_level = toml.level.unwrap_or(TomlLevel::Inert).into();
652        return DispatchKind::Branching {
653            subs: filter_candidates(toml.sub)
654                .flat_map(|s| build_subs(parent, s, handler_policies))
655                .collect(),
656            bare_flags: Vec::new(),
657            bare_ok: toml.nested_bare.unwrap_or(false),
658            pre_standalone: toml.standalone,
659            pre_valued: toml.valued,
660            first_arg: toml.first_arg,
661            first_arg_level,
662            first_arg_standalone: toml.first_arg_standalone,
663            first_arg_valued: toml.first_arg_valued,
664            first_arg_loopback_valued: toml.first_arg_loopback_valued,
665            credential_first_arg: toml.credential_first_arg,
666        };
667    }
668    build_policy_sub_kind(parent, toml, handler_policies)
669}
670
671fn build_policy_sub_kind(
672    parent: &str,
673    toml: TomlSub,
674    handler_policies: &std::collections::HashMap<String, OwnedPolicy>,
675) -> DispatchKind {
676    let policy = if let Some(key) = &toml.policy {
677        if !toml.standalone.is_empty() || !toml.valued.is_empty() {
678            panic!(
679                "command '{parent}' sub `{}` sets both `policy = \"{}\"` and \
680                 inline standalone/valued — pick one. Either drop the inline \
681                 lists (and rely on the referenced handler_policy) or drop \
682                 the `policy` field.",
683                toml.name, key,
684            );
685        }
686        handler_policies.get(key).cloned().unwrap_or_else(|| {
687            panic!(
688                "command '{parent}' sub `{}` references handler_policy \
689                 `{key}` which is not declared. Add a \
690                 [command.handler_policy.{key}] block or fix the typo.",
691                toml.name,
692            )
693        })
694    } else {
695        build_policy(
696            toml.standalone,
697            toml.valued,
698            toml.bare,
699            toml.max_positional,
700            toml.tolerate_unknown_short,
701            toml.tolerate_unknown_long,
702            toml.numeric_dash,
703        )
704    };
705    let level: SafetyLevel = toml.level.unwrap_or(TomlLevel::Inert).into();
706    if let Some(name) = toml.executor.as_deref() {
707        let kind = ExecutorKind::from_name(name).unwrap_or_else(|| {
708            panic!("command '{parent}' sub `{}`: unknown executor `{name}` (known: file, project)", toml.name)
709        });
710        let shape = toml.positional_shape.as_deref().map(|s| {
711            crate::policy::PositionalShape::from_name(s)
712                .unwrap_or_else(|| panic!("command '{parent}' sub `{}`: unknown positional_shape `{s}`", toml.name))
713        });
714        return DispatchKind::Executor {
715            policy,
716            level,
717            kind,
718            redirect_flag: toml.executor_redirect_flag,
719            shape,
720        };
721    }
722    if !toml.write_flags.is_empty() {
723        return DispatchKind::WriteFlagged {
724            policy,
725            base_level: level,
726            write_flags: toml.write_flags,
727        };
728    }
729    if let Some(guard) = toml.guard {
730        let mut require_any = vec![guard];
731        if let Some(short) = toml.guard_short {
732            require_any.push(short);
733        }
734        return DispatchKind::RequireAny {
735            require_any,
736            policy,
737            level,
738            accept_bare_help: true,
739        };
740    }
741    if !toml.first_arg.is_empty() {
742        return DispatchKind::FirstArg {
743            patterns: toml.first_arg,
744            level,
745            standalone: toml.first_arg_standalone,
746            valued: toml.first_arg_valued,
747            loopback_valued: toml.first_arg_loopback_valued,
748        };
749    }
750    if !toml.require_any.is_empty() {
751        return DispatchKind::RequireAny {
752            require_any: toml.require_any,
753            policy,
754            level,
755            accept_bare_help: false,
756        };
757    }
758    DispatchKind::Policy { policy, level }
759}
760
761/// Diagnostic for a configuration class that silently breaks flag dispatch:
762/// a structured command (with `[[command.sub]]` blocks) cannot also use the
763/// flat-style top-level fields. When subs are present, top-level standalone/
764/// valued/max_positional/positional_style/numeric_dash are dropped — the
765/// dispatch routes through the Branching path. The fix is to either remove
766/// the subs (if the command is meant to be flat) or move global flags into
767/// a `[command.wrapper]` block.
768fn assert_flat_or_structured(toml: &TomlCommand) {
769    if toml.sub.is_empty() {
770        return;
771    }
772    let mut conflicts = Vec::new();
773    if !toml.standalone.is_empty() {
774        conflicts.push("standalone");
775    }
776    if !toml.valued.is_empty() {
777        conflicts.push("valued");
778    }
779    if toml.max_positional.is_some() {
780        conflicts.push("max_positional");
781    }
782    if toml.tolerate_unknown_short.is_some() {
783        conflicts.push("tolerate_unknown_short");
784    }
785    if toml.tolerate_unknown_long.is_some() {
786        conflicts.push("tolerate_unknown_long");
787    }
788    if toml.numeric_dash.is_some() {
789        conflicts.push("numeric_dash");
790    }
791    if !conflicts.is_empty() {
792        panic!(
793            "command '{}' mixes flat-style top-level fields ({}) with [[command.sub]] blocks. \
794             When subs are present these fields are silently dropped. \
795             Either drop the subs (if the command is flat) or move global \
796             flags into a [command.wrapper] block.",
797            toml.name,
798            conflicts.join(", "),
799        );
800    }
801}
802
803fn assert_matrix_policy_keys_exist(toml: &TomlCommand) {
804    if toml.matrix.is_empty() {
805        return;
806    }
807    for matrix in &toml.matrix {
808        for (action_name, action) in &matrix.actions {
809            let policy_key = match action {
810                TomlMatrixAction::Policy(k) => k,
811                TomlMatrixAction::Detailed(d) => &d.policy,
812            };
813            if !toml.handler_policy.contains_key(policy_key) {
814                panic!(
815                    "command '{}' matrix action `{}` references \
816                     handler_policy `{}` which is not declared. \
817                     Add a [command.handler_policy.{}] block or fix the typo.",
818                    toml.name, action_name, policy_key, policy_key,
819                );
820            }
821        }
822    }
823}
824
825fn assert_matrix_no_duplicate_parent_action(toml: &TomlCommand) {
826    if toml.matrix.len() < 2 {
827        return;
828    }
829    let mut seen: std::collections::HashSet<(String, String)> = std::collections::HashSet::new();
830    for matrix in &toml.matrix {
831        for parent in &matrix.parents {
832            for action in matrix.actions.keys() {
833                let key = (parent.clone(), action.clone());
834                if !seen.insert(key) {
835                    panic!(
836                        "command '{}' matrix has duplicate (parent, action) pair \
837                         (`{}`, `{}`). The first match would silently win — \
838                         consolidate into one matrix block or remove the duplicate.",
839                        toml.name, parent, action,
840                    );
841                }
842            }
843        }
844    }
845}
846
847fn assert_fallback_requires_handler(toml: &TomlCommand) {
848    if toml.fallback.is_some() && toml.handler.is_none() {
849        panic!(
850            "command '{}' declares [command.fallback] without a handler. \
851             Fallback grammars are only consulted via \
852             registry::try_fallback_grammar() from a Rust handler — without \
853             handler = \"...\" the block is silently dropped. \
854             Either set handler or remove [command.fallback].",
855            toml.name,
856        );
857    }
858}
859
860/// Lower a `[command.behavior]` block into a typed `BehaviorSpec`. Every facet string is
861/// resolved to its enum here (via `FacetTerm::from_term`); an unknown term PANICS naming the
862/// command, so a typo fails the build (the registry loads in a test) rather than silently
863/// mis-classifying. `None` when the command declares no behavior.
864fn lower_behavior(name: &str, b: Option<&TomlBehavior>) -> Option<BehaviorSpec> {
865    use crate::engine::facet::{FacetTerm, Operation};
866    let b = b?;
867    let operation = Operation::from_term(&b.operation)
868        .unwrap_or_else(|| panic!("command '{name}': unknown behavior operation `{}`", b.operation));
869    let positionals = match b.positionals.as_str() {
870        "none" => PositionalRole::None,
871        "read" => PositionalRole::Read,
872        "write" => PositionalRole::Write,
873        "pattern-then-read" => PositionalRole::PatternThenRead,
874        "transfer" => PositionalRole::Transfer,
875        other => panic!("command '{name}': unknown behavior positionals `{other}` (known: none, read, write, pattern-then-read, transfer)"),
876    };
877    let scale = match b.scale.as_deref() {
878        None | Some("single") => ScaleModel::Single,
879        Some("breadth") => ScaleModel::Breadth,
880        Some(other) => panic!("command '{name}': unknown behavior scale `{other}` (known: single, breadth)"),
881    };
882    let hook = match b.hook.as_deref() {
883        None => None,
884        Some("grep") => Some(BehaviorHook::Grep),
885        Some("dd") => Some(BehaviorHook::Dd),
886        Some("tar") => Some(BehaviorHook::Tar),
887        Some("sed") => Some(BehaviorHook::Sed),
888        Some(other) => panic!("command '{name}': unknown behavior hook `{other}` (known: grep, dd, tar, sed)"),
889    };
890    let (short, long) = split_flag_forms(&b.standalone);
891    let (valued_short, valued_long) = split_flag_forms(&b.valued);
892    let mut unbounded_flags = Vec::new();
893    let mut path_flags = Vec::new();
894    for (flag, delta) in &b.flags {
895        if delta.scale.as_deref() == Some("unbounded") {
896            unbounded_flags.push(flag.clone());
897        } else if let Some(other) = delta.scale.as_deref() {
898            panic!("command '{name}': behavior flag `{flag}` has unknown scale `{other}` (known: unbounded)");
899        }
900        if let Some(kind) = delta.kind.as_deref() {
901            let role = match kind {
902                "read" => PathRole::Read,
903                "write" => PathRole::Write,
904                other => panic!("command '{name}': behavior flag `{flag}` has unknown kind `{other}` (known: read, write)"),
905            };
906            if !b.valued.contains(flag) {
907                panic!("command '{name}': behavior path-flag `{flag}` (kind = {kind}) must also be listed in `valued`");
908            }
909            let (short, long) = if let Some(rest) = flag.strip_prefix("--") {
910                (None, Some(format!("--{rest}")))
911            } else if let Some(rest) = flag.strip_prefix('-') {
912                if rest.len() == 1 {
913                    (Some(rest.as_bytes()[0]), None)
914                } else {
915                    panic!("command '{name}': behavior path-flag `{flag}` must be a single-char short or a `--long`");
916                }
917            } else {
918                panic!("command '{name}': behavior path-flag `{flag}` must start with `-`");
919            };
920            path_flags.push(PathFlag { short, long, role });
921        }
922    }
923    let transfer = lower_transfer(name, b.transfer.as_ref());
924    // A transfer role needs its knobs; anything else must not carry them.
925    match (positionals, &transfer) {
926        (PositionalRole::Transfer, None) => {
927            panic!("command '{name}': positionals = \"transfer\" requires a [command.behavior.transfer] block")
928        }
929        (role, Some(_)) if role != PositionalRole::Transfer => {
930            panic!("command '{name}': [command.behavior.transfer] is only valid with positionals = \"transfer\"")
931        }
932        _ => {}
933    }
934    Some(BehaviorSpec {
935        operation,
936        positionals,
937        scale,
938        short,
939        valued_short,
940        long,
941        valued_long,
942        numeric_shorthand: b.numeric_shorthand.unwrap_or(false),
943        unbounded_flags,
944        path_flags,
945        hook,
946        transfer,
947    })
948}
949
950/// Lower a `[command.behavior.transfer]` block, resolving the `source` term and enforcing that
951/// the two clobber-flag sets are mutually exclusive (a command declares whether the default is
952/// clobber or no-clobber, never both).
953fn lower_transfer(name: &str, t: Option<&TomlTransfer>) -> Option<TransferSpec> {
954    let t = t?;
955    let source = match t.source.as_str() {
956        "observe" => TransferSource::Observe,
957        "relocate" => TransferSource::Relocate,
958        other => panic!("command '{name}': unknown transfer source `{other}` (known: observe, relocate)"),
959    };
960    if !t.no_clobber_flags.is_empty() && !t.clobber_flags.is_empty() {
961        panic!("command '{name}': transfer declares both no_clobber_flags and clobber_flags (mutually exclusive)");
962    }
963    Some(TransferSpec {
964        source,
965        no_clobber_flags: t.no_clobber_flags.clone(),
966        clobber_flags: t.clobber_flags.clone(),
967        recursive_flags: t.recursive_flags.clone(),
968    })
969}
970
971/// Split a behavior flag list into (single-dash single-char shorts as bytes, `--long`
972/// tokens). A single-dash multi-char token is kept whole in `long` — it then only matches as
973/// a literal, which for a non-`--` token means it never classifies as known and fails closed.
974fn split_flag_forms(tokens: &[String]) -> (Vec<u8>, Vec<String>) {
975    let mut short = Vec::new();
976    let mut long = Vec::new();
977    for t in tokens {
978        if t.starts_with("--") {
979            long.push(t.clone());
980        } else if let Some(rest) = t.strip_prefix('-') {
981            if rest.len() == 1 {
982                short.push(rest.as_bytes()[0]);
983            } else {
984                long.push(t.clone());
985            }
986        }
987    }
988    (short, long)
989}
990
991/// Validate and lower a top-level command's `[[command.flag]]` classifying flags — the flat-command
992/// analog of a profiled sub's escalating flags. Each must name a known archetype (or `unclassified`)
993/// and cite `fact`/`source`; `when_absent`/`value_prefix` are mutually exclusive. Mirrors the per-sub
994/// check in `assert_sub_provenance`.
995fn build_command_archetype_flags(
996    cmd: &str,
997    flags: Vec<TomlSubFlag>,
998) -> Vec<crate::registry::types::FlagProvenance> {
999    let cited = |o: &Option<String>| o.as_deref().is_some_and(|s| !s.trim().is_empty());
1000    let judged = |o: &Option<String>| o.as_deref().is_none_or(|s| !s.trim().is_empty());
1001    flags
1002        .into_iter()
1003        .map(|f| {
1004            assert!(
1005                f.classifies == "unclassified"
1006                    || crate::engine::archetype::archetype(&f.classifies).is_some(),
1007                "command `{cmd}` flag `{}`: classifies `{}` is not a known archetype",
1008                f.name, f.classifies,
1009            );
1010            assert!(cited(&f.fact), "command `{cmd}` flag `{}`: requires a `fact`", f.name);
1011            assert!(cited(&f.source), "command `{cmd}` flag `{}`: requires a `source`", f.name);
1012            assert!(judged(&f.judgment), "command `{cmd}` flag `{}`: `judgment`, if given, must not be blank", f.name);
1013            assert!(
1014                !(f.when_absent == Some(true) && f.value_prefix.is_some()),
1015                "command `{cmd}` flag `{}`: `when_absent` and `value_prefix` are mutually exclusive",
1016                f.name,
1017            );
1018            crate::registry::types::FlagProvenance {
1019                name: f.name,
1020                classifies: f.classifies,
1021                value_prefix: f.value_prefix,
1022                when_absent: f.when_absent.unwrap_or(false),
1023            }
1024        })
1025        .collect()
1026}
1027
1028#[allow(clippy::too_many_lines)]
1029pub(super) fn build_command(toml: TomlCommand, category: &str) -> CommandSpec {
1030    assert_flat_or_structured(&toml);
1031    assert_fallback_requires_handler(&toml);
1032    assert_matrix_policy_keys_exist(&toml);
1033    assert_no_candidate_shadowed_by_glob(&toml.name, &toml.sub, &toml.first_arg);
1034    assert_matrix_no_duplicate_parent_action(&toml);
1035    assert_command_eval_safe_only_on_leaf(&toml);
1036    assert_eval_safe_tagged_command_has_researched_version(&toml);
1037    check_no_legacy_positional_style(&toml.name, toml.positional_style);
1038    let cat = category.to_string();
1039    let desc = toml.description.unwrap_or_default();
1040    let researched_version = toml.researched_version;
1041    let examples_safe = toml.examples_safe;
1042    let examples_denied = toml.examples_denied;
1043    let eval_safe = toml.eval_safe.unwrap_or(false);
1044    let eval_safe_flags = toml.eval_safe_flags;
1045    let eval_safe_flag_values = toml.eval_safe_flag_values;
1046    let eval_safe_required_flags = toml.eval_safe_required_flags;
1047    if !eval_safe_flags.is_empty() && !eval_safe {
1048        panic!(
1049            "command '{}' declares `eval_safe_flags` without `eval_safe = true`. \
1050             The flag allowlist only takes effect when the command is tagged \
1051             eval-safe. Add `eval_safe = true` or drop `eval_safe_flags`.",
1052            toml.name,
1053        );
1054    }
1055    assert_eval_safe_flag_values_consistent(
1056        &toml.name,
1057        "<command>",
1058        &eval_safe_flags,
1059        &eval_safe_flag_values,
1060    );
1061    assert_eval_safe_valued_flags_declared(
1062        &toml.name,
1063        "<command>",
1064        &eval_safe_flags,
1065        &toml.valued,
1066        &eval_safe_flag_values,
1067    );
1068    assert_eval_safe_required_flags_consistent(
1069        &toml.name,
1070        "<command>",
1071        &eval_safe_flags,
1072        &eval_safe_required_flags,
1073    );
1074    let behavior = lower_behavior(&toml.name, toml.behavior.as_ref());
1075    let env_assignment_positionals = toml.env_assignment_positionals.unwrap_or(false);
1076    let archetype_flags = build_command_archetype_flags(&toml.name, toml.flag);
1077    if toml.deny.unwrap_or(false) {
1078        return CommandSpec {
1079            name: toml.name,
1080            description: desc,
1081            aliases: toml.aliases,
1082            url: toml.url,
1083            category: cat,
1084            researched_version,
1085            examples_safe,
1086            examples_denied,
1087            eval_safe,
1088            eval_safe_flags: eval_safe_flags.clone(),
1089            eval_safe_flag_values: eval_safe_flag_values.clone(),
1090            eval_safe_required_flags: eval_safe_required_flags.clone(),
1091            path_gate: toml.path_gate,
1092            archetype_flags: archetype_flags.clone(),
1093            behavior: behavior.clone(),
1094            env_assignment_positionals,
1095            kind: DispatchKind::Policy {
1096                policy: OwnedPolicy {
1097                    standalone: Vec::new(),
1098                    valued: Vec::new(),
1099                    bare: false,
1100                    max_positional: Some(0),
1101                    tolerance: FlagTolerance::default(),
1102                },
1103                level: SafetyLevel::Inert,
1104            },
1105        };
1106    }
1107    if let Some(vc) = toml.verb_chain {
1108        return CommandSpec {
1109            name: toml.name,
1110            description: desc,
1111            aliases: toml.aliases,
1112            url: toml.url,
1113            category: cat,
1114            researched_version,
1115            examples_safe,
1116            examples_denied,
1117            eval_safe,
1118            eval_safe_flags: eval_safe_flags.clone(),
1119            eval_safe_flag_values: eval_safe_flag_values.clone(),
1120            eval_safe_required_flags: eval_safe_required_flags.clone(),
1121            path_gate: toml.path_gate,
1122            archetype_flags: archetype_flags.clone(),
1123            behavior: behavior.clone(),
1124            env_assignment_positionals,
1125            kind: DispatchKind::VerbChain(build_verb_chain(vc)),
1126        };
1127    }
1128
1129    if let Some(handler_name) = toml.handler {
1130        // Build handler_policies first so subs that use `policy = "key"`
1131        // can resolve the reference at build time.
1132        let handler_policies: std::collections::HashMap<String, OwnedPolicy> = toml
1133            .handler_policy
1134            .into_iter()
1135            .map(|(k, v)| (k, build_handler_policy(v)))
1136            .collect();
1137        let parent_name = toml.name.clone();
1138        let subs: Vec<SubSpec> = filter_candidates(toml.sub)
1139            .flat_map(|s| build_subs(&parent_name, s, &handler_policies))
1140            .collect();
1141        let fallback = toml.fallback.map(|f| build_fallback(&toml.name, f));
1142        let matrices = toml
1143            .matrix
1144            .into_iter()
1145            .map(build_matrix)
1146            .collect();
1147        return CommandSpec {
1148            name: toml.name,
1149            description: desc,
1150            aliases: toml.aliases,
1151            url: toml.url,
1152            category: cat,
1153            researched_version,
1154            examples_safe,
1155            examples_denied,
1156            eval_safe,
1157            eval_safe_flags: eval_safe_flags.clone(),
1158            eval_safe_flag_values: eval_safe_flag_values.clone(),
1159            eval_safe_required_flags: eval_safe_required_flags.clone(),
1160            path_gate: toml.path_gate,
1161            archetype_flags: archetype_flags.clone(),
1162            behavior: behavior.clone(),
1163            env_assignment_positionals,
1164            kind: DispatchKind::Custom {
1165                handler_name,
1166                doc_body: toml.doc_body,
1167                subs,
1168                fallback,
1169                handler_policies,
1170                matrices,
1171            },
1172        };
1173    }
1174
1175    if let Some(w) = toml.wrapper {
1176        if !toml.sub.is_empty() || !toml.bare_flags.is_empty() {
1177            let first_arg_level = toml.level.unwrap_or(TomlLevel::Inert).into();
1178            let parent_name = toml.name.clone();
1179            return CommandSpec {
1180                name: toml.name,
1181                description: desc,
1182                aliases: toml.aliases,
1183                url: toml.url,
1184                category: cat,
1185                researched_version,
1186                examples_safe,
1187                examples_denied,
1188                eval_safe,
1189                eval_safe_flags: eval_safe_flags.clone(),
1190                eval_safe_flag_values: eval_safe_flag_values.clone(),
1191                eval_safe_required_flags: eval_safe_required_flags.clone(),
1192                path_gate: toml.path_gate,
1193                archetype_flags: archetype_flags.clone(),
1194                behavior: behavior.clone(),
1195                env_assignment_positionals,
1196                kind: DispatchKind::Branching {
1197                    bare_flags: toml.bare_flags,
1198                    subs: filter_candidates(toml.sub)
1199                        .flat_map(|s| build_subs(&parent_name, s, &std::collections::HashMap::new()))
1200                        .collect(),
1201                    pre_standalone: w.standalone,
1202                    pre_valued: w.valued,
1203                    bare_ok: toml.bare.unwrap_or(false),
1204                    first_arg: toml.first_arg,
1205                    first_arg_standalone: toml.first_arg_standalone,
1206                    first_arg_valued: toml.first_arg_valued,
1207                    first_arg_loopback_valued: toml.first_arg_loopback_valued,
1208                    first_arg_level,
1209                    credential_first_arg: toml.credential_first_arg,
1210                },
1211            };
1212        }
1213        return CommandSpec {
1214            name: toml.name,
1215            description: desc,
1216            aliases: toml.aliases,
1217            url: toml.url,
1218            category: cat,
1219            researched_version,
1220            examples_safe,
1221            examples_denied,
1222            eval_safe,
1223            eval_safe_flags: eval_safe_flags.clone(),
1224            eval_safe_flag_values: eval_safe_flag_values.clone(),
1225            eval_safe_required_flags: eval_safe_required_flags.clone(),
1226            path_gate: toml.path_gate,
1227            archetype_flags: archetype_flags.clone(),
1228            behavior: behavior.clone(),
1229            env_assignment_positionals,
1230            kind: DispatchKind::Wrapper {
1231                standalone: w.standalone,
1232                valued: w.valued,
1233                positional_skip: w.positional_skip.unwrap_or(0),
1234                separator: w.separator,
1235                bare_ok: w.bare_ok.unwrap_or(false),
1236            },
1237        };
1238    }
1239
1240    if !toml.sub.is_empty() || !toml.bare_flags.is_empty() {
1241        let first_arg_level = toml.level.unwrap_or(TomlLevel::Inert).into();
1242        let parent_name = toml.name.clone();
1243        return CommandSpec {
1244            name: toml.name,
1245            description: desc,
1246            aliases: toml.aliases,
1247            url: toml.url,
1248            category: cat,
1249            researched_version,
1250            examples_safe,
1251            examples_denied,
1252            eval_safe,
1253            eval_safe_flags: eval_safe_flags.clone(),
1254            eval_safe_flag_values: eval_safe_flag_values.clone(),
1255            eval_safe_required_flags: eval_safe_required_flags.clone(),
1256            path_gate: toml.path_gate,
1257            archetype_flags: archetype_flags.clone(),
1258            behavior: behavior.clone(),
1259            env_assignment_positionals,
1260            kind: DispatchKind::Branching {
1261                bare_flags: toml.bare_flags,
1262                subs: filter_candidates(toml.sub)
1263                    .flat_map(|s| build_subs(&parent_name, s, &std::collections::HashMap::new()))
1264                    .collect(),
1265                pre_standalone: Vec::new(),
1266                pre_valued: Vec::new(),
1267                bare_ok: toml.bare.unwrap_or(false),
1268                first_arg: toml.first_arg,
1269                first_arg_level,
1270                first_arg_standalone: toml.first_arg_standalone,
1271                first_arg_valued: toml.first_arg_valued,
1272                first_arg_loopback_valued: toml.first_arg_loopback_valued,
1273                credential_first_arg: toml.credential_first_arg,
1274            },
1275        };
1276    }
1277
1278    let policy = build_policy(
1279        toml.standalone,
1280        toml.valued,
1281        toml.bare,
1282        toml.max_positional,
1283        toml.tolerate_unknown_short,
1284        toml.tolerate_unknown_long,
1285        toml.numeric_dash,
1286    );
1287
1288    let level = toml.level.unwrap_or(TomlLevel::Inert).into();
1289
1290    if !toml.first_arg.is_empty() {
1291        return CommandSpec {
1292            name: toml.name,
1293            description: desc,
1294            aliases: toml.aliases,
1295            url: toml.url,
1296            category: cat,
1297            researched_version,
1298            examples_safe,
1299            examples_denied,
1300            eval_safe,
1301            eval_safe_flags: eval_safe_flags.clone(),
1302            eval_safe_flag_values: eval_safe_flag_values.clone(),
1303            eval_safe_required_flags: eval_safe_required_flags.clone(),
1304            path_gate: toml.path_gate,
1305            archetype_flags: archetype_flags.clone(),
1306            behavior: behavior.clone(),
1307            env_assignment_positionals,
1308            kind: DispatchKind::FirstArg {
1309                patterns: toml.first_arg,
1310                level,
1311                standalone: toml.first_arg_standalone,
1312                valued: toml.first_arg_valued,
1313                loopback_valued: toml.first_arg_loopback_valued,
1314            },
1315        };
1316    }
1317
1318    if !toml.write_flags.is_empty() {
1319        return CommandSpec {
1320            name: toml.name,
1321            description: desc,
1322            aliases: toml.aliases,
1323            url: toml.url,
1324            category: cat,
1325            researched_version,
1326            examples_safe,
1327            examples_denied,
1328            eval_safe,
1329            eval_safe_flags: eval_safe_flags.clone(),
1330            eval_safe_flag_values: eval_safe_flag_values.clone(),
1331            eval_safe_required_flags: eval_safe_required_flags.clone(),
1332            path_gate: toml.path_gate,
1333            archetype_flags: archetype_flags.clone(),
1334            behavior: behavior.clone(),
1335            env_assignment_positionals,
1336            kind: DispatchKind::WriteFlagged {
1337                policy,
1338                base_level: level,
1339                write_flags: toml.write_flags,
1340            },
1341        };
1342    }
1343
1344    if !toml.require_any.is_empty() {
1345        return CommandSpec {
1346            name: toml.name,
1347            description: desc,
1348            aliases: toml.aliases,
1349            url: toml.url,
1350            category: cat,
1351            researched_version,
1352            examples_safe,
1353            examples_denied,
1354            eval_safe,
1355            eval_safe_flags: eval_safe_flags.clone(),
1356            eval_safe_flag_values: eval_safe_flag_values.clone(),
1357            eval_safe_required_flags: eval_safe_required_flags.clone(),
1358            path_gate: toml.path_gate,
1359            archetype_flags: archetype_flags.clone(),
1360            behavior: behavior.clone(),
1361            env_assignment_positionals,
1362            kind: DispatchKind::RequireAny {
1363                require_any: toml.require_any,
1364                policy,
1365                level,
1366                accept_bare_help: false,
1367            },
1368        };
1369    }
1370
1371    CommandSpec {
1372        env_assignment_positionals,
1373        name: toml.name,
1374        description: desc,
1375        aliases: toml.aliases,
1376        url: toml.url,
1377        category: cat,
1378        researched_version,
1379        examples_safe,
1380        examples_denied,
1381        eval_safe,
1382        eval_safe_flags,
1383        eval_safe_flag_values,
1384        eval_safe_required_flags,
1385        path_gate: toml.path_gate,
1386        archetype_flags,
1387        behavior,
1388        kind: DispatchKind::Policy {
1389            policy,
1390            level,
1391        },
1392    }
1393}
1394
1395pub fn load_toml(source: &str, category: &str) -> Vec<CommandSpec> {
1396    let file: TomlFile = match toml::from_str(source) {
1397        Ok(f) => f,
1398        Err(e) => {
1399            let preview: String = source.chars().take(80).collect();
1400            panic!("invalid TOML command definition: {e}\n  source begins: {preview}");
1401        }
1402    };
1403    file.command.into_iter()
1404        .filter(|cmd| !cmd.candidate.unwrap_or(false))
1405        .map(|cmd| build_command(cmd, category))
1406        .collect()
1407}
1408
1409pub fn build_registry(specs: Vec<CommandSpec>) -> HashMap<String, CommandSpec> {
1410    let mut map = HashMap::new();
1411    for spec in specs {
1412        insert_spec(&mut map, spec);
1413    }
1414    map
1415}
1416
1417/// Insert a CommandSpec into the registry, registering both its canonical
1418/// name and each alias. Existing entries for the same command name are
1419/// removed first, so a custom-TOML override of `gh` replaces every
1420/// built-in alias of `gh` rather than leaving stale aliases pointing at
1421/// the old spec.
1422pub fn insert_spec(map: &mut HashMap<String, CommandSpec>, spec: CommandSpec) {
1423    map.retain(|_, s| s.name != spec.name);
1424    for alias in &spec.aliases {
1425        map.insert(alias.clone(), CommandSpec {
1426            // Carried, not defaulted: `typeset` is an alias of `declare`, and dropping this here
1427            // would let the alias put a variable into the environment unclassified while the
1428            // canonical spelling denied.
1429            env_assignment_positionals: spec.env_assignment_positionals,
1430            name: spec.name.clone(),
1431            description: spec.description.clone(),
1432            aliases: vec![],
1433            url: spec.url.clone(),
1434            category: spec.category.clone(),
1435            researched_version: spec.researched_version.clone(),
1436            examples_safe: vec![],
1437            examples_denied: vec![],
1438            eval_safe: spec.eval_safe,
1439            eval_safe_flags: spec.eval_safe_flags.clone(),
1440            eval_safe_flag_values: spec.eval_safe_flag_values.clone(),
1441            eval_safe_required_flags: spec.eval_safe_required_flags.clone(),
1442            // Aliases are canonicalized (`registry::canonical_name`) before `should_deny` and
1443            // before the engine's behavior lookup, so the canonical spec's `path_gate` /
1444            // `behavior` is what's consulted — the alias entry never needs either.
1445            path_gate: None,
1446            archetype_flags: Vec::new(),
1447            behavior: None,
1448            kind: spec.kind.clone(),
1449        });
1450    }
1451    map.insert(spec.name.clone(), spec);
1452}