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 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
166fn 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
174fn 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
197pub(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 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
236fn 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 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 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 assert!(toml.sub.is_empty(), "{parent} sub `{}`: a profiled sub must be a leaf (no nested subs)", toml.name);
273 }
274 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 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
310fn assert_loopback_localizes_is_coherent(parent: &str, name: &str, toml: &TomlSub) {
320 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 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 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
487fn 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 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 if !toml.sub.is_empty() || !toml.credential_first_arg.is_empty() {
661 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
777fn 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
876fn 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 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
983fn 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
1005fn 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
1025fn 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 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
1462pub 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 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 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}