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