1use crate::parse::{has_flag, Token};
2use crate::policy::{self, FlagPolicy};
3use crate::verdict::{SafetyLevel, Verdict};
4#[cfg(test)]
5use crate::policy::FlagStyle;
6
7pub type CheckFn = fn(&[Token]) -> Verdict;
8
9pub enum SubDef {
10 Policy {
11 name: &'static str,
12 policy: &'static FlagPolicy,
13 level: SafetyLevel,
14 },
15 Nested {
16 name: &'static str,
17 subs: &'static [SubDef],
18 },
19 Guarded {
20 name: &'static str,
21 guard_short: Option<&'static str>,
22 guard_long: &'static str,
23 policy: &'static FlagPolicy,
24 level: SafetyLevel,
25 },
26 Custom {
27 name: &'static str,
28 check: CheckFn,
29 doc: &'static str,
30 test_suffix: Option<&'static str>,
31 },
32 Delegation {
33 name: &'static str,
34 skip: usize,
35 doc: &'static str,
36 },
37}
38
39pub struct CommandDef {
40 pub name: &'static str,
41 pub subs: &'static [SubDef],
42 pub bare_flags: &'static [&'static str],
43 pub help_eligible: bool,
44 pub url: &'static str,
45 pub aliases: &'static [&'static str],
46}
47
48impl SubDef {
49 pub fn name(&self) -> &'static str {
50 match self {
51 Self::Policy { name, .. }
52 | Self::Nested { name, .. }
53 | Self::Guarded { name, .. }
54 | Self::Custom { name, .. }
55 | Self::Delegation { name, .. } => name,
56 }
57 }
58
59 pub fn check(&self, tokens: &[Token]) -> Verdict {
60 match self {
61 Self::Policy { policy, level, .. } => {
62 if tokens.len() == 2 && (tokens[1] == "--help" || tokens[1] == "-h") {
63 return Verdict::Allowed(SafetyLevel::Inert);
64 }
65 if policy::check(tokens, policy) {
66 Verdict::Allowed(*level)
67 } else {
68 Verdict::Denied
69 }
70 }
71 Self::Nested { subs, .. } => {
72 if tokens.len() < 2 {
73 return Verdict::Denied;
74 }
75 let sub = tokens[1].as_str();
76 if tokens.len() == 2 && (sub == "--help" || sub == "-h") {
77 return Verdict::Allowed(SafetyLevel::Inert);
78 }
79 subs.iter()
80 .find(|s| s.name() == sub)
81 .map(|s| s.check(&tokens[1..]))
82 .unwrap_or(Verdict::Denied)
83 }
84 Self::Guarded {
85 guard_short,
86 guard_long,
87 policy,
88 level,
89 ..
90 } => {
91 if tokens.len() == 2 && (tokens[1] == "--help" || tokens[1] == "-h") {
92 return Verdict::Allowed(SafetyLevel::Inert);
93 }
94 if has_flag(tokens, *guard_short, Some(guard_long))
95 && policy::check(tokens, policy)
96 {
97 Verdict::Allowed(*level)
98 } else {
99 Verdict::Denied
100 }
101 }
102 Self::Custom { check: f, .. } => {
103 if tokens.len() == 2 && (tokens[1] == "--help" || tokens[1] == "-h") {
104 return Verdict::Allowed(SafetyLevel::Inert);
105 }
106 f(tokens)
107 }
108 Self::Delegation { skip, .. } => {
109 if tokens.len() <= *skip {
110 return Verdict::Denied;
111 }
112 let inner = shell_words::join(tokens[*skip..].iter().map(|t| t.as_str()));
113 crate::command_verdict(&inner)
114 }
115 }
116 }
117}
118
119impl CommandDef {
120 pub fn opencode_patterns(&self) -> Vec<String> {
121 let mut patterns = Vec::new();
122 let names: Vec<&str> = std::iter::once(self.name)
123 .chain(self.aliases.iter().copied())
124 .collect();
125 for name in &names {
126 for sub in self.subs {
127 sub_opencode_patterns(name, sub, &mut patterns);
128 }
129 }
130 patterns
131 }
132
133 pub fn check(&self, tokens: &[Token]) -> Verdict {
134 if tokens.len() < 2 {
135 return Verdict::Denied;
136 }
137 let arg = tokens[1].as_str();
138 if self.help_eligible && tokens.len() == 2 && matches!(arg, "--help" | "-h" | "--version" | "-V") {
139 return Verdict::Allowed(SafetyLevel::Inert);
140 }
141 if tokens.len() == 2 && self.bare_flags.contains(&arg) {
142 return Verdict::Allowed(SafetyLevel::Inert);
143 }
144 self.subs
145 .iter()
146 .find(|s| s.name() == arg)
147 .map(|s| s.check(&tokens[1..]))
148 .unwrap_or(Verdict::Denied)
149 }
150
151 pub fn dispatch(
152 &self,
153 cmd: &str,
154 tokens: &[Token],
155 ) -> Option<Verdict> {
156 if cmd == self.name || self.aliases.contains(&cmd) {
157 Some(self.check(tokens))
158 } else {
159 None
160 }
161 }
162
163 pub fn to_doc(&self) -> crate::docs::CommandDoc {
164 let mut lines = Vec::new();
165
166 if !self.bare_flags.is_empty() {
167 lines.push(format!("- Info flags: {}", self.bare_flags.join(", ")));
168 }
169
170 let mut sub_lines: Vec<String> = Vec::new();
171 for sub in self.subs {
172 sub_doc_line(sub, "", &mut sub_lines);
173 }
174 sub_lines.sort();
175 lines.extend(sub_lines);
176
177 let mut doc = crate::docs::CommandDoc::handler(self.name, self.url, lines.join("\n"));
178 doc.aliases = self.aliases.iter().map(|a| a.to_string()).collect();
179 doc
180 }
181}
182
183pub struct FlatDef {
184 pub name: &'static str,
185 pub policy: &'static FlagPolicy,
186 pub level: SafetyLevel,
187 pub help_eligible: bool,
188 pub url: &'static str,
189 pub aliases: &'static [&'static str],
190}
191
192impl FlatDef {
193 pub fn opencode_patterns(&self) -> Vec<String> {
194 let mut patterns = Vec::new();
195 let names: Vec<&str> = std::iter::once(self.name)
196 .chain(self.aliases.iter().copied())
197 .collect();
198 for name in names {
199 patterns.push(name.to_string());
200 patterns.push(format!("{name} *"));
201 }
202 patterns
203 }
204
205 pub fn dispatch(&self, cmd: &str, tokens: &[Token]) -> Option<Verdict> {
206 if cmd == self.name || self.aliases.contains(&cmd) {
207 if self.help_eligible
208 && tokens.len() == 2
209 && matches!(tokens[1].as_str(), "--help" | "-h" | "--version" | "-V")
210 {
211 return Some(Verdict::Allowed(SafetyLevel::Inert));
212 }
213 if policy::check(tokens, self.policy) {
214 Some(Verdict::Allowed(self.level))
215 } else {
216 Some(Verdict::Denied)
217 }
218 } else {
219 None
220 }
221 }
222
223 pub fn to_doc(&self) -> crate::docs::CommandDoc {
224 let mut doc = crate::docs::CommandDoc::handler(self.name, self.url, self.policy.describe());
225 doc.aliases = self.aliases.iter().map(|a| a.to_string()).collect();
226 doc
227 }
228}
229
230#[cfg(test)]
231impl FlatDef {
232 pub fn auto_test_reject_unknown(&self) {
233 if self.policy.flag_style == FlagStyle::Positional {
234 return;
235 }
236 let test = format!("{} --xyzzy-unknown-42", self.name);
237 assert!(
238 !crate::is_safe_command(&test),
239 "{}: accepted unknown flag: {test}",
240 self.name,
241 );
242 for alias in self.aliases {
243 let test = format!("{alias} --xyzzy-unknown-42");
244 assert!(
245 !crate::is_safe_command(&test),
246 "{alias}: alias accepted unknown flag: {test}",
247 );
248 }
249 }
250}
251
252fn sub_opencode_patterns(prefix: &str, sub: &SubDef, out: &mut Vec<String>) {
253 match sub {
254 SubDef::Policy { name, .. } => {
255 out.push(format!("{prefix} {name}"));
256 out.push(format!("{prefix} {name} *"));
257 }
258 SubDef::Nested { name, subs } => {
259 let path = format!("{prefix} {name}");
260 for s in *subs {
261 sub_opencode_patterns(&path, s, out);
262 }
263 }
264 SubDef::Guarded {
265 name, guard_long, ..
266 } => {
267 out.push(format!("{prefix} {name} {guard_long}"));
268 out.push(format!("{prefix} {name} {guard_long} *"));
269 }
270 SubDef::Custom { name, .. } => {
271 out.push(format!("{prefix} {name}"));
272 out.push(format!("{prefix} {name} *"));
273 }
274 SubDef::Delegation { .. } => {}
275 }
276}
277
278fn sub_doc_line(sub: &SubDef, prefix: &str, out: &mut Vec<String>) {
279 match sub {
280 SubDef::Policy { name, policy, .. } => {
281 let summary = policy.flag_summary();
282 let label = if prefix.is_empty() {
283 (*name).to_string()
284 } else {
285 format!("{prefix} {name}")
286 };
287 if summary.is_empty() {
288 out.push(format!("- **{label}**"));
289 } else {
290 out.push(format!("- **{label}**: {summary}"));
291 }
292 }
293 SubDef::Nested { name, subs } => {
294 let path = if prefix.is_empty() {
295 (*name).to_string()
296 } else {
297 format!("{prefix} {name}")
298 };
299 for s in *subs {
300 sub_doc_line(s, &path, out);
301 }
302 }
303 SubDef::Guarded {
304 name,
305 guard_long,
306 policy,
307 ..
308 } => {
309 let summary = policy.flag_summary();
310 let label = if prefix.is_empty() {
311 (*name).to_string()
312 } else {
313 format!("{prefix} {name}")
314 };
315 if summary.is_empty() {
316 out.push(format!("- **{label}** (requires {guard_long})"));
317 } else {
318 out.push(format!("- **{label}** (requires {guard_long}): {summary}"));
319 }
320 }
321 SubDef::Custom { name, doc, .. } => {
322 if !doc.is_empty() && doc.trim().is_empty() {
323 return;
324 }
325 let label = if prefix.is_empty() {
326 (*name).to_string()
327 } else {
328 format!("{prefix} {name}")
329 };
330 if doc.is_empty() {
331 out.push(format!("- **{label}**"));
332 } else {
333 out.push(format!("- **{label}**: {doc}"));
334 }
335 }
336 SubDef::Delegation { name, doc, .. } => {
337 if doc.is_empty() {
338 return;
339 }
340 let label = if prefix.is_empty() {
341 (*name).to_string()
342 } else {
343 format!("{prefix} {name}")
344 };
345 out.push(format!("- **{label}**: {doc}"));
346 }
347 }
348}
349
350#[cfg(test)]
351impl CommandDef {
352 pub fn auto_test_reject_unknown(&self) {
353 let mut failures = Vec::new();
354
355 assert!(
356 !crate::is_safe_command(self.name),
357 "{}: accepted bare invocation",
358 self.name,
359 );
360
361 let test = format!("{} xyzzy-unknown-42", self.name);
362 assert!(
363 !crate::is_safe_command(&test),
364 "{}: accepted unknown subcommand: {test}",
365 self.name,
366 );
367
368 for sub in self.subs {
369 auto_test_sub(self.name, sub, &mut failures);
370 }
371 assert!(
372 failures.is_empty(),
373 "{}: unknown flags/subcommands accepted:\n{}",
374 self.name,
375 failures.join("\n"),
376 );
377 }
378}
379
380#[cfg(test)]
381fn auto_test_sub(prefix: &str, sub: &SubDef, failures: &mut Vec<String>) {
382 const UNKNOWN: &str = "--xyzzy-unknown-42";
383
384 match sub {
385 SubDef::Policy { name, policy, .. } => {
386 if policy.flag_style == FlagStyle::Positional {
387 return;
388 }
389 let test = format!("{prefix} {name} {UNKNOWN}");
390 if crate::is_safe_command(&test) {
391 failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
392 }
393 }
394 SubDef::Nested { name, subs } => {
395 let path = format!("{prefix} {name}");
396 let test = format!("{path} xyzzy-unknown-42");
397 if crate::is_safe_command(&test) {
398 failures.push(format!("{path}: accepted unknown subcommand: {test}"));
399 }
400 for s in *subs {
401 auto_test_sub(&path, s, failures);
402 }
403 }
404 SubDef::Guarded {
405 name, guard_long, ..
406 } => {
407 let test = format!("{prefix} {name} {guard_long} {UNKNOWN}");
408 if crate::is_safe_command(&test) {
409 failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
410 }
411 }
412 SubDef::Custom {
413 name, test_suffix, ..
414 } => {
415 if let Some(suffix) = test_suffix {
416 let test = format!("{prefix} {name} {suffix} {UNKNOWN}");
417 if crate::is_safe_command(&test) {
418 failures.push(format!(
419 "{prefix} {name}: accepted unknown flag: {test}"
420 ));
421 }
422 }
423 }
424 SubDef::Delegation { .. } => {}
425 }
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431 use crate::parse::WordSet;
432 use crate::policy::FlagStyle;
433
434 fn toks(words: &[&str]) -> Vec<Token> {
435 words.iter().map(|s| Token::from_test(s)).collect()
436 }
437
438
439 static TEST_POLICY: FlagPolicy = FlagPolicy {
440 standalone: WordSet::new(&["--verbose", "-v"]),
441 valued: WordSet::new(&["--output", "-o"]),
442 bare: true,
443 max_positional: None,
444 flag_style: FlagStyle::Strict,
445 };
446
447 static SIMPLE_CMD: CommandDef = CommandDef {
448 name: "mycmd",
449 subs: &[SubDef::Policy {
450 name: "build",
451 policy: &TEST_POLICY,
452 level: SafetyLevel::SafeWrite,
453 }],
454 bare_flags: &["--info"],
455 help_eligible: true,
456 url: "",
457 aliases: &[],
458 };
459
460 #[test]
461 fn bare_rejected() {
462 assert_eq!(SIMPLE_CMD.check(&toks(&["mycmd"])), Verdict::Denied);
463 }
464
465 #[test]
466 fn bare_flag_accepted() {
467 assert_eq!(
468 SIMPLE_CMD.check(&toks(&["mycmd", "--info"])),
469 Verdict::Allowed(SafetyLevel::Inert),
470 );
471 }
472
473 #[test]
474 fn bare_flag_with_extra_rejected() {
475 assert_eq!(
476 SIMPLE_CMD.check(&toks(&["mycmd", "--info", "extra"])),
477 Verdict::Denied,
478 );
479 }
480
481 #[test]
482 fn policy_sub_bare() {
483 assert_eq!(
484 SIMPLE_CMD.check(&toks(&["mycmd", "build"])),
485 Verdict::Allowed(SafetyLevel::SafeWrite),
486 );
487 }
488
489 #[test]
490 fn policy_sub_with_flag() {
491 assert_eq!(
492 SIMPLE_CMD.check(&toks(&["mycmd", "build", "--verbose"])),
493 Verdict::Allowed(SafetyLevel::SafeWrite),
494 );
495 }
496
497 #[test]
498 fn policy_sub_unknown_flag() {
499 assert_eq!(
500 SIMPLE_CMD.check(&toks(&["mycmd", "build", "--bad"])),
501 Verdict::Denied,
502 );
503 }
504
505 #[test]
506 fn unknown_sub_rejected() {
507 assert_eq!(
508 SIMPLE_CMD.check(&toks(&["mycmd", "deploy"])),
509 Verdict::Denied,
510 );
511 }
512
513 #[test]
514 fn dispatch_matches() {
515 assert_eq!(
516 SIMPLE_CMD.dispatch("mycmd", &toks(&["mycmd", "build"])),
517 Some(Verdict::Allowed(SafetyLevel::SafeWrite)),
518 );
519 }
520
521 #[test]
522 fn dispatch_no_match() {
523 assert_eq!(
524 SIMPLE_CMD.dispatch("other", &toks(&["other", "build"])),
525 None
526 );
527 }
528
529 static NESTED_CMD: CommandDef = CommandDef {
530 name: "nested",
531 subs: &[SubDef::Nested {
532 name: "package",
533 subs: &[SubDef::Policy {
534 name: "describe",
535 policy: &TEST_POLICY,
536 level: SafetyLevel::Inert,
537 }],
538 }],
539 bare_flags: &[],
540 help_eligible: false,
541 url: "",
542 aliases: &[],
543 };
544
545 #[test]
546 fn nested_sub() {
547 assert!(NESTED_CMD.check(&toks(&["nested", "package", "describe"])).is_allowed());
548 }
549
550 #[test]
551 fn nested_sub_with_flag() {
552 assert!(NESTED_CMD.check(
553 &toks(&["nested", "package", "describe", "--verbose"]),
554 ).is_allowed());
555 }
556
557 #[test]
558 fn nested_bare_rejected() {
559 assert_eq!(
560 NESTED_CMD.check(&toks(&["nested", "package"])),
561 Verdict::Denied,
562 );
563 }
564
565 #[test]
566 fn nested_unknown_sub_rejected() {
567 assert_eq!(
568 NESTED_CMD.check(&toks(&["nested", "package", "deploy"])),
569 Verdict::Denied,
570 );
571 }
572
573 static GUARDED_POLICY: FlagPolicy = FlagPolicy {
574 standalone: WordSet::new(&["--all", "--check"]),
575 valued: WordSet::new(&[]),
576 bare: false,
577 max_positional: None,
578 flag_style: FlagStyle::Strict,
579 };
580
581 static GUARDED_CMD: CommandDef = CommandDef {
582 name: "guarded",
583 subs: &[SubDef::Guarded {
584 name: "fmt",
585 guard_short: None,
586 guard_long: "--check",
587 policy: &GUARDED_POLICY,
588 level: SafetyLevel::Inert,
589 }],
590 bare_flags: &[],
591 help_eligible: false,
592 url: "",
593 aliases: &[],
594 };
595
596 #[test]
597 fn guarded_with_guard() {
598 assert!(GUARDED_CMD.check(&toks(&["guarded", "fmt", "--check"])).is_allowed());
599 }
600
601 #[test]
602 fn guarded_without_guard() {
603 assert_eq!(
604 GUARDED_CMD.check(&toks(&["guarded", "fmt"])),
605 Verdict::Denied,
606 );
607 }
608
609 #[test]
610 fn guarded_with_guard_and_flag() {
611 assert!(GUARDED_CMD.check(
612 &toks(&["guarded", "fmt", "--check", "--all"]),
613 ).is_allowed());
614 }
615
616 static DELEGATION_CMD: CommandDef = CommandDef {
617 name: "runner",
618 subs: &[SubDef::Delegation {
619 name: "run",
620 skip: 2,
621 doc: "run delegates to inner command.",
622 }],
623 bare_flags: &[],
624 help_eligible: false,
625 url: "",
626 aliases: &[],
627 };
628
629 #[test]
630 fn delegation_safe_inner() {
631 assert!(DELEGATION_CMD.check(
632 &toks(&["runner", "run", "stable", "echo", "hello"]),
633 ).is_allowed());
634 }
635
636 #[test]
637 fn delegation_unsafe_inner() {
638 assert_eq!(
639 DELEGATION_CMD.check(&toks(&["runner", "run", "stable", "rm", "-rf"])),
640 Verdict::Denied,
641 );
642 }
643
644 #[test]
645 fn delegation_no_inner() {
646 assert_eq!(
647 DELEGATION_CMD.check(&toks(&["runner", "run", "stable"])),
648 Verdict::Denied,
649 );
650 }
651
652 fn custom_check(tokens: &[Token]) -> Verdict {
653 if tokens.len() >= 2 && tokens[1] == "safe" {
654 Verdict::Allowed(SafetyLevel::Inert)
655 } else {
656 Verdict::Denied
657 }
658 }
659
660 static CUSTOM_CMD: CommandDef = CommandDef {
661 name: "custom",
662 subs: &[SubDef::Custom {
663 name: "special",
664 check: custom_check,
665 doc: "special (safe only).",
666 test_suffix: Some("safe"),
667 }],
668 bare_flags: &[],
669 help_eligible: false,
670 url: "",
671 aliases: &[],
672 };
673
674 #[test]
675 fn custom_passes() {
676 assert!(CUSTOM_CMD.check(&toks(&["custom", "special", "safe"])).is_allowed());
677 }
678
679 #[test]
680 fn custom_fails() {
681 assert_eq!(
682 CUSTOM_CMD.check(&toks(&["custom", "special", "bad"])),
683 Verdict::Denied,
684 );
685 }
686
687 #[test]
688 fn help_on_sub_is_inert() {
689 assert_eq!(
690 SIMPLE_CMD.check(&toks(&["mycmd", "build", "--help"])),
691 Verdict::Allowed(SafetyLevel::Inert),
692 );
693 }
694
695 #[test]
696 fn help_on_command_is_inert() {
697 assert_eq!(
698 SIMPLE_CMD.check(&toks(&["mycmd", "--help"])),
699 Verdict::Allowed(SafetyLevel::Inert),
700 );
701 }
702
703 #[test]
704 fn doc_simple() {
705 let doc = SIMPLE_CMD.to_doc();
706 assert_eq!(doc.name, "mycmd");
707 assert_eq!(
708 doc.description,
709 "- Info flags: --info\n- **build**: Flags: --verbose, -v. Valued: --output, -o"
710 );
711 }
712
713 #[test]
714 fn doc_nested() {
715 let doc = NESTED_CMD.to_doc();
716 assert_eq!(
717 doc.description,
718 "- **package describe**: Flags: --verbose, -v. Valued: --output, -o"
719 );
720 }
721
722 #[test]
723 fn doc_guarded() {
724 let doc = GUARDED_CMD.to_doc();
725 assert_eq!(
726 doc.description,
727 "- **fmt** (requires --check): Flags: --all, --check"
728 );
729 }
730
731 #[test]
732 fn doc_delegation() {
733 let doc = DELEGATION_CMD.to_doc();
734 assert_eq!(doc.description, "- **run**: run delegates to inner command.");
735 }
736
737 #[test]
738 fn doc_custom() {
739 let doc = CUSTOM_CMD.to_doc();
740 assert_eq!(doc.description, "- **special**: special (safe only).");
741 }
742
743 #[test]
744 fn opencode_patterns_simple() {
745 let patterns = SIMPLE_CMD.opencode_patterns();
746 assert!(patterns.contains(&"mycmd build".to_string()));
747 assert!(patterns.contains(&"mycmd build *".to_string()));
748 }
749
750 #[test]
751 fn opencode_patterns_nested() {
752 let patterns = NESTED_CMD.opencode_patterns();
753 assert!(patterns.contains(&"nested package describe".to_string()));
754 assert!(patterns.contains(&"nested package describe *".to_string()));
755 assert!(!patterns.iter().any(|p| p == "nested package"));
756 }
757
758 #[test]
759 fn opencode_patterns_guarded() {
760 let patterns = GUARDED_CMD.opencode_patterns();
761 assert!(patterns.contains(&"guarded fmt --check".to_string()));
762 assert!(patterns.contains(&"guarded fmt --check *".to_string()));
763 assert!(!patterns.iter().any(|p| p == "guarded fmt"));
764 }
765
766 #[test]
767 fn opencode_patterns_delegation_skipped() {
768 let patterns = DELEGATION_CMD.opencode_patterns();
769 assert!(patterns.is_empty());
770 }
771
772 #[test]
773 fn opencode_patterns_custom() {
774 let patterns = CUSTOM_CMD.opencode_patterns();
775 assert!(patterns.contains(&"custom special".to_string()));
776 assert!(patterns.contains(&"custom special *".to_string()));
777 }
778
779 #[test]
780 fn opencode_patterns_aliases() {
781 static ALIASED: CommandDef = CommandDef {
782 name: "primary",
783 subs: &[SubDef::Policy {
784 name: "list",
785 policy: &TEST_POLICY,
786 level: SafetyLevel::Inert,
787 }],
788 bare_flags: &[],
789 help_eligible: false,
790 url: "",
791 aliases: &["alt"],
792 };
793 let patterns = ALIASED.opencode_patterns();
794 assert!(patterns.contains(&"primary list".to_string()));
795 assert!(patterns.contains(&"alt list".to_string()));
796 assert!(patterns.contains(&"alt list *".to_string()));
797 }
798
799 #[test]
800 fn flat_def_opencode_patterns() {
801 static FLAT: FlatDef = FlatDef {
802 name: "grep",
803 policy: &TEST_POLICY,
804 level: SafetyLevel::Inert,
805 help_eligible: true,
806 url: "",
807 aliases: &["rg"],
808 };
809 let patterns = FLAT.opencode_patterns();
810 assert_eq!(patterns, vec!["grep", "grep *", "rg", "rg *"]);
811 }
812}