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