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