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