Skip to main content

awaken_tool_pattern/
matcher.rs

1//! Pattern matching engine for [`ToolCallPattern`].
2//!
3//! Evaluates a pattern against a `(tool_id, tool_args)` pair and returns a
4//! [`MatchResult`] with specificity information for priority-based rule ordering.
5
6use serde_json::Value;
7
8use crate::types::{
9    ArgMatcher, FieldCondition, MatchOp, MatchResult, PathSegment, Specificity, ToolCallPattern,
10    ToolMatcher,
11};
12
13/// Evaluate whether a pattern matches a tool call.
14#[must_use]
15pub fn pattern_matches(pattern: &ToolCallPattern, tool_id: &str, tool_args: &Value) -> MatchResult {
16    // 1. Match tool name
17    let tool_kind = match &pattern.tool {
18        ToolMatcher::Exact(name) => {
19            if name != tool_id {
20                return MatchResult::NoMatch;
21            }
22            3
23        }
24        ToolMatcher::Glob(pat) => {
25            if !wildcard_match(pat, tool_id) {
26                return MatchResult::NoMatch;
27            }
28            2
29        }
30        ToolMatcher::Regex(re) => {
31            if !re.is_match(tool_id) {
32                return MatchResult::NoMatch;
33            }
34            1
35        }
36    };
37
38    // 2. Match arguments
39    match &pattern.args {
40        ArgMatcher::Any => MatchResult::Match {
41            specificity: Specificity {
42                tool_kind,
43                has_args: false,
44                field_count: 0,
45                field_precision: 0,
46            },
47        },
48        ArgMatcher::Primary { op, value } => {
49            let primary_value = infer_primary_value(tool_args);
50            if evaluate_op(op, value, &primary_value) {
51                MatchResult::Match {
52                    specificity: Specificity {
53                        tool_kind,
54                        has_args: true,
55                        field_count: 1,
56                        field_precision: op_precision(op),
57                    },
58                }
59            } else {
60                MatchResult::NoMatch
61            }
62        }
63        ArgMatcher::Fields(conditions) => {
64            let mut field_precision = 0u8;
65            for cond in conditions {
66                if !evaluate_field_condition(cond, tool_args) {
67                    return MatchResult::NoMatch;
68                }
69                field_precision = field_precision.saturating_add(op_precision(&cond.op));
70            }
71            MatchResult::Match {
72                specificity: Specificity {
73                    tool_kind,
74                    has_args: true,
75                    field_count: conditions.len().min(255) as u8,
76                    field_precision,
77                },
78            }
79        }
80    }
81}
82
83// ---------------------------------------------------------------------------
84// Field resolution
85// ---------------------------------------------------------------------------
86
87/// Resolve a dotted path against a JSON value, returning all matching leaf values.
88#[must_use]
89pub fn resolve_path<'a>(value: &'a Value, path: &[PathSegment]) -> Vec<&'a Value> {
90    if path.is_empty() {
91        return vec![value];
92    }
93
94    let Some((head, tail)) = path.split_first() else {
95        return vec![];
96    };
97
98    match head {
99        PathSegment::Field(name) => match value.get(name.as_str()) {
100            Some(child) => resolve_path(child, tail),
101            None => vec![],
102        },
103        PathSegment::Index(i) => match value.as_array().and_then(|arr| arr.get(*i)) {
104            Some(child) => resolve_path(child, tail),
105            None => vec![],
106        },
107        PathSegment::AnyIndex => match value.as_array() {
108            Some(arr) => arr
109                .iter()
110                .flat_map(|elem| resolve_path(elem, tail))
111                .collect(),
112            None => vec![],
113        },
114        PathSegment::Wildcard => match value.as_object() {
115            Some(obj) => obj.values().flat_map(|v| resolve_path(v, tail)).collect(),
116            None => vec![],
117        },
118    }
119}
120
121/// Infer the "primary" field value from tool args.
122///
123/// - If args is an object with exactly one key, use that value as string.
124/// - Otherwise, stringify the whole args.
125fn infer_primary_value(args: &Value) -> String {
126    if let Some(obj) = args.as_object()
127        && obj.len() == 1
128        && let Some(v) = obj.values().next()
129    {
130        return value_to_string(v);
131    }
132    value_to_string(args)
133}
134
135/// Convert a JSON value to a string for matching.
136pub fn value_to_string(v: &Value) -> String {
137    match v {
138        Value::String(s) => s.clone(),
139        Value::Null => String::new(),
140        Value::Bool(b) => b.to_string(),
141        Value::Number(n) => n.to_string(),
142        other => other.to_string(),
143    }
144}
145
146// ---------------------------------------------------------------------------
147// Condition evaluation
148// ---------------------------------------------------------------------------
149
150/// Evaluate a single field condition against a JSON value.
151///
152/// If the field path does not resolve to any value, the condition evaluates
153/// to `false` regardless of operator polarity.
154#[must_use]
155pub fn evaluate_field_condition(cond: &FieldCondition, args: &Value) -> bool {
156    let resolved = resolve_path(args, &cond.path);
157    if resolved.is_empty() {
158        return false;
159    }
160    // Existential: any resolved value matching -> condition passes.
161    resolved
162        .iter()
163        .any(|v| evaluate_op(&cond.op, &cond.value, &value_to_string(v)))
164}
165
166/// Evaluate a match operator against a string value.
167#[must_use]
168pub fn evaluate_op(op: &MatchOp, pattern: &str, value: &str) -> bool {
169    match op {
170        MatchOp::Glob => wildcard_match(pattern, value),
171        MatchOp::Exact => pattern == value,
172        MatchOp::Regex => regex::Regex::new(pattern)
173            .map(|re| re.is_match(value))
174            .unwrap_or(false),
175        MatchOp::NotGlob => !wildcard_match(pattern, value),
176        MatchOp::NotExact => pattern != value,
177        MatchOp::NotRegex => regex::Regex::new(pattern)
178            .map(|re| !re.is_match(value))
179            .unwrap_or(true),
180    }
181}
182
183/// Wildcard match where `*` matches any characters including `/`.
184#[must_use]
185pub fn wildcard_match(pattern: &str, value: &str) -> bool {
186    let normalized = normalize_wildcards(pattern);
187    glob_match::glob_match(&normalized, value)
188}
189
190/// Convert standalone `*` to `**` so glob_match treats them as crossing `/`.
191fn normalize_wildcards(pattern: &str) -> String {
192    let bytes = pattern.as_bytes();
193    let len = bytes.len();
194    let mut result = String::with_capacity(len + 8);
195    let mut i = 0;
196    while i < len {
197        if bytes[i] == b'*' {
198            let start = i;
199            while i < len && bytes[i] == b'*' {
200                i += 1;
201            }
202            let count = i - start;
203            if count >= 2 {
204                for _ in 0..count {
205                    result.push('*');
206                }
207            } else {
208                let preceded_by_globstar = start >= 3 && &bytes[start - 3..start] == b"**/";
209                let followed_by_globstar =
210                    i + 2 < len && &bytes[i..i + 2] == b"/*" && bytes[i + 2] == b'*';
211
212                if preceded_by_globstar || followed_by_globstar {
213                    result.push('*');
214                } else {
215                    result.push_str("**");
216                }
217            }
218        } else {
219            result.push(bytes[i] as char);
220            i += 1;
221        }
222    }
223    result
224}
225
226// ---------------------------------------------------------------------------
227// Schema validation
228// ---------------------------------------------------------------------------
229
230/// Validate that a pattern's field references exist in a tool's JSON Schema.
231#[must_use]
232pub fn validate_pattern_fields(
233    pattern: &ToolCallPattern,
234    parameters_schema: &Value,
235) -> Vec<String> {
236    let conditions = match &pattern.args {
237        ArgMatcher::Any | ArgMatcher::Primary { .. } => return vec![],
238        ArgMatcher::Fields(conditions) => conditions,
239    };
240
241    let mut warnings = Vec::new();
242    for cond in conditions {
243        if !schema_has_path(parameters_schema, &cond.path) {
244            let path_str = cond
245                .path
246                .iter()
247                .map(|s| s.to_string())
248                .collect::<Vec<_>>()
249                .join(".");
250            warnings.push(path_str);
251        }
252    }
253    warnings
254}
255
256/// Check if a JSON Schema has properties along the given path.
257#[must_use]
258pub fn schema_has_path(schema: &Value, path: &[PathSegment]) -> bool {
259    if path.is_empty() {
260        return true;
261    }
262
263    let Some((head, tail)) = path.split_first() else {
264        return true;
265    };
266    match head {
267        PathSegment::Field(name) => {
268            let prop = schema.get("properties").and_then(|p| p.get(name.as_str()));
269            match prop {
270                Some(sub_schema) => schema_has_path(sub_schema, tail),
271                None => schema
272                    .get("additionalProperties")
273                    .is_some_and(|ap| ap.is_object() && schema_has_path(ap, tail)),
274            }
275        }
276        PathSegment::Index(_) | PathSegment::AnyIndex => schema
277            .get("items")
278            .is_some_and(|items| schema_has_path(items, tail)),
279        PathSegment::Wildcard => {
280            if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
281                props.values().any(|sub| schema_has_path(sub, tail))
282            } else {
283                schema
284                    .get("additionalProperties")
285                    .is_some_and(|ap| ap.is_object() && schema_has_path(ap, tail))
286            }
287        }
288    }
289}
290
291/// Precision score for an operator (used in specificity).
292#[must_use]
293pub fn op_precision(op: &MatchOp) -> u8 {
294    match op {
295        MatchOp::Exact | MatchOp::NotExact => 3,
296        MatchOp::Glob | MatchOp::NotGlob => 2,
297        MatchOp::Regex | MatchOp::NotRegex => 1,
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use serde_json::json;
305
306    fn exact(name: &str) -> ToolCallPattern {
307        ToolCallPattern::tool(name)
308    }
309
310    fn primary(name: &str, pat: &str) -> ToolCallPattern {
311        ToolCallPattern::tool_with_primary(name, pat)
312    }
313
314    fn field_rule(name: &str, field: &str, op: MatchOp, value: &str) -> ToolCallPattern {
315        ToolCallPattern {
316            tool: ToolMatcher::Exact(name.into()),
317            args: ArgMatcher::Fields(vec![FieldCondition {
318                path: vec![PathSegment::Field(field.into())],
319                op,
320                value: value.into(),
321            }]),
322        }
323    }
324
325    // --- Tool name matching ---
326
327    #[test]
328    fn exact_tool_matches() {
329        assert!(pattern_matches(&exact("Bash"), "Bash", &json!({})).is_match());
330    }
331
332    #[test]
333    fn exact_tool_no_match() {
334        assert!(!pattern_matches(&exact("Bash"), "Read", &json!({})).is_match());
335    }
336
337    #[test]
338    fn glob_tool_matches() {
339        let p = ToolCallPattern::tool_glob("mcp__github__*");
340        assert!(pattern_matches(&p, "mcp__github__create_issue", &json!({})).is_match());
341        assert!(pattern_matches(&p, "mcp__github__list_repos", &json!({})).is_match());
342        assert!(!pattern_matches(&p, "mcp__slack__post", &json!({})).is_match());
343    }
344
345    #[test]
346    fn regex_tool_matches() {
347        let p = ToolCallPattern {
348            tool: ToolMatcher::Regex(regex::Regex::new(r"mcp__(github|gitlab)__.*").unwrap()),
349            args: ArgMatcher::Any,
350        };
351        assert!(pattern_matches(&p, "mcp__github__create_issue", &json!({})).is_match());
352        assert!(pattern_matches(&p, "mcp__gitlab__merge", &json!({})).is_match());
353        assert!(!pattern_matches(&p, "mcp__slack__post", &json!({})).is_match());
354    }
355
356    // --- Primary arg matching ---
357
358    #[test]
359    fn primary_glob_matches() {
360        let p = primary("Bash", "npm *");
361        assert!(pattern_matches(&p, "Bash", &json!({"command": "npm install"})).is_match());
362        assert!(!pattern_matches(&p, "Bash", &json!({"command": "git status"})).is_match());
363    }
364
365    #[test]
366    fn primary_glob_multi_key_uses_stringify() {
367        let p = primary("Bash", "*npm*");
368        assert!(pattern_matches(&p, "Bash", &json!({"a": "npm", "b": "x"})).is_match());
369        let p2 = primary("Bash", "*cargo*");
370        assert!(!pattern_matches(&p2, "Bash", &json!({"a": "npm", "b": "x"})).is_match());
371    }
372
373    // --- Named field matching ---
374
375    #[test]
376    fn named_field_glob() {
377        let p = field_rule("Edit", "file_path", MatchOp::Glob, "src/**/*.rs");
378        assert!(pattern_matches(&p, "Edit", &json!({"file_path": "src/main.rs"})).is_match());
379        assert!(pattern_matches(&p, "Edit", &json!({"file_path": "src/sub/lib.rs"})).is_match());
380        assert!(!pattern_matches(&p, "Edit", &json!({"file_path": "tests/test.rs"})).is_match());
381    }
382
383    #[test]
384    fn named_field_exact() {
385        let p = field_rule("Bash", "command", MatchOp::Exact, "ls");
386        assert!(pattern_matches(&p, "Bash", &json!({"command": "ls"})).is_match());
387        assert!(!pattern_matches(&p, "Bash", &json!({"command": "ls -la"})).is_match());
388    }
389
390    #[test]
391    fn named_field_regex() {
392        let p = field_rule("Bash", "command", MatchOp::Regex, "(?i)eval|exec");
393        assert!(pattern_matches(&p, "Bash", &json!({"command": "eval foo"})).is_match());
394        assert!(pattern_matches(&p, "Bash", &json!({"command": "EXEC bar"})).is_match());
395        assert!(!pattern_matches(&p, "Bash", &json!({"command": "npm install"})).is_match());
396    }
397
398    #[test]
399    fn named_field_not_glob() {
400        let p = field_rule("Bash", "command", MatchOp::NotGlob, "rm *");
401        assert!(!pattern_matches(&p, "Bash", &json!({"command": "rm -rf /"})).is_match());
402        assert!(pattern_matches(&p, "Bash", &json!({"command": "ls"})).is_match());
403    }
404
405    #[test]
406    fn missing_field_positive_op_no_match() {
407        let p = field_rule("Bash", "command", MatchOp::Glob, "npm *");
408        assert!(!pattern_matches(&p, "Bash", &json!({})).is_match());
409    }
410
411    #[test]
412    fn missing_field_negative_op_no_match() {
413        let p = field_rule("Bash", "command", MatchOp::NotGlob, "rm *");
414        assert!(!pattern_matches(&p, "Bash", &json!({})).is_match());
415    }
416
417    // --- Nested paths ---
418
419    #[test]
420    fn nested_path_dot_notation() {
421        let p = ToolCallPattern {
422            tool: ToolMatcher::Exact("Tool".into()),
423            args: ArgMatcher::Fields(vec![FieldCondition {
424                path: vec![
425                    PathSegment::Field("config".into()),
426                    PathSegment::Field("host".into()),
427                ],
428                op: MatchOp::Exact,
429                value: "localhost".into(),
430            }]),
431        };
432        assert!(pattern_matches(&p, "Tool", &json!({"config": {"host": "localhost"}})).is_match());
433        assert!(!pattern_matches(&p, "Tool", &json!({"config": {"host": "prod"}})).is_match());
434    }
435
436    #[test]
437    fn nested_path_any_index() {
438        let p = ToolCallPattern {
439            tool: ToolMatcher::Exact("Tool".into()),
440            args: ArgMatcher::Fields(vec![FieldCondition {
441                path: vec![
442                    PathSegment::Field("items".into()),
443                    PathSegment::AnyIndex,
444                    PathSegment::Field("name".into()),
445                ],
446                op: MatchOp::Exact,
447                value: "target".into(),
448            }]),
449        };
450        assert!(
451            pattern_matches(
452                &p,
453                "Tool",
454                &json!({"items": [{"name": "other"}, {"name": "target"}]})
455            )
456            .is_match()
457        );
458        assert!(
459            !pattern_matches(
460                &p,
461                "Tool",
462                &json!({"items": [{"name": "a"}, {"name": "b"}]})
463            )
464            .is_match()
465        );
466    }
467
468    #[test]
469    fn specificity_exact_tool_higher_than_glob() {
470        let exact_result = pattern_matches(&exact("Bash"), "Bash", &json!({}));
471        let glob_result = pattern_matches(&ToolCallPattern::tool_glob("Bas*"), "Bash", &json!({}));
472        if let (MatchResult::Match { specificity: a }, MatchResult::Match { specificity: b }) =
473            (&exact_result, &glob_result)
474        {
475            assert!(a > b);
476        } else {
477            panic!("both should match");
478        }
479    }
480
481    #[test]
482    fn specificity_with_args_higher_than_without() {
483        let no_args = pattern_matches(&exact("Bash"), "Bash", &json!({"command": "npm install"}));
484        let with_args = pattern_matches(
485            &primary("Bash", "npm *"),
486            "Bash",
487            &json!({"command": "npm install"}),
488        );
489        if let (MatchResult::Match { specificity: a }, MatchResult::Match { specificity: b }) =
490            (&no_args, &with_args)
491        {
492            assert!(b > a);
493        } else {
494            panic!("both should match");
495        }
496    }
497
498    #[test]
499    fn wildcard_match_crosses_slashes() {
500        assert!(wildcard_match("rm *", "rm -rf /"));
501        assert!(wildcard_match("curl *", "curl https://example.com"));
502        assert!(wildcard_match("npm *", "npm install"));
503        assert!(wildcard_match("mcp__*", "mcp__github__create"));
504        assert!(wildcard_match("rm **", "rm -rf /"));
505        assert!(wildcard_match("src/**/*.rs", "src/main.rs"));
506        assert!(wildcard_match("src/**/*.rs", "src/sub/lib.rs"));
507        assert!(!wildcard_match("src/**/*.rs", "tests/test.rs"));
508    }
509
510    #[test]
511    fn validate_pattern_fields_ok() {
512        let schema = json!({
513            "type": "object",
514            "properties": {
515                "command": { "type": "string" }
516            }
517        });
518        let p = field_rule("Bash", "command", MatchOp::Glob, "npm *");
519        assert!(validate_pattern_fields(&p, &schema).is_empty());
520    }
521
522    #[test]
523    fn validate_pattern_fields_missing() {
524        let schema = json!({
525            "type": "object",
526            "properties": {
527                "file_path": { "type": "string" }
528            }
529        });
530        let p = field_rule("Edit", "command", MatchOp::Glob, "npm *");
531        let warnings = validate_pattern_fields(&p, &schema);
532        assert_eq!(warnings, vec!["command"]);
533    }
534
535    // --- value_to_string ---
536
537    #[test]
538    fn value_to_string_variants() {
539        assert_eq!(value_to_string(&json!("hello")), "hello");
540        assert_eq!(value_to_string(&json!(null)), "");
541        assert_eq!(value_to_string(&json!(true)), "true");
542        assert_eq!(value_to_string(&json!(false)), "false");
543        assert_eq!(value_to_string(&json!(42)), "42");
544        assert_eq!(value_to_string(&json!(2.5)), "2.5");
545        // Array / object fall through to serde stringify
546        assert_eq!(value_to_string(&json!([1, 2])), "[1,2]");
547        assert_eq!(value_to_string(&json!({"a": 1})), "{\"a\":1}");
548    }
549
550    // --- evaluate_op edge cases ---
551
552    #[test]
553    fn evaluate_op_not_exact() {
554        assert!(evaluate_op(&MatchOp::NotExact, "a", "b"));
555        assert!(!evaluate_op(&MatchOp::NotExact, "a", "a"));
556    }
557
558    #[test]
559    fn evaluate_op_not_regex() {
560        assert!(evaluate_op(&MatchOp::NotRegex, "^rm", "ls"));
561        assert!(!evaluate_op(&MatchOp::NotRegex, "^rm", "rm -rf"));
562    }
563
564    #[test]
565    fn evaluate_op_invalid_regex_returns_false() {
566        // Invalid regex pattern for positive match returns false
567        assert!(!evaluate_op(&MatchOp::Regex, "[invalid", "anything"));
568    }
569
570    #[test]
571    fn evaluate_op_invalid_not_regex_returns_true() {
572        // Invalid regex for negated match returns true
573        assert!(evaluate_op(&MatchOp::NotRegex, "[invalid", "anything"));
574    }
575
576    // --- resolve_path edge cases ---
577
578    #[test]
579    fn resolve_path_specific_index() {
580        let val = json!({"items": ["a", "b", "c"]});
581        let path = vec![PathSegment::Field("items".into()), PathSegment::Index(1)];
582        let resolved = resolve_path(&val, &path);
583        assert_eq!(resolved, vec![&json!("b")]);
584    }
585
586    #[test]
587    fn resolve_path_index_out_of_bounds() {
588        let val = json!({"items": ["a"]});
589        let path = vec![PathSegment::Field("items".into()), PathSegment::Index(99)];
590        assert!(resolve_path(&val, &path).is_empty());
591    }
592
593    #[test]
594    fn resolve_path_index_on_non_array() {
595        let val = json!({"items": "not_array"});
596        let path = vec![PathSegment::Field("items".into()), PathSegment::Index(0)];
597        assert!(resolve_path(&val, &path).is_empty());
598    }
599
600    #[test]
601    fn resolve_path_any_index_on_non_array() {
602        let val = json!({"items": "not_array"});
603        let path = vec![PathSegment::Field("items".into()), PathSegment::AnyIndex];
604        assert!(resolve_path(&val, &path).is_empty());
605    }
606
607    #[test]
608    fn resolve_path_wildcard() {
609        let val = json!({"a": {"x": 1}, "b": {"x": 2}});
610        let path = vec![PathSegment::Wildcard, PathSegment::Field("x".into())];
611        let resolved = resolve_path(&val, &path);
612        assert_eq!(resolved.len(), 2);
613    }
614
615    #[test]
616    fn resolve_path_wildcard_on_non_object() {
617        let val = json!("string");
618        let path = vec![PathSegment::Wildcard];
619        assert!(resolve_path(&val, &path).is_empty());
620    }
621
622    #[test]
623    fn resolve_path_empty() {
624        let val = json!({"a": 1});
625        let resolved = resolve_path(&val, &[]);
626        assert_eq!(resolved, vec![&json!({"a": 1})]);
627    }
628
629    #[test]
630    fn resolve_path_missing_field() {
631        let val = json!({"a": 1});
632        let path = vec![PathSegment::Field("b".into())];
633        assert!(resolve_path(&val, &path).is_empty());
634    }
635
636    // --- normalize_wildcards edge cases ---
637
638    #[test]
639    fn normalize_single_star_adjacent_to_globstar() {
640        // */** should keep the first * single since it's followed by globstar
641        assert!(wildcard_match("*/**/*.rs", "src/sub/lib.rs"));
642    }
643
644    #[test]
645    fn normalize_preserves_triple_stars() {
646        // *** should be preserved as-is (count >= 2)
647        assert!(wildcard_match("***", "anything"));
648    }
649
650    // --- schema_has_path edge cases ---
651
652    #[test]
653    fn schema_has_path_additional_properties() {
654        let schema = json!({
655            "type": "object",
656            "additionalProperties": {
657                "type": "string"
658            }
659        });
660        assert!(schema_has_path(
661            &schema,
662            &[PathSegment::Field("anything".into())]
663        ));
664    }
665
666    #[test]
667    fn schema_has_path_additional_properties_false() {
668        // additionalProperties: false (not an object) should not match
669        let schema = json!({
670            "type": "object",
671            "additionalProperties": false
672        });
673        assert!(!schema_has_path(
674            &schema,
675            &[PathSegment::Field("missing".into())]
676        ));
677    }
678
679    #[test]
680    fn schema_has_path_array_items() {
681        let schema = json!({
682            "type": "object",
683            "properties": {
684                "list": {
685                    "type": "array",
686                    "items": {
687                        "type": "object",
688                        "properties": {
689                            "name": { "type": "string" }
690                        }
691                    }
692                }
693            }
694        });
695        assert!(schema_has_path(
696            &schema,
697            &[
698                PathSegment::Field("list".into()),
699                PathSegment::AnyIndex,
700                PathSegment::Field("name".into()),
701            ]
702        ));
703        assert!(schema_has_path(
704            &schema,
705            &[
706                PathSegment::Field("list".into()),
707                PathSegment::Index(0),
708                PathSegment::Field("name".into()),
709            ]
710        ));
711    }
712
713    #[test]
714    fn schema_has_path_no_items() {
715        let schema = json!({
716            "type": "object",
717            "properties": {
718                "list": { "type": "string" }
719            }
720        });
721        assert!(!schema_has_path(
722            &schema,
723            &[PathSegment::Field("list".into()), PathSegment::AnyIndex,]
724        ));
725    }
726
727    #[test]
728    fn schema_has_path_wildcard() {
729        let schema = json!({
730            "type": "object",
731            "properties": {
732                "a": {
733                    "type": "object",
734                    "properties": {
735                        "id": { "type": "string" }
736                    }
737                },
738                "b": {
739                    "type": "object",
740                    "properties": {
741                        "id": { "type": "string" }
742                    }
743                }
744            }
745        });
746        assert!(schema_has_path(
747            &schema,
748            &[PathSegment::Wildcard, PathSegment::Field("id".into())]
749        ));
750    }
751
752    #[test]
753    fn schema_has_path_wildcard_no_properties() {
754        let schema = json!({
755            "type": "object",
756            "additionalProperties": {
757                "type": "object",
758                "properties": {
759                    "name": { "type": "string" }
760                }
761            }
762        });
763        // Wildcard without properties falls back to additionalProperties
764        assert!(schema_has_path(
765            &schema,
766            &[PathSegment::Wildcard, PathSegment::Field("name".into())]
767        ));
768    }
769
770    #[test]
771    fn schema_has_path_wildcard_no_props_no_additional() {
772        let schema = json!({"type": "object"});
773        assert!(!schema_has_path(
774            &schema,
775            &[PathSegment::Wildcard, PathSegment::Field("x".into())]
776        ));
777    }
778
779    // --- validate_pattern_fields edge cases ---
780
781    #[test]
782    fn validate_pattern_fields_any_args() {
783        let schema = json!({"type": "object"});
784        let p = exact("Bash");
785        assert!(validate_pattern_fields(&p, &schema).is_empty());
786    }
787
788    #[test]
789    fn validate_pattern_fields_primary_args() {
790        let schema = json!({"type": "object"});
791        let p = primary("Bash", "npm *");
792        assert!(validate_pattern_fields(&p, &schema).is_empty());
793    }
794
795    // --- op_precision ---
796
797    #[test]
798    fn op_precision_values() {
799        assert_eq!(op_precision(&MatchOp::Exact), 3);
800        assert_eq!(op_precision(&MatchOp::NotExact), 3);
801        assert_eq!(op_precision(&MatchOp::Glob), 2);
802        assert_eq!(op_precision(&MatchOp::NotGlob), 2);
803        assert_eq!(op_precision(&MatchOp::Regex), 1);
804        assert_eq!(op_precision(&MatchOp::NotRegex), 1);
805    }
806
807    // --- Multiple field conditions matching ---
808
809    #[test]
810    fn multiple_field_conditions_all_must_match() {
811        let p = ToolCallPattern {
812            tool: ToolMatcher::Exact("Tool".into()),
813            args: ArgMatcher::Fields(vec![
814                FieldCondition {
815                    path: vec![PathSegment::Field("a".into())],
816                    op: MatchOp::Exact,
817                    value: "1".into(),
818                },
819                FieldCondition {
820                    path: vec![PathSegment::Field("b".into())],
821                    op: MatchOp::Exact,
822                    value: "2".into(),
823                },
824            ]),
825        };
826        assert!(pattern_matches(&p, "Tool", &json!({"a": "1", "b": "2"})).is_match());
827        assert!(!pattern_matches(&p, "Tool", &json!({"a": "1", "b": "3"})).is_match());
828        assert!(!pattern_matches(&p, "Tool", &json!({"a": "1"})).is_match());
829    }
830
831    // --- Named field not_exact and not_regex ---
832
833    #[test]
834    fn named_field_not_exact() {
835        let p = field_rule("Bash", "command", MatchOp::NotExact, "rm");
836        assert!(pattern_matches(&p, "Bash", &json!({"command": "ls"})).is_match());
837        assert!(!pattern_matches(&p, "Bash", &json!({"command": "rm"})).is_match());
838    }
839
840    #[test]
841    fn named_field_not_regex() {
842        let p = field_rule("Bash", "command", MatchOp::NotRegex, "^rm");
843        assert!(pattern_matches(&p, "Bash", &json!({"command": "ls"})).is_match());
844        assert!(!pattern_matches(&p, "Bash", &json!({"command": "rm -rf"})).is_match());
845    }
846
847    // --- infer_primary_value edge cases ---
848
849    #[test]
850    fn infer_primary_from_non_object() {
851        let p = primary("Tool", "*hello*");
852        assert!(pattern_matches(&p, "Tool", &json!("hello world")).is_match());
853    }
854
855    #[test]
856    fn infer_primary_from_multi_key_object() {
857        let p = primary("Tool", "*a*b*");
858        // Multi-key objects get stringified
859        assert!(pattern_matches(&p, "Tool", &json!({"a": 1, "b": 2})).is_match());
860    }
861}