Skip to main content

opencode_voice/approval/
matcher.rs

1//! Voice command matching for approval handling.
2//!
3//! Matches transcribed text against permission approval patterns and question answers.
4
5use crate::approval::types::{PermissionReply, QuestionRequest};
6
7/// Result of matching a voice command against an approval context.
8#[derive(Debug, Clone, PartialEq)]
9pub enum MatchResult {
10    /// Matched a permission reply command.
11    PermissionReply {
12        reply: PermissionReply,
13        message: Option<String>,
14    },
15    /// Matched a question answer.
16    QuestionAnswer { answers: Vec<Vec<String>> },
17    /// Matched a question rejection.
18    QuestionReject,
19    /// No match found.
20    NoMatch,
21}
22
23/// Normalizes text: lowercase, trim, strip trailing punctuation, collapse whitespace.
24pub fn normalize(text: &str) -> String {
25    let lower = text.to_lowercase();
26    let trimmed = lower.trim();
27    // Strip trailing punctuation
28    let stripped =
29        trimmed.trim_end_matches(|c: char| matches!(c, '.' | '!' | '?' | ',' | ';' | ':'));
30    // Collapse internal whitespace
31    stripped.split_whitespace().collect::<Vec<_>>().join(" ")
32}
33
34/// Patterns that result in a "once" permission grant.
35const ONCE_PATTERNS: &[&str] = &[
36    "allow",
37    "allow once",
38    "allow it",
39    "allow this",
40    "yes",
41    "yeah",
42    "yep",
43    "ok",
44    "okay",
45    "sure",
46    "proceed",
47    "go ahead",
48    "do it",
49    "approve",
50    "approve once",
51    "approve it",
52    "approve this",
53    "accept",
54    "run it",
55    "go for it",
56    "permit",
57    "execute",
58];
59
60/// Patterns that result in an "always" permission grant.
61const ALWAYS_PATTERNS: &[&str] = &["always", "always allow", "trust", "trust it", "allow all"];
62
63/// Patterns that result in a permission rejection.
64const REJECT_PATTERNS: &[&str] = &[
65    "reject",
66    "deny",
67    "no",
68    "nope",
69    "cancel",
70    "stop",
71    "don't",
72    "do not",
73    "refuse",
74    "block",
75    "skip",
76    "decline",
77    "dismiss",
78    "not allowed",
79    "nah",
80    "don't do it",
81];
82
83/// Rejection prefixes that can be followed by a message.
84const REJECT_PREFIXES: &[&str] = &["no", "nope", "reject", "deny", "cancel", "don't", "refuse"];
85
86/// Question rejection phrases.
87const QUESTION_REJECT_PATTERNS: &[&str] = &[
88    "skip",
89    "dismiss",
90    "cancel",
91    "reject",
92    "none",
93    "never mind",
94    "nevermind",
95];
96
97/// Number word to 1-based index mapping.
98fn parse_number_word(word: &str) -> Option<usize> {
99    match word {
100        "one" | "first" | "1" => Some(1),
101        "two" | "second" | "2" => Some(2),
102        "three" | "third" | "3" => Some(3),
103        "four" | "fourth" | "4" => Some(4),
104        "five" | "fifth" | "5" => Some(5),
105        "six" | "sixth" | "6" => Some(6),
106        "seven" | "seventh" | "7" => Some(7),
107        "eight" | "eighth" | "8" => Some(8),
108        "nine" | "ninth" | "9" => Some(9),
109        "ten" | "tenth" | "10" => Some(10),
110        _ => None,
111    }
112}
113
114/// Matches a voice command against permission approval patterns.
115///
116/// Returns the appropriate MatchResult based on which pattern matches.
117pub fn match_permission_command(text: &str) -> MatchResult {
118    let normalized = normalize(text);
119
120    // Check always patterns first (more specific than once)
121    for pattern in ALWAYS_PATTERNS {
122        if normalized == *pattern {
123            return MatchResult::PermissionReply {
124                reply: PermissionReply::Always,
125                message: None,
126            };
127        }
128    }
129
130    // Check once patterns
131    for pattern in ONCE_PATTERNS {
132        if normalized == *pattern {
133            return MatchResult::PermissionReply {
134                reply: PermissionReply::Once,
135                message: None,
136            };
137        }
138    }
139
140    // Check exact reject patterns
141    for pattern in REJECT_PATTERNS {
142        if normalized == *pattern {
143            return MatchResult::PermissionReply {
144                reply: PermissionReply::Reject,
145                message: None,
146            };
147        }
148    }
149
150    // Check reject with message: "no, <message>" or "reject, <message>" etc.
151    for prefix in REJECT_PREFIXES {
152        if let Some(after) = normalized.strip_prefix(prefix) {
153            let after = after.trim_start_matches(|c: char| c == ',' || c.is_whitespace());
154            if !after.is_empty() {
155                return MatchResult::PermissionReply {
156                    reply: PermissionReply::Reject,
157                    message: Some(after.to_string()),
158                };
159            }
160        }
161    }
162
163    MatchResult::NoMatch
164}
165
166/// Matches a voice command against question options.
167///
168/// Returns QuestionReject, QuestionAnswer, or NoMatch.
169pub fn match_question_answer(text: &str, question: &QuestionRequest) -> MatchResult {
170    let normalized = normalize(text);
171
172    // Check question rejection phrases first
173    for pattern in QUESTION_REJECT_PATTERNS {
174        if normalized == *pattern {
175            return MatchResult::QuestionReject;
176        }
177    }
178
179    // Process each question in the request
180    let mut all_answers: Vec<Vec<String>> = Vec::new();
181
182    for q in &question.questions {
183        let options = &q.options;
184
185        // 1. Exact label match
186        let exact = options.iter().find(|o| normalize(&o.label) == normalized);
187        if let Some(opt) = exact {
188            all_answers.push(vec![opt.label.clone()]);
189            continue;
190        }
191
192        // 2. Label-in-text match
193        let contains = options
194            .iter()
195            .find(|o| normalized.contains(&normalize(&o.label)));
196        if let Some(opt) = contains {
197            all_answers.push(vec![opt.label.clone()]);
198            continue;
199        }
200
201        // 3. Numeric match: "option 1", "one", "first", etc.
202        // Check "option N" pattern
203        let numeric = if let Some(after_option) = normalized.strip_prefix("option ") {
204            parse_number_word(after_option.trim())
205        } else {
206            // Check bare number word
207            parse_number_word(&normalized)
208        };
209
210        if let Some(idx) = numeric {
211            if idx >= 1 && idx <= options.len() {
212                all_answers.push(vec![options[idx - 1].label.clone()]);
213                continue;
214            }
215        }
216
217        // 4. Custom answer fallback (if question allows custom responses)
218        if q.custom {
219            all_answers.push(vec![text.trim().to_string()]);
220            continue;
221        }
222
223        // No match for this question
224        return MatchResult::NoMatch;
225    }
226
227    if all_answers.is_empty() {
228        MatchResult::NoMatch
229    } else {
230        MatchResult::QuestionAnswer {
231            answers: all_answers,
232        }
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use crate::approval::types::{QuestionInfo, QuestionOption, QuestionRequest};
240
241    fn make_question(options: &[&str], custom: bool) -> QuestionRequest {
242        QuestionRequest {
243            id: "test-id".to_string(),
244            questions: vec![QuestionInfo {
245                question: "What do you want to do?".to_string(),
246                options: options
247                    .iter()
248                    .map(|&l| QuestionOption {
249                        label: l.to_string(),
250                    })
251                    .collect(),
252                custom,
253            }],
254        }
255    }
256
257    // Permission tests — Once patterns (22)
258    #[test]
259    fn test_once_allow() {
260        assert!(matches!(
261            match_permission_command("allow"),
262            MatchResult::PermissionReply {
263                reply: PermissionReply::Once,
264                ..
265            }
266        ));
267    }
268    #[test]
269    fn test_once_yes() {
270        assert!(matches!(
271            match_permission_command("yes"),
272            MatchResult::PermissionReply {
273                reply: PermissionReply::Once,
274                ..
275            }
276        ));
277    }
278    #[test]
279    fn test_once_ok() {
280        assert!(matches!(
281            match_permission_command("ok"),
282            MatchResult::PermissionReply {
283                reply: PermissionReply::Once,
284                ..
285            }
286        ));
287    }
288    #[test]
289    fn test_once_okay() {
290        assert!(matches!(
291            match_permission_command("okay"),
292            MatchResult::PermissionReply {
293                reply: PermissionReply::Once,
294                ..
295            }
296        ));
297    }
298    #[test]
299    fn test_once_sure() {
300        assert!(matches!(
301            match_permission_command("sure"),
302            MatchResult::PermissionReply {
303                reply: PermissionReply::Once,
304                ..
305            }
306        ));
307    }
308    #[test]
309    fn test_once_proceed() {
310        assert!(matches!(
311            match_permission_command("proceed"),
312            MatchResult::PermissionReply {
313                reply: PermissionReply::Once,
314                ..
315            }
316        ));
317    }
318    #[test]
319    fn test_once_approve() {
320        assert!(matches!(
321            match_permission_command("approve"),
322            MatchResult::PermissionReply {
323                reply: PermissionReply::Once,
324                ..
325            }
326        ));
327    }
328    #[test]
329    fn test_once_execute() {
330        assert!(matches!(
331            match_permission_command("execute"),
332            MatchResult::PermissionReply {
333                reply: PermissionReply::Once,
334                ..
335            }
336        ));
337    }
338    #[test]
339    fn test_once_accept() {
340        assert!(matches!(
341            match_permission_command("accept"),
342            MatchResult::PermissionReply {
343                reply: PermissionReply::Once,
344                ..
345            }
346        ));
347    }
348    #[test]
349    fn test_once_yeah() {
350        assert!(matches!(
351            match_permission_command("yeah"),
352            MatchResult::PermissionReply {
353                reply: PermissionReply::Once,
354                ..
355            }
356        ));
357    }
358    #[test]
359    fn test_once_with_punctuation() {
360        // "yes." should normalize to "yes" and match once
361        assert!(matches!(
362            match_permission_command("yes."),
363            MatchResult::PermissionReply {
364                reply: PermissionReply::Once,
365                ..
366            }
367        ));
368    }
369
370    // Always patterns (5)
371    #[test]
372    fn test_always_always() {
373        assert!(matches!(
374            match_permission_command("always"),
375            MatchResult::PermissionReply {
376                reply: PermissionReply::Always,
377                ..
378            }
379        ));
380    }
381    #[test]
382    fn test_always_always_allow() {
383        assert!(matches!(
384            match_permission_command("always allow"),
385            MatchResult::PermissionReply {
386                reply: PermissionReply::Always,
387                ..
388            }
389        ));
390    }
391    #[test]
392    fn test_always_trust() {
393        assert!(matches!(
394            match_permission_command("trust"),
395            MatchResult::PermissionReply {
396                reply: PermissionReply::Always,
397                ..
398            }
399        ));
400    }
401    #[test]
402    fn test_always_trust_it() {
403        assert!(matches!(
404            match_permission_command("trust it"),
405            MatchResult::PermissionReply {
406                reply: PermissionReply::Always,
407                ..
408            }
409        ));
410    }
411    #[test]
412    fn test_always_allow_all() {
413        assert!(matches!(
414            match_permission_command("allow all"),
415            MatchResult::PermissionReply {
416                reply: PermissionReply::Always,
417                ..
418            }
419        ));
420    }
421
422    // Reject patterns (16)
423    #[test]
424    fn test_reject_no() {
425        assert!(matches!(
426            match_permission_command("no"),
427            MatchResult::PermissionReply {
428                reply: PermissionReply::Reject,
429                ..
430            }
431        ));
432    }
433    #[test]
434    fn test_reject_reject() {
435        assert!(matches!(
436            match_permission_command("reject"),
437            MatchResult::PermissionReply {
438                reply: PermissionReply::Reject,
439                ..
440            }
441        ));
442    }
443    #[test]
444    fn test_reject_deny() {
445        assert!(matches!(
446            match_permission_command("deny"),
447            MatchResult::PermissionReply {
448                reply: PermissionReply::Reject,
449                ..
450            }
451        ));
452    }
453    #[test]
454    fn test_reject_cancel() {
455        assert!(matches!(
456            match_permission_command("cancel"),
457            MatchResult::PermissionReply {
458                reply: PermissionReply::Reject,
459                ..
460            }
461        ));
462    }
463    #[test]
464    fn test_reject_nope() {
465        assert!(matches!(
466            match_permission_command("nope"),
467            MatchResult::PermissionReply {
468                reply: PermissionReply::Reject,
469                ..
470            }
471        ));
472    }
473
474    // Reject with message
475    #[test]
476    fn test_reject_with_message() {
477        let result = match_permission_command("no, try something else");
478        match result {
479            MatchResult::PermissionReply {
480                reply: PermissionReply::Reject,
481                message,
482            } => {
483                assert_eq!(message, Some("try something else".to_string()));
484            }
485            _ => panic!("Expected reject with message"),
486        }
487    }
488
489    // NoMatch
490    #[test]
491    fn test_no_match() {
492        assert_eq!(
493            match_permission_command("hello world"),
494            MatchResult::NoMatch
495        );
496    }
497    #[test]
498    fn test_no_match_empty() {
499        assert_eq!(match_permission_command(""), MatchResult::NoMatch);
500    }
501
502    // Question tests
503    #[test]
504    fn test_question_exact_label() {
505        let q = make_question(&["Continue", "Cancel", "Retry"], true);
506        let result = match_question_answer("continue", &q);
507        assert!(matches!(result, MatchResult::QuestionAnswer { .. }));
508    }
509
510    #[test]
511    fn test_question_numeric_option_1() {
512        let q = make_question(&["Yes", "No"], true);
513        let result = match_question_answer("option 1", &q);
514        assert!(matches!(result, MatchResult::QuestionAnswer { .. }));
515    }
516
517    #[test]
518    fn test_question_numeric_word_first() {
519        let q = make_question(&["Alpha", "Beta"], true);
520        let result = match_question_answer("first", &q);
521        assert!(
522            matches!(result, MatchResult::QuestionAnswer { answers } if answers[0] == vec!["Alpha"])
523        );
524    }
525
526    #[test]
527    fn test_question_numeric_word_one() {
528        let q = make_question(&["Alpha", "Beta"], true);
529        let result = match_question_answer("one", &q);
530        assert!(
531            matches!(result, MatchResult::QuestionAnswer { answers } if answers[0] == vec!["Alpha"])
532        );
533    }
534
535    #[test]
536    fn test_question_reject_skip() {
537        let q = make_question(&["Yes", "No"], true);
538        assert_eq!(
539            match_question_answer("skip", &q),
540            MatchResult::QuestionReject
541        );
542    }
543
544    #[test]
545    fn test_question_reject_dismiss() {
546        let q = make_question(&["Yes", "No"], true);
547        assert_eq!(
548            match_question_answer("dismiss", &q),
549            MatchResult::QuestionReject
550        );
551    }
552
553    #[test]
554    fn test_question_custom_answer() {
555        let q = make_question(&["Yes", "No"], true);
556        let result = match_question_answer("do something custom", &q);
557        assert!(matches!(result, MatchResult::QuestionAnswer { .. }));
558    }
559
560    #[test]
561    fn test_question_no_match_no_custom() {
562        let q = make_question(&["Yes", "No"], false);
563        let result = match_question_answer("do something custom", &q);
564        assert_eq!(result, MatchResult::NoMatch);
565    }
566
567    #[test]
568    fn test_normalize_punctuation() {
569        assert_eq!(normalize("yes."), "yes");
570        assert_eq!(normalize("Allow!"), "allow");
571        assert_eq!(normalize("  ok  "), "ok");
572    }
573
574    // Additional coverage tests
575    #[test]
576    fn test_once_all_patterns() {
577        let expected_once = [
578            "allow",
579            "allow once",
580            "allow it",
581            "allow this",
582            "yes",
583            "yeah",
584            "yep",
585            "ok",
586            "okay",
587            "sure",
588            "proceed",
589            "go ahead",
590            "do it",
591            "approve",
592            "approve once",
593            "approve it",
594            "approve this",
595            "accept",
596            "run it",
597            "go for it",
598            "permit",
599            "execute",
600        ];
601        assert_eq!(
602            expected_once.len(),
603            22,
604            "Must have exactly 22 once patterns"
605        );
606        for pattern in &expected_once {
607            assert!(
608                matches!(
609                    match_permission_command(pattern),
610                    MatchResult::PermissionReply {
611                        reply: PermissionReply::Once,
612                        ..
613                    }
614                ),
615                "Pattern '{}' should match Once",
616                pattern
617            );
618        }
619    }
620
621    #[test]
622    fn test_always_all_patterns() {
623        let expected_always = ["always", "always allow", "trust", "trust it", "allow all"];
624        assert_eq!(
625            expected_always.len(),
626            5,
627            "Must have exactly 5 always patterns"
628        );
629        for pattern in &expected_always {
630            assert!(
631                matches!(
632                    match_permission_command(pattern),
633                    MatchResult::PermissionReply {
634                        reply: PermissionReply::Always,
635                        ..
636                    }
637                ),
638                "Pattern '{}' should match Always",
639                pattern
640            );
641        }
642    }
643
644    #[test]
645    fn test_reject_all_patterns() {
646        let expected_reject = [
647            "reject",
648            "deny",
649            "no",
650            "nope",
651            "cancel",
652            "stop",
653            "don't",
654            "do not",
655            "refuse",
656            "block",
657            "skip",
658            "decline",
659            "dismiss",
660            "not allowed",
661            "nah",
662            "don't do it",
663        ];
664        assert_eq!(
665            expected_reject.len(),
666            16,
667            "Must have exactly 16 reject patterns"
668        );
669        for pattern in &expected_reject {
670            assert!(
671                matches!(
672                    match_permission_command(pattern),
673                    MatchResult::PermissionReply {
674                        reply: PermissionReply::Reject,
675                        ..
676                    }
677                ),
678                "Pattern '{}' should match Reject",
679                pattern
680            );
681        }
682    }
683
684    #[test]
685    fn test_always_takes_priority_over_once() {
686        // "always allow" contains "allow" (once) but should match Always
687        assert!(matches!(
688            match_permission_command("always allow"),
689            MatchResult::PermissionReply {
690                reply: PermissionReply::Always,
691                ..
692            }
693        ));
694    }
695
696    #[test]
697    fn test_not_allowed_exact_reject() {
698        // "not allowed" starts with "no" but should be exact reject, not reject-with-message
699        let result = match_permission_command("not allowed");
700        match result {
701            MatchResult::PermissionReply {
702                reply: PermissionReply::Reject,
703                message,
704            } => {
705                assert_eq!(message, None, "'not allowed' should have no message");
706            }
707            _ => panic!("Expected reject"),
708        }
709    }
710
711    #[test]
712    fn test_question_label_in_text() {
713        let q = make_question(&["Continue", "Cancel"], true);
714        // "I want to continue" contains "continue"
715        let result = match_question_answer("I want to continue", &q);
716        assert!(
717            matches!(result, MatchResult::QuestionAnswer { answers } if answers[0] == vec!["Continue"])
718        );
719    }
720
721    #[test]
722    fn test_question_numeric_option_2() {
723        let q = make_question(&["Alpha", "Beta", "Gamma"], true);
724        let result = match_question_answer("option 2", &q);
725        assert!(
726            matches!(result, MatchResult::QuestionAnswer { answers } if answers[0] == vec!["Beta"])
727        );
728    }
729
730    #[test]
731    fn test_question_numeric_word_second() {
732        let q = make_question(&["Alpha", "Beta"], true);
733        let result = match_question_answer("second", &q);
734        assert!(
735            matches!(result, MatchResult::QuestionAnswer { answers } if answers[0] == vec!["Beta"])
736        );
737    }
738
739    #[test]
740    fn test_question_reject_never_mind() {
741        let q = make_question(&["Yes", "No"], true);
742        assert_eq!(
743            match_question_answer("never mind", &q),
744            MatchResult::QuestionReject
745        );
746    }
747
748    #[test]
749    fn test_question_empty_questions_no_match() {
750        let q = QuestionRequest {
751            id: "test".to_string(),
752            questions: vec![],
753        };
754        assert_eq!(match_question_answer("yes", &q), MatchResult::NoMatch);
755    }
756
757    // --- Additional tests added to expand coverage ---
758
759    // normalize: extra whitespace collapsing
760    #[test]
761    fn test_normalize_extra_whitespace() {
762        assert_eq!(normalize("  hello   world  "), "hello world");
763    }
764
765    // normalize: mixed case
766    #[test]
767    fn test_normalize_mixed_case() {
768        assert_eq!(normalize("ALLOW"), "allow");
769        assert_eq!(normalize("AlLoW OnCe"), "allow once");
770    }
771
772    // normalize: multiple trailing punctuation chars
773    #[test]
774    fn test_normalize_multiple_trailing_punctuation() {
775        assert_eq!(normalize("yes!?"), "yes");
776        assert_eq!(normalize("ok..."), "ok");
777    }
778
779    // normalize: internal punctuation is preserved (only trailing stripped)
780    #[test]
781    fn test_normalize_internal_punctuation_preserved() {
782        // Commas in the middle are not stripped
783        let result = normalize("no, try again");
784        assert_eq!(result, "no, try again");
785    }
786
787    // Once patterns: remaining ones not individually tested above
788    #[test]
789    fn test_once_yep() {
790        assert!(matches!(
791            match_permission_command("yep"),
792            MatchResult::PermissionReply {
793                reply: PermissionReply::Once,
794                ..
795            }
796        ));
797    }
798
799    #[test]
800    fn test_once_go_ahead() {
801        assert!(matches!(
802            match_permission_command("go ahead"),
803            MatchResult::PermissionReply {
804                reply: PermissionReply::Once,
805                ..
806            }
807        ));
808    }
809
810    #[test]
811    fn test_once_do_it() {
812        assert!(matches!(
813            match_permission_command("do it"),
814            MatchResult::PermissionReply {
815                reply: PermissionReply::Once,
816                ..
817            }
818        ));
819    }
820
821    #[test]
822    fn test_once_run_it() {
823        assert!(matches!(
824            match_permission_command("run it"),
825            MatchResult::PermissionReply {
826                reply: PermissionReply::Once,
827                ..
828            }
829        ));
830    }
831
832    #[test]
833    fn test_once_go_for_it() {
834        assert!(matches!(
835            match_permission_command("go for it"),
836            MatchResult::PermissionReply {
837                reply: PermissionReply::Once,
838                ..
839            }
840        ));
841    }
842
843    #[test]
844    fn test_once_permit() {
845        assert!(matches!(
846            match_permission_command("permit"),
847            MatchResult::PermissionReply {
848                reply: PermissionReply::Once,
849                ..
850            }
851        ));
852    }
853
854    #[test]
855    fn test_once_allow_once() {
856        assert!(matches!(
857            match_permission_command("allow once"),
858            MatchResult::PermissionReply {
859                reply: PermissionReply::Once,
860                ..
861            }
862        ));
863    }
864
865    #[test]
866    fn test_once_allow_it() {
867        assert!(matches!(
868            match_permission_command("allow it"),
869            MatchResult::PermissionReply {
870                reply: PermissionReply::Once,
871                ..
872            }
873        ));
874    }
875
876    #[test]
877    fn test_once_allow_this() {
878        assert!(matches!(
879            match_permission_command("allow this"),
880            MatchResult::PermissionReply {
881                reply: PermissionReply::Once,
882                ..
883            }
884        ));
885    }
886
887    #[test]
888    fn test_once_approve_once() {
889        assert!(matches!(
890            match_permission_command("approve once"),
891            MatchResult::PermissionReply {
892                reply: PermissionReply::Once,
893                ..
894            }
895        ));
896    }
897
898    #[test]
899    fn test_once_approve_it() {
900        assert!(matches!(
901            match_permission_command("approve it"),
902            MatchResult::PermissionReply {
903                reply: PermissionReply::Once,
904                ..
905            }
906        ));
907    }
908
909    #[test]
910    fn test_once_approve_this() {
911        assert!(matches!(
912            match_permission_command("approve this"),
913            MatchResult::PermissionReply {
914                reply: PermissionReply::Once,
915                ..
916            }
917        ));
918    }
919
920    // Reject patterns: remaining ones not individually tested above
921    #[test]
922    fn test_reject_stop() {
923        assert!(matches!(
924            match_permission_command("stop"),
925            MatchResult::PermissionReply {
926                reply: PermissionReply::Reject,
927                ..
928            }
929        ));
930    }
931
932    #[test]
933    fn test_reject_dont() {
934        assert!(matches!(
935            match_permission_command("don't"),
936            MatchResult::PermissionReply {
937                reply: PermissionReply::Reject,
938                ..
939            }
940        ));
941    }
942
943    #[test]
944    fn test_reject_do_not() {
945        assert!(matches!(
946            match_permission_command("do not"),
947            MatchResult::PermissionReply {
948                reply: PermissionReply::Reject,
949                ..
950            }
951        ));
952    }
953
954    #[test]
955    fn test_reject_refuse() {
956        assert!(matches!(
957            match_permission_command("refuse"),
958            MatchResult::PermissionReply {
959                reply: PermissionReply::Reject,
960                ..
961            }
962        ));
963    }
964
965    #[test]
966    fn test_reject_block() {
967        assert!(matches!(
968            match_permission_command("block"),
969            MatchResult::PermissionReply {
970                reply: PermissionReply::Reject,
971                ..
972            }
973        ));
974    }
975
976    #[test]
977    fn test_reject_skip() {
978        assert!(matches!(
979            match_permission_command("skip"),
980            MatchResult::PermissionReply {
981                reply: PermissionReply::Reject,
982                ..
983            }
984        ));
985    }
986
987    #[test]
988    fn test_reject_decline() {
989        assert!(matches!(
990            match_permission_command("decline"),
991            MatchResult::PermissionReply {
992                reply: PermissionReply::Reject,
993                ..
994            }
995        ));
996    }
997
998    #[test]
999    fn test_reject_dismiss() {
1000        assert!(matches!(
1001            match_permission_command("dismiss"),
1002            MatchResult::PermissionReply {
1003                reply: PermissionReply::Reject,
1004                ..
1005            }
1006        ));
1007    }
1008
1009    #[test]
1010    fn test_reject_nah() {
1011        assert!(matches!(
1012            match_permission_command("nah"),
1013            MatchResult::PermissionReply {
1014                reply: PermissionReply::Reject,
1015                ..
1016            }
1017        ));
1018    }
1019
1020    #[test]
1021    fn test_reject_dont_do_it() {
1022        assert!(matches!(
1023            match_permission_command("don't do it"),
1024            MatchResult::PermissionReply {
1025                reply: PermissionReply::Reject,
1026                ..
1027            }
1028        ));
1029    }
1030
1031    // Reject with message: various prefixes
1032    #[test]
1033    fn test_reject_with_message_deny_prefix() {
1034        let result = match_permission_command("deny, not safe");
1035        match result {
1036            MatchResult::PermissionReply {
1037                reply: PermissionReply::Reject,
1038                message,
1039            } => {
1040                assert_eq!(message, Some("not safe".to_string()));
1041            }
1042            _ => panic!("Expected reject with message"),
1043        }
1044    }
1045
1046    #[test]
1047    fn test_reject_with_message_cancel_prefix() {
1048        let result = match_permission_command("cancel, wrong command");
1049        match result {
1050            MatchResult::PermissionReply {
1051                reply: PermissionReply::Reject,
1052                message,
1053            } => {
1054                assert_eq!(message, Some("wrong command".to_string()));
1055            }
1056            _ => panic!("Expected reject with message"),
1057        }
1058    }
1059
1060    #[test]
1061    fn test_reject_with_message_no_space_separator() {
1062        // "no try again" — "no" is a reject prefix, "try again" is the message
1063        let result = match_permission_command("no try again");
1064        match result {
1065            MatchResult::PermissionReply {
1066                reply: PermissionReply::Reject,
1067                message,
1068            } => {
1069                assert_eq!(message, Some("try again".to_string()));
1070            }
1071            _ => panic!("Expected reject with message"),
1072        }
1073    }
1074
1075    // Case-insensitive matching via normalize
1076    #[test]
1077    fn test_once_case_insensitive() {
1078        assert!(matches!(
1079            match_permission_command("YES"),
1080            MatchResult::PermissionReply {
1081                reply: PermissionReply::Once,
1082                ..
1083            }
1084        ));
1085        assert!(matches!(
1086            match_permission_command("Allow"),
1087            MatchResult::PermissionReply {
1088                reply: PermissionReply::Once,
1089                ..
1090            }
1091        ));
1092    }
1093
1094    #[test]
1095    fn test_always_case_insensitive() {
1096        assert!(matches!(
1097            match_permission_command("ALWAYS"),
1098            MatchResult::PermissionReply {
1099                reply: PermissionReply::Always,
1100                ..
1101            }
1102        ));
1103        assert!(matches!(
1104            match_permission_command("Trust"),
1105            MatchResult::PermissionReply {
1106                reply: PermissionReply::Always,
1107                ..
1108            }
1109        ));
1110    }
1111
1112    #[test]
1113    fn test_reject_case_insensitive() {
1114        assert!(matches!(
1115            match_permission_command("NO"),
1116            MatchResult::PermissionReply {
1117                reply: PermissionReply::Reject,
1118                ..
1119            }
1120        ));
1121        assert!(matches!(
1122            match_permission_command("Deny"),
1123            MatchResult::PermissionReply {
1124                reply: PermissionReply::Reject,
1125                ..
1126            }
1127        ));
1128    }
1129
1130    // Question: reject with cancel
1131    #[test]
1132    fn test_question_reject_cancel() {
1133        let q = make_question(&["Yes", "No"], true);
1134        assert_eq!(
1135            match_question_answer("cancel", &q),
1136            MatchResult::QuestionReject
1137        );
1138    }
1139
1140    // Question: reject with nevermind (no space)
1141    #[test]
1142    fn test_question_reject_nevermind() {
1143        let q = make_question(&["Yes", "No"], true);
1144        assert_eq!(
1145            match_question_answer("nevermind", &q),
1146            MatchResult::QuestionReject
1147        );
1148    }
1149
1150    // Question: reject with "none"
1151    #[test]
1152    fn test_question_reject_none() {
1153        let q = make_question(&["Yes", "No"], true);
1154        assert_eq!(
1155            match_question_answer("none", &q),
1156            MatchResult::QuestionReject
1157        );
1158    }
1159
1160    // Question: numeric "two" / "second" selects second option
1161    #[test]
1162    fn test_question_numeric_word_two() {
1163        let q = make_question(&["Alpha", "Beta", "Gamma"], true);
1164        let result = match_question_answer("two", &q);
1165        assert!(
1166            matches!(result, MatchResult::QuestionAnswer { answers } if answers[0] == vec!["Beta"])
1167        );
1168    }
1169
1170    // Question: "option 3" selects third option
1171    #[test]
1172    fn test_question_numeric_option_3() {
1173        let q = make_question(&["Alpha", "Beta", "Gamma"], true);
1174        let result = match_question_answer("option 3", &q);
1175        assert!(
1176            matches!(result, MatchResult::QuestionAnswer { answers } if answers[0] == vec!["Gamma"])
1177        );
1178    }
1179
1180    // Question: out-of-range numeric falls through to custom
1181    #[test]
1182    fn test_question_numeric_out_of_range_with_custom() {
1183        let q = make_question(&["Alpha", "Beta"], true);
1184        // "option 5" is out of range (only 2 options), falls through to custom
1185        let result = match_question_answer("option 5", &q);
1186        assert!(matches!(result, MatchResult::QuestionAnswer { .. }));
1187    }
1188
1189    // Question: out-of-range numeric without custom → NoMatch
1190    #[test]
1191    fn test_question_numeric_out_of_range_no_custom() {
1192        let q = make_question(&["Alpha", "Beta"], false);
1193        // "option 5" is out of range, no custom → NoMatch
1194        let result = match_question_answer("option 5", &q);
1195        assert_eq!(result, MatchResult::NoMatch);
1196    }
1197
1198    // Question: exact label match is case-insensitive (via normalize)
1199    #[test]
1200    fn test_question_exact_label_case_insensitive() {
1201        let q = make_question(&["Continue", "Cancel", "Retry"], false);
1202        let result = match_question_answer("CONTINUE", &q);
1203        assert!(
1204            matches!(result, MatchResult::QuestionAnswer { answers } if answers[0] == vec!["Continue"])
1205        );
1206    }
1207
1208    // Question: custom answer preserves original text (not normalized)
1209    #[test]
1210    fn test_question_custom_answer_preserves_text() {
1211        let q = make_question(&["Yes", "No"], true);
1212        let result = match_question_answer("  My Custom Answer  ", &q);
1213        match result {
1214            MatchResult::QuestionAnswer { answers } => {
1215                // Custom answer uses text.trim()
1216                assert_eq!(answers[0], vec!["My Custom Answer"]);
1217            }
1218            _ => panic!("Expected QuestionAnswer"),
1219        }
1220    }
1221
1222    // NoMatch: random text
1223    #[test]
1224    fn test_no_match_random_text() {
1225        assert_eq!(
1226            match_permission_command("the quick brown fox"),
1227            MatchResult::NoMatch
1228        );
1229    }
1230
1231    // NoMatch: whitespace only
1232    #[test]
1233    fn test_no_match_whitespace_only() {
1234        assert_eq!(match_permission_command("   "), MatchResult::NoMatch);
1235    }
1236}