1use crate::parse::{has_flag, Segment, Token};
2use crate::policy::{self, FlagPolicy};
3#[cfg(test)]
4use crate::policy::FlagStyle;
5
6pub type CheckFn = fn(&[Token], &dyn Fn(&Segment) -> bool) -> bool;
7
8pub enum SubDef {
9 Policy {
10 name: &'static str,
11 policy: &'static FlagPolicy,
12 },
13 Nested {
14 name: &'static str,
15 subs: &'static [SubDef],
16 },
17 Guarded {
18 name: &'static str,
19 guard_short: Option<&'static str>,
20 guard_long: &'static str,
21 policy: &'static FlagPolicy,
22 },
23 Custom {
24 name: &'static str,
25 check: CheckFn,
26 doc: &'static str,
27 test_suffix: Option<&'static str>,
28 },
29 Delegation {
30 name: &'static str,
31 skip: usize,
32 doc: &'static str,
33 },
34}
35
36pub struct CommandDef {
37 pub name: &'static str,
38 pub subs: &'static [SubDef],
39 pub bare_flags: &'static [&'static str],
40 pub help_eligible: bool,
41 pub url: &'static str,
42 pub aliases: &'static [&'static str],
43}
44
45impl SubDef {
46 pub fn name(&self) -> &'static str {
47 match self {
48 Self::Policy { name, .. }
49 | Self::Nested { name, .. }
50 | Self::Guarded { name, .. }
51 | Self::Custom { name, .. }
52 | Self::Delegation { name, .. } => name,
53 }
54 }
55
56 pub fn check(&self, tokens: &[Token], is_safe: &dyn Fn(&Segment) -> bool) -> bool {
57 match self {
58 Self::Policy { policy, .. } => {
59 if tokens.len() == 2 && (tokens[1] == "--help" || tokens[1] == "-h") {
60 return true;
61 }
62 policy::check(tokens, policy)
63 }
64 Self::Nested { subs, .. } => {
65 if tokens.len() < 2 {
66 return false;
67 }
68 let sub = tokens[1].as_str();
69 if tokens.len() == 2 && (sub == "--help" || sub == "-h") {
70 return true;
71 }
72 subs.iter()
73 .any(|s| s.name() == sub && s.check(&tokens[1..], is_safe))
74 }
75 Self::Guarded {
76 guard_short,
77 guard_long,
78 policy,
79 ..
80 } => {
81 if tokens.len() == 2 && (tokens[1] == "--help" || tokens[1] == "-h") {
82 return true;
83 }
84 has_flag(tokens, *guard_short, Some(guard_long))
85 && policy::check(tokens, policy)
86 }
87 Self::Custom { check: f, .. } => {
88 if tokens.len() == 2 && (tokens[1] == "--help" || tokens[1] == "-h") {
89 return true;
90 }
91 f(tokens, is_safe)
92 }
93 Self::Delegation { skip, .. } => {
94 if tokens.len() <= *skip {
95 return false;
96 }
97 let inner = Token::join(&tokens[*skip..]);
98 is_safe(&inner)
99 }
100 }
101 }
102}
103
104impl CommandDef {
105 pub fn check(&self, tokens: &[Token], is_safe: &dyn Fn(&Segment) -> bool) -> bool {
106 if tokens.len() < 2 {
107 return false;
108 }
109 let arg = tokens[1].as_str();
110 if self.help_eligible && tokens.len() == 2 && matches!(arg, "--help" | "-h" | "--version" | "-V") {
111 return true;
112 }
113 if tokens.len() == 2 && self.bare_flags.contains(&arg) {
114 return true;
115 }
116 self.subs
117 .iter()
118 .find(|s| s.name() == arg)
119 .is_some_and(|s| s.check(&tokens[1..], is_safe))
120 }
121
122 pub fn dispatch(
123 &self,
124 cmd: &str,
125 tokens: &[Token],
126 is_safe: &dyn Fn(&Segment) -> bool,
127 ) -> Option<bool> {
128 if cmd == self.name || self.aliases.contains(&cmd) {
129 Some(self.check(tokens, is_safe))
130 } else {
131 None
132 }
133 }
134
135 pub fn to_doc(&self) -> crate::docs::CommandDoc {
136 let mut lines = Vec::new();
137
138 if !self.bare_flags.is_empty() {
139 lines.push(format!("- Info flags: {}", self.bare_flags.join(", ")));
140 }
141
142 let mut sub_lines: Vec<String> = Vec::new();
143 for sub in self.subs {
144 sub_doc_line(sub, "", &mut sub_lines);
145 }
146 sub_lines.sort();
147 lines.extend(sub_lines);
148
149 let mut doc = crate::docs::CommandDoc::handler(self.name, self.url, lines.join("\n"));
150 doc.aliases = self.aliases.iter().map(|a| a.to_string()).collect();
151 doc
152 }
153}
154
155pub struct FlatDef {
156 pub name: &'static str,
157 pub policy: &'static FlagPolicy,
158 pub help_eligible: bool,
159 pub url: &'static str,
160 pub aliases: &'static [&'static str],
161}
162
163impl FlatDef {
164 pub fn dispatch(&self, cmd: &str, tokens: &[Token]) -> Option<bool> {
165 if cmd == self.name || self.aliases.contains(&cmd) {
166 if self.help_eligible
167 && tokens.len() == 2
168 && matches!(tokens[1].as_str(), "--help" | "-h" | "--version" | "-V")
169 {
170 return Some(true);
171 }
172 Some(policy::check(tokens, self.policy))
173 } else {
174 None
175 }
176 }
177
178 pub fn to_doc(&self) -> crate::docs::CommandDoc {
179 let mut doc = crate::docs::CommandDoc::handler(self.name, self.url, self.policy.describe());
180 doc.aliases = self.aliases.iter().map(|a| a.to_string()).collect();
181 doc
182 }
183}
184
185#[cfg(test)]
186impl FlatDef {
187 pub fn auto_test_reject_unknown(&self) {
188 if self.policy.flag_style == FlagStyle::Positional {
189 return;
190 }
191 let test = format!("{} --xyzzy-unknown-42", self.name);
192 assert!(
193 !crate::is_safe_command(&test),
194 "{}: accepted unknown flag: {test}",
195 self.name,
196 );
197 for alias in self.aliases {
198 let test = format!("{alias} --xyzzy-unknown-42");
199 assert!(
200 !crate::is_safe_command(&test),
201 "{alias}: alias accepted unknown flag: {test}",
202 );
203 }
204 }
205}
206
207fn sub_doc_line(sub: &SubDef, prefix: &str, out: &mut Vec<String>) {
208 match sub {
209 SubDef::Policy { name, policy } => {
210 let summary = policy.flag_summary();
211 let label = if prefix.is_empty() {
212 (*name).to_string()
213 } else {
214 format!("{prefix} {name}")
215 };
216 if summary.is_empty() {
217 out.push(format!("- **{label}**"));
218 } else {
219 out.push(format!("- **{label}**: {summary}"));
220 }
221 }
222 SubDef::Nested { name, subs } => {
223 let path = if prefix.is_empty() {
224 (*name).to_string()
225 } else {
226 format!("{prefix} {name}")
227 };
228 for s in *subs {
229 sub_doc_line(s, &path, out);
230 }
231 }
232 SubDef::Guarded {
233 name,
234 guard_long,
235 policy,
236 ..
237 } => {
238 let summary = policy.flag_summary();
239 let label = if prefix.is_empty() {
240 (*name).to_string()
241 } else {
242 format!("{prefix} {name}")
243 };
244 if summary.is_empty() {
245 out.push(format!("- **{label}** (requires {guard_long})"));
246 } else {
247 out.push(format!("- **{label}** (requires {guard_long}): {summary}"));
248 }
249 }
250 SubDef::Custom { name, doc, .. } => {
251 if !doc.is_empty() && doc.trim().is_empty() {
252 return;
253 }
254 let label = if prefix.is_empty() {
255 (*name).to_string()
256 } else {
257 format!("{prefix} {name}")
258 };
259 if doc.is_empty() {
260 out.push(format!("- **{label}**"));
261 } else {
262 out.push(format!("- **{label}**: {doc}"));
263 }
264 }
265 SubDef::Delegation { name, doc, .. } => {
266 if doc.is_empty() {
267 return;
268 }
269 let label = if prefix.is_empty() {
270 (*name).to_string()
271 } else {
272 format!("{prefix} {name}")
273 };
274 out.push(format!("- **{label}**: {doc}"));
275 }
276 }
277}
278
279#[cfg(test)]
280impl CommandDef {
281 pub fn auto_test_reject_unknown(&self) {
282 let mut failures = Vec::new();
283
284 assert!(
285 !crate::is_safe_command(self.name),
286 "{}: accepted bare invocation",
287 self.name,
288 );
289
290 let test = format!("{} xyzzy-unknown-42", self.name);
291 assert!(
292 !crate::is_safe_command(&test),
293 "{}: accepted unknown subcommand: {test}",
294 self.name,
295 );
296
297 for sub in self.subs {
298 auto_test_sub(self.name, sub, &mut failures);
299 }
300 assert!(
301 failures.is_empty(),
302 "{}: unknown flags/subcommands accepted:\n{}",
303 self.name,
304 failures.join("\n"),
305 );
306 }
307}
308
309#[cfg(test)]
310fn auto_test_sub(prefix: &str, sub: &SubDef, failures: &mut Vec<String>) {
311 const UNKNOWN: &str = "--xyzzy-unknown-42";
312
313 match sub {
314 SubDef::Policy { name, policy } => {
315 if policy.flag_style == FlagStyle::Positional {
316 return;
317 }
318 let test = format!("{prefix} {name} {UNKNOWN}");
319 if crate::is_safe_command(&test) {
320 failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
321 }
322 }
323 SubDef::Nested { name, subs } => {
324 let path = format!("{prefix} {name}");
325 let test = format!("{path} xyzzy-unknown-42");
326 if crate::is_safe_command(&test) {
327 failures.push(format!("{path}: accepted unknown subcommand: {test}"));
328 }
329 for s in *subs {
330 auto_test_sub(&path, s, failures);
331 }
332 }
333 SubDef::Guarded {
334 name, guard_long, ..
335 } => {
336 let test = format!("{prefix} {name} {guard_long} {UNKNOWN}");
337 if crate::is_safe_command(&test) {
338 failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
339 }
340 }
341 SubDef::Custom {
342 name, test_suffix, ..
343 } => {
344 if let Some(suffix) = test_suffix {
345 let test = format!("{prefix} {name} {suffix} {UNKNOWN}");
346 if crate::is_safe_command(&test) {
347 failures.push(format!(
348 "{prefix} {name}: accepted unknown flag: {test}"
349 ));
350 }
351 }
352 }
353 SubDef::Delegation { .. } => {}
354 }
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360 use crate::parse::WordSet;
361 use crate::policy::FlagStyle;
362
363 fn toks(words: &[&str]) -> Vec<Token> {
364 words.iter().map(|s| Token::from_test(s)).collect()
365 }
366
367 fn no_safe(_: &Segment) -> bool {
368 false
369 }
370
371 static TEST_POLICY: FlagPolicy = FlagPolicy {
372 standalone: WordSet::new(&["--verbose", "-v"]),
373 valued: WordSet::new(&["--output", "-o"]),
374 bare: true,
375 max_positional: None,
376 flag_style: FlagStyle::Strict,
377 };
378
379 static SIMPLE_CMD: CommandDef = CommandDef {
380 name: "mycmd",
381 subs: &[SubDef::Policy {
382 name: "build",
383 policy: &TEST_POLICY,
384 }],
385 bare_flags: &["--info"],
386 help_eligible: true,
387 url: "",
388 aliases: &[],
389 };
390
391 #[test]
392 fn bare_rejected() {
393 assert!(!SIMPLE_CMD.check(&toks(&["mycmd"]), &no_safe));
394 }
395
396 #[test]
397 fn bare_flag_accepted() {
398 assert!(SIMPLE_CMD.check(&toks(&["mycmd", "--info"]), &no_safe));
399 }
400
401 #[test]
402 fn bare_flag_with_extra_rejected() {
403 assert!(!SIMPLE_CMD.check(&toks(&["mycmd", "--info", "extra"]), &no_safe));
404 }
405
406 #[test]
407 fn policy_sub_bare() {
408 assert!(SIMPLE_CMD.check(&toks(&["mycmd", "build"]), &no_safe));
409 }
410
411 #[test]
412 fn policy_sub_with_flag() {
413 assert!(SIMPLE_CMD.check(&toks(&["mycmd", "build", "--verbose"]), &no_safe));
414 }
415
416 #[test]
417 fn policy_sub_unknown_flag() {
418 assert!(!SIMPLE_CMD.check(&toks(&["mycmd", "build", "--bad"]), &no_safe));
419 }
420
421 #[test]
422 fn unknown_sub_rejected() {
423 assert!(!SIMPLE_CMD.check(&toks(&["mycmd", "deploy"]), &no_safe));
424 }
425
426 #[test]
427 fn dispatch_matches() {
428 assert_eq!(
429 SIMPLE_CMD.dispatch("mycmd", &toks(&["mycmd", "build"]), &no_safe),
430 Some(true)
431 );
432 }
433
434 #[test]
435 fn dispatch_no_match() {
436 assert_eq!(
437 SIMPLE_CMD.dispatch("other", &toks(&["other", "build"]), &no_safe),
438 None
439 );
440 }
441
442 static NESTED_CMD: CommandDef = CommandDef {
443 name: "nested",
444 subs: &[SubDef::Nested {
445 name: "package",
446 subs: &[SubDef::Policy {
447 name: "describe",
448 policy: &TEST_POLICY,
449 }],
450 }],
451 bare_flags: &[],
452 help_eligible: false,
453 url: "",
454 aliases: &[],
455 };
456
457 #[test]
458 fn nested_sub() {
459 assert!(NESTED_CMD.check(&toks(&["nested", "package", "describe"]), &no_safe));
460 }
461
462 #[test]
463 fn nested_sub_with_flag() {
464 assert!(NESTED_CMD.check(
465 &toks(&["nested", "package", "describe", "--verbose"]),
466 &no_safe,
467 ));
468 }
469
470 #[test]
471 fn nested_bare_rejected() {
472 assert!(!NESTED_CMD.check(&toks(&["nested", "package"]), &no_safe));
473 }
474
475 #[test]
476 fn nested_unknown_sub_rejected() {
477 assert!(!NESTED_CMD.check(&toks(&["nested", "package", "deploy"]), &no_safe));
478 }
479
480 static GUARDED_POLICY: FlagPolicy = FlagPolicy {
481 standalone: WordSet::new(&["--all", "--check"]),
482 valued: WordSet::new(&[]),
483 bare: false,
484 max_positional: None,
485 flag_style: FlagStyle::Strict,
486 };
487
488 static GUARDED_CMD: CommandDef = CommandDef {
489 name: "guarded",
490 subs: &[SubDef::Guarded {
491 name: "fmt",
492 guard_short: None,
493 guard_long: "--check",
494 policy: &GUARDED_POLICY,
495 }],
496 bare_flags: &[],
497 help_eligible: false,
498 url: "",
499 aliases: &[],
500 };
501
502 #[test]
503 fn guarded_with_guard() {
504 assert!(GUARDED_CMD.check(&toks(&["guarded", "fmt", "--check"]), &no_safe));
505 }
506
507 #[test]
508 fn guarded_without_guard() {
509 assert!(!GUARDED_CMD.check(&toks(&["guarded", "fmt"]), &no_safe));
510 }
511
512 #[test]
513 fn guarded_with_guard_and_flag() {
514 assert!(GUARDED_CMD.check(
515 &toks(&["guarded", "fmt", "--check", "--all"]),
516 &no_safe,
517 ));
518 }
519
520 fn safe_echo(seg: &Segment) -> bool {
521 seg.as_str() == "echo hello"
522 }
523
524 static DELEGATION_CMD: CommandDef = CommandDef {
525 name: "runner",
526 subs: &[SubDef::Delegation {
527 name: "run",
528 skip: 2,
529 doc: "run delegates to inner command.",
530 }],
531 bare_flags: &[],
532 help_eligible: false,
533 url: "",
534 aliases: &[],
535 };
536
537 #[test]
538 fn delegation_safe_inner() {
539 assert!(DELEGATION_CMD.check(
540 &toks(&["runner", "run", "stable", "echo", "hello"]),
541 &safe_echo,
542 ));
543 }
544
545 #[test]
546 fn delegation_unsafe_inner() {
547 assert!(!DELEGATION_CMD.check(
548 &toks(&["runner", "run", "stable", "rm", "-rf"]),
549 &no_safe,
550 ));
551 }
552
553 #[test]
554 fn delegation_no_inner() {
555 assert!(!DELEGATION_CMD.check(
556 &toks(&["runner", "run", "stable"]),
557 &no_safe,
558 ));
559 }
560
561 fn custom_check(tokens: &[Token], _is_safe: &dyn Fn(&Segment) -> bool) -> bool {
562 tokens.len() >= 2 && tokens[1] == "safe"
563 }
564
565 static CUSTOM_CMD: CommandDef = CommandDef {
566 name: "custom",
567 subs: &[SubDef::Custom {
568 name: "special",
569 check: custom_check,
570 doc: "special (safe only).",
571 test_suffix: Some("safe"),
572 }],
573 bare_flags: &[],
574 help_eligible: false,
575 url: "",
576 aliases: &[],
577 };
578
579 #[test]
580 fn custom_passes() {
581 assert!(CUSTOM_CMD.check(&toks(&["custom", "special", "safe"]), &no_safe));
582 }
583
584 #[test]
585 fn custom_fails() {
586 assert!(!CUSTOM_CMD.check(&toks(&["custom", "special", "bad"]), &no_safe));
587 }
588
589 #[test]
590 fn doc_simple() {
591 let doc = SIMPLE_CMD.to_doc();
592 assert_eq!(doc.name, "mycmd");
593 assert_eq!(
594 doc.description,
595 "- Info flags: --info\n- **build**: Flags: --verbose, -v. Valued: --output, -o"
596 );
597 }
598
599 #[test]
600 fn doc_nested() {
601 let doc = NESTED_CMD.to_doc();
602 assert_eq!(
603 doc.description,
604 "- **package describe**: Flags: --verbose, -v. Valued: --output, -o"
605 );
606 }
607
608 #[test]
609 fn doc_guarded() {
610 let doc = GUARDED_CMD.to_doc();
611 assert_eq!(
612 doc.description,
613 "- **fmt** (requires --check): Flags: --all, --check"
614 );
615 }
616
617 #[test]
618 fn doc_delegation() {
619 let doc = DELEGATION_CMD.to_doc();
620 assert_eq!(doc.description, "- **run**: run delegates to inner command.");
621 }
622
623 #[test]
624 fn doc_custom() {
625 let doc = CUSTOM_CMD.to_doc();
626 assert_eq!(doc.description, "- **special**: special (safe only).");
627 }
628}