use serde_json::Value;
use crate::types::{
ArgMatcher, FieldCondition, MatchOp, MatchResult, PathSegment, Specificity, ToolCallPattern,
ToolMatcher,
};
#[must_use]
pub fn pattern_matches(pattern: &ToolCallPattern, tool_id: &str, tool_args: &Value) -> MatchResult {
let tool_kind = match &pattern.tool {
ToolMatcher::Exact(name) => {
if name != tool_id {
return MatchResult::NoMatch;
}
3
}
ToolMatcher::Glob(pat) => {
if !wildcard_match(pat, tool_id) {
return MatchResult::NoMatch;
}
2
}
ToolMatcher::Regex(re) => {
if !re.is_match(tool_id) {
return MatchResult::NoMatch;
}
1
}
};
match &pattern.args {
ArgMatcher::Any => MatchResult::Match {
specificity: Specificity {
tool_kind,
has_args: false,
field_count: 0,
field_precision: 0,
},
},
ArgMatcher::Primary { op, value } => {
let primary_value = infer_primary_value(tool_args);
if evaluate_op(op, value, &primary_value) {
MatchResult::Match {
specificity: Specificity {
tool_kind,
has_args: true,
field_count: 1,
field_precision: op_precision(op),
},
}
} else {
MatchResult::NoMatch
}
}
ArgMatcher::Fields(conditions) => {
let mut field_precision = 0u8;
for cond in conditions {
if !evaluate_field_condition(cond, tool_args) {
return MatchResult::NoMatch;
}
field_precision = field_precision.saturating_add(op_precision(&cond.op));
}
MatchResult::Match {
specificity: Specificity {
tool_kind,
has_args: true,
field_count: conditions.len().min(255) as u8,
field_precision,
},
}
}
}
}
#[must_use]
pub fn resolve_path<'a>(value: &'a Value, path: &[PathSegment]) -> Vec<&'a Value> {
if path.is_empty() {
return vec![value];
}
let Some((head, tail)) = path.split_first() else {
return vec![];
};
match head {
PathSegment::Field(name) => match value.get(name.as_str()) {
Some(child) => resolve_path(child, tail),
None => vec![],
},
PathSegment::Index(i) => match value.as_array().and_then(|arr| arr.get(*i)) {
Some(child) => resolve_path(child, tail),
None => vec![],
},
PathSegment::AnyIndex => match value.as_array() {
Some(arr) => arr
.iter()
.flat_map(|elem| resolve_path(elem, tail))
.collect(),
None => vec![],
},
PathSegment::Wildcard => match value.as_object() {
Some(obj) => obj.values().flat_map(|v| resolve_path(v, tail)).collect(),
None => vec![],
},
}
}
fn infer_primary_value(args: &Value) -> String {
if let Some(obj) = args.as_object()
&& obj.len() == 1
&& let Some(v) = obj.values().next()
{
return value_to_string(v);
}
value_to_string(args)
}
pub fn value_to_string(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
Value::Null => String::new(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
other => other.to_string(),
}
}
#[must_use]
pub fn evaluate_field_condition(cond: &FieldCondition, args: &Value) -> bool {
let resolved = resolve_path(args, &cond.path);
if resolved.is_empty() {
return false;
}
resolved
.iter()
.any(|v| evaluate_op(&cond.op, &cond.value, &value_to_string(v)))
}
#[must_use]
pub fn evaluate_op(op: &MatchOp, pattern: &str, value: &str) -> bool {
match op {
MatchOp::Glob => wildcard_match(pattern, value),
MatchOp::Exact => pattern == value,
MatchOp::Regex => regex::Regex::new(pattern)
.map(|re| re.is_match(value))
.unwrap_or(false),
MatchOp::NotGlob => !wildcard_match(pattern, value),
MatchOp::NotExact => pattern != value,
MatchOp::NotRegex => regex::Regex::new(pattern)
.map(|re| !re.is_match(value))
.unwrap_or(true),
}
}
#[must_use]
pub fn wildcard_match(pattern: &str, value: &str) -> bool {
let normalized = normalize_wildcards(pattern);
glob_match::glob_match(&normalized, value)
}
fn normalize_wildcards(pattern: &str) -> String {
let bytes = pattern.as_bytes();
let len = bytes.len();
let mut result = String::with_capacity(len + 8);
let mut i = 0;
while i < len {
if bytes[i] == b'*' {
let start = i;
while i < len && bytes[i] == b'*' {
i += 1;
}
let count = i - start;
if count >= 2 {
for _ in 0..count {
result.push('*');
}
} else {
let preceded_by_globstar = start >= 3 && &bytes[start - 3..start] == b"**/";
let followed_by_globstar =
i + 2 < len && &bytes[i..i + 2] == b"/*" && bytes[i + 2] == b'*';
if preceded_by_globstar || followed_by_globstar {
result.push('*');
} else {
result.push_str("**");
}
}
} else {
result.push(bytes[i] as char);
i += 1;
}
}
result
}
#[must_use]
pub fn validate_pattern_fields(
pattern: &ToolCallPattern,
parameters_schema: &Value,
) -> Vec<String> {
let conditions = match &pattern.args {
ArgMatcher::Any | ArgMatcher::Primary { .. } => return vec![],
ArgMatcher::Fields(conditions) => conditions,
};
let mut warnings = Vec::new();
for cond in conditions {
if !schema_has_path(parameters_schema, &cond.path) {
let path_str = cond
.path
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>()
.join(".");
warnings.push(path_str);
}
}
warnings
}
#[must_use]
pub fn schema_has_path(schema: &Value, path: &[PathSegment]) -> bool {
if path.is_empty() {
return true;
}
let Some((head, tail)) = path.split_first() else {
return true;
};
match head {
PathSegment::Field(name) => {
let prop = schema.get("properties").and_then(|p| p.get(name.as_str()));
match prop {
Some(sub_schema) => schema_has_path(sub_schema, tail),
None => schema
.get("additionalProperties")
.is_some_and(|ap| ap.is_object() && schema_has_path(ap, tail)),
}
}
PathSegment::Index(_) | PathSegment::AnyIndex => schema
.get("items")
.is_some_and(|items| schema_has_path(items, tail)),
PathSegment::Wildcard => {
if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
props.values().any(|sub| schema_has_path(sub, tail))
} else {
schema
.get("additionalProperties")
.is_some_and(|ap| ap.is_object() && schema_has_path(ap, tail))
}
}
}
}
#[must_use]
pub fn op_precision(op: &MatchOp) -> u8 {
match op {
MatchOp::Exact | MatchOp::NotExact => 3,
MatchOp::Glob | MatchOp::NotGlob => 2,
MatchOp::Regex | MatchOp::NotRegex => 1,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn exact(name: &str) -> ToolCallPattern {
ToolCallPattern::tool(name)
}
fn primary(name: &str, pat: &str) -> ToolCallPattern {
ToolCallPattern::tool_with_primary(name, pat)
}
fn field_rule(name: &str, field: &str, op: MatchOp, value: &str) -> ToolCallPattern {
ToolCallPattern {
tool: ToolMatcher::Exact(name.into()),
args: ArgMatcher::Fields(vec![FieldCondition {
path: vec![PathSegment::Field(field.into())],
op,
value: value.into(),
}]),
}
}
#[test]
fn exact_tool_matches() {
assert!(pattern_matches(&exact("Bash"), "Bash", &json!({})).is_match());
}
#[test]
fn exact_tool_no_match() {
assert!(!pattern_matches(&exact("Bash"), "Read", &json!({})).is_match());
}
#[test]
fn glob_tool_matches() {
let p = ToolCallPattern::tool_glob("mcp__github__*");
assert!(pattern_matches(&p, "mcp__github__create_issue", &json!({})).is_match());
assert!(pattern_matches(&p, "mcp__github__list_repos", &json!({})).is_match());
assert!(!pattern_matches(&p, "mcp__slack__post", &json!({})).is_match());
}
#[test]
fn regex_tool_matches() {
let p = ToolCallPattern {
tool: ToolMatcher::Regex(regex::Regex::new(r"mcp__(github|gitlab)__.*").unwrap()),
args: ArgMatcher::Any,
};
assert!(pattern_matches(&p, "mcp__github__create_issue", &json!({})).is_match());
assert!(pattern_matches(&p, "mcp__gitlab__merge", &json!({})).is_match());
assert!(!pattern_matches(&p, "mcp__slack__post", &json!({})).is_match());
}
#[test]
fn primary_glob_matches() {
let p = primary("Bash", "npm *");
assert!(pattern_matches(&p, "Bash", &json!({"command": "npm install"})).is_match());
assert!(!pattern_matches(&p, "Bash", &json!({"command": "git status"})).is_match());
}
#[test]
fn primary_glob_multi_key_uses_stringify() {
let p = primary("Bash", "*npm*");
assert!(pattern_matches(&p, "Bash", &json!({"a": "npm", "b": "x"})).is_match());
let p2 = primary("Bash", "*cargo*");
assert!(!pattern_matches(&p2, "Bash", &json!({"a": "npm", "b": "x"})).is_match());
}
#[test]
fn named_field_glob() {
let p = field_rule("Edit", "file_path", MatchOp::Glob, "src/**/*.rs");
assert!(pattern_matches(&p, "Edit", &json!({"file_path": "src/main.rs"})).is_match());
assert!(pattern_matches(&p, "Edit", &json!({"file_path": "src/sub/lib.rs"})).is_match());
assert!(!pattern_matches(&p, "Edit", &json!({"file_path": "tests/test.rs"})).is_match());
}
#[test]
fn named_field_exact() {
let p = field_rule("Bash", "command", MatchOp::Exact, "ls");
assert!(pattern_matches(&p, "Bash", &json!({"command": "ls"})).is_match());
assert!(!pattern_matches(&p, "Bash", &json!({"command": "ls -la"})).is_match());
}
#[test]
fn named_field_regex() {
let p = field_rule("Bash", "command", MatchOp::Regex, "(?i)eval|exec");
assert!(pattern_matches(&p, "Bash", &json!({"command": "eval foo"})).is_match());
assert!(pattern_matches(&p, "Bash", &json!({"command": "EXEC bar"})).is_match());
assert!(!pattern_matches(&p, "Bash", &json!({"command": "npm install"})).is_match());
}
#[test]
fn named_field_not_glob() {
let p = field_rule("Bash", "command", MatchOp::NotGlob, "rm *");
assert!(!pattern_matches(&p, "Bash", &json!({"command": "rm -rf /"})).is_match());
assert!(pattern_matches(&p, "Bash", &json!({"command": "ls"})).is_match());
}
#[test]
fn missing_field_positive_op_no_match() {
let p = field_rule("Bash", "command", MatchOp::Glob, "npm *");
assert!(!pattern_matches(&p, "Bash", &json!({})).is_match());
}
#[test]
fn missing_field_negative_op_no_match() {
let p = field_rule("Bash", "command", MatchOp::NotGlob, "rm *");
assert!(!pattern_matches(&p, "Bash", &json!({})).is_match());
}
#[test]
fn nested_path_dot_notation() {
let p = ToolCallPattern {
tool: ToolMatcher::Exact("Tool".into()),
args: ArgMatcher::Fields(vec![FieldCondition {
path: vec![
PathSegment::Field("config".into()),
PathSegment::Field("host".into()),
],
op: MatchOp::Exact,
value: "localhost".into(),
}]),
};
assert!(pattern_matches(&p, "Tool", &json!({"config": {"host": "localhost"}})).is_match());
assert!(!pattern_matches(&p, "Tool", &json!({"config": {"host": "prod"}})).is_match());
}
#[test]
fn nested_path_any_index() {
let p = ToolCallPattern {
tool: ToolMatcher::Exact("Tool".into()),
args: ArgMatcher::Fields(vec![FieldCondition {
path: vec![
PathSegment::Field("items".into()),
PathSegment::AnyIndex,
PathSegment::Field("name".into()),
],
op: MatchOp::Exact,
value: "target".into(),
}]),
};
assert!(
pattern_matches(
&p,
"Tool",
&json!({"items": [{"name": "other"}, {"name": "target"}]})
)
.is_match()
);
assert!(
!pattern_matches(
&p,
"Tool",
&json!({"items": [{"name": "a"}, {"name": "b"}]})
)
.is_match()
);
}
#[test]
fn specificity_exact_tool_higher_than_glob() {
let exact_result = pattern_matches(&exact("Bash"), "Bash", &json!({}));
let glob_result = pattern_matches(&ToolCallPattern::tool_glob("Bas*"), "Bash", &json!({}));
if let (MatchResult::Match { specificity: a }, MatchResult::Match { specificity: b }) =
(&exact_result, &glob_result)
{
assert!(a > b);
} else {
panic!("both should match");
}
}
#[test]
fn specificity_with_args_higher_than_without() {
let no_args = pattern_matches(&exact("Bash"), "Bash", &json!({"command": "npm install"}));
let with_args = pattern_matches(
&primary("Bash", "npm *"),
"Bash",
&json!({"command": "npm install"}),
);
if let (MatchResult::Match { specificity: a }, MatchResult::Match { specificity: b }) =
(&no_args, &with_args)
{
assert!(b > a);
} else {
panic!("both should match");
}
}
#[test]
fn wildcard_match_crosses_slashes() {
assert!(wildcard_match("rm *", "rm -rf /"));
assert!(wildcard_match("curl *", "curl https://example.com"));
assert!(wildcard_match("npm *", "npm install"));
assert!(wildcard_match("mcp__*", "mcp__github__create"));
assert!(wildcard_match("rm **", "rm -rf /"));
assert!(wildcard_match("src/**/*.rs", "src/main.rs"));
assert!(wildcard_match("src/**/*.rs", "src/sub/lib.rs"));
assert!(!wildcard_match("src/**/*.rs", "tests/test.rs"));
}
#[test]
fn validate_pattern_fields_ok() {
let schema = json!({
"type": "object",
"properties": {
"command": { "type": "string" }
}
});
let p = field_rule("Bash", "command", MatchOp::Glob, "npm *");
assert!(validate_pattern_fields(&p, &schema).is_empty());
}
#[test]
fn validate_pattern_fields_missing() {
let schema = json!({
"type": "object",
"properties": {
"file_path": { "type": "string" }
}
});
let p = field_rule("Edit", "command", MatchOp::Glob, "npm *");
let warnings = validate_pattern_fields(&p, &schema);
assert_eq!(warnings, vec!["command"]);
}
#[test]
fn value_to_string_variants() {
assert_eq!(value_to_string(&json!("hello")), "hello");
assert_eq!(value_to_string(&json!(null)), "");
assert_eq!(value_to_string(&json!(true)), "true");
assert_eq!(value_to_string(&json!(false)), "false");
assert_eq!(value_to_string(&json!(42)), "42");
assert_eq!(value_to_string(&json!(2.5)), "2.5");
assert_eq!(value_to_string(&json!([1, 2])), "[1,2]");
assert_eq!(value_to_string(&json!({"a": 1})), "{\"a\":1}");
}
#[test]
fn evaluate_op_not_exact() {
assert!(evaluate_op(&MatchOp::NotExact, "a", "b"));
assert!(!evaluate_op(&MatchOp::NotExact, "a", "a"));
}
#[test]
fn evaluate_op_not_regex() {
assert!(evaluate_op(&MatchOp::NotRegex, "^rm", "ls"));
assert!(!evaluate_op(&MatchOp::NotRegex, "^rm", "rm -rf"));
}
#[test]
fn evaluate_op_invalid_regex_returns_false() {
assert!(!evaluate_op(&MatchOp::Regex, "[invalid", "anything"));
}
#[test]
fn evaluate_op_invalid_not_regex_returns_true() {
assert!(evaluate_op(&MatchOp::NotRegex, "[invalid", "anything"));
}
#[test]
fn resolve_path_specific_index() {
let val = json!({"items": ["a", "b", "c"]});
let path = vec![PathSegment::Field("items".into()), PathSegment::Index(1)];
let resolved = resolve_path(&val, &path);
assert_eq!(resolved, vec![&json!("b")]);
}
#[test]
fn resolve_path_index_out_of_bounds() {
let val = json!({"items": ["a"]});
let path = vec![PathSegment::Field("items".into()), PathSegment::Index(99)];
assert!(resolve_path(&val, &path).is_empty());
}
#[test]
fn resolve_path_index_on_non_array() {
let val = json!({"items": "not_array"});
let path = vec![PathSegment::Field("items".into()), PathSegment::Index(0)];
assert!(resolve_path(&val, &path).is_empty());
}
#[test]
fn resolve_path_any_index_on_non_array() {
let val = json!({"items": "not_array"});
let path = vec![PathSegment::Field("items".into()), PathSegment::AnyIndex];
assert!(resolve_path(&val, &path).is_empty());
}
#[test]
fn resolve_path_wildcard() {
let val = json!({"a": {"x": 1}, "b": {"x": 2}});
let path = vec![PathSegment::Wildcard, PathSegment::Field("x".into())];
let resolved = resolve_path(&val, &path);
assert_eq!(resolved.len(), 2);
}
#[test]
fn resolve_path_wildcard_on_non_object() {
let val = json!("string");
let path = vec![PathSegment::Wildcard];
assert!(resolve_path(&val, &path).is_empty());
}
#[test]
fn resolve_path_empty() {
let val = json!({"a": 1});
let resolved = resolve_path(&val, &[]);
assert_eq!(resolved, vec![&json!({"a": 1})]);
}
#[test]
fn resolve_path_missing_field() {
let val = json!({"a": 1});
let path = vec![PathSegment::Field("b".into())];
assert!(resolve_path(&val, &path).is_empty());
}
#[test]
fn normalize_single_star_adjacent_to_globstar() {
assert!(wildcard_match("*/**/*.rs", "src/sub/lib.rs"));
}
#[test]
fn normalize_preserves_triple_stars() {
assert!(wildcard_match("***", "anything"));
}
#[test]
fn schema_has_path_additional_properties() {
let schema = json!({
"type": "object",
"additionalProperties": {
"type": "string"
}
});
assert!(schema_has_path(
&schema,
&[PathSegment::Field("anything".into())]
));
}
#[test]
fn schema_has_path_additional_properties_false() {
let schema = json!({
"type": "object",
"additionalProperties": false
});
assert!(!schema_has_path(
&schema,
&[PathSegment::Field("missing".into())]
));
}
#[test]
fn schema_has_path_array_items() {
let schema = json!({
"type": "object",
"properties": {
"list": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" }
}
}
}
}
});
assert!(schema_has_path(
&schema,
&[
PathSegment::Field("list".into()),
PathSegment::AnyIndex,
PathSegment::Field("name".into()),
]
));
assert!(schema_has_path(
&schema,
&[
PathSegment::Field("list".into()),
PathSegment::Index(0),
PathSegment::Field("name".into()),
]
));
}
#[test]
fn schema_has_path_no_items() {
let schema = json!({
"type": "object",
"properties": {
"list": { "type": "string" }
}
});
assert!(!schema_has_path(
&schema,
&[PathSegment::Field("list".into()), PathSegment::AnyIndex,]
));
}
#[test]
fn schema_has_path_wildcard() {
let schema = json!({
"type": "object",
"properties": {
"a": {
"type": "object",
"properties": {
"id": { "type": "string" }
}
},
"b": {
"type": "object",
"properties": {
"id": { "type": "string" }
}
}
}
});
assert!(schema_has_path(
&schema,
&[PathSegment::Wildcard, PathSegment::Field("id".into())]
));
}
#[test]
fn schema_has_path_wildcard_no_properties() {
let schema = json!({
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"name": { "type": "string" }
}
}
});
assert!(schema_has_path(
&schema,
&[PathSegment::Wildcard, PathSegment::Field("name".into())]
));
}
#[test]
fn schema_has_path_wildcard_no_props_no_additional() {
let schema = json!({"type": "object"});
assert!(!schema_has_path(
&schema,
&[PathSegment::Wildcard, PathSegment::Field("x".into())]
));
}
#[test]
fn validate_pattern_fields_any_args() {
let schema = json!({"type": "object"});
let p = exact("Bash");
assert!(validate_pattern_fields(&p, &schema).is_empty());
}
#[test]
fn validate_pattern_fields_primary_args() {
let schema = json!({"type": "object"});
let p = primary("Bash", "npm *");
assert!(validate_pattern_fields(&p, &schema).is_empty());
}
#[test]
fn op_precision_values() {
assert_eq!(op_precision(&MatchOp::Exact), 3);
assert_eq!(op_precision(&MatchOp::NotExact), 3);
assert_eq!(op_precision(&MatchOp::Glob), 2);
assert_eq!(op_precision(&MatchOp::NotGlob), 2);
assert_eq!(op_precision(&MatchOp::Regex), 1);
assert_eq!(op_precision(&MatchOp::NotRegex), 1);
}
#[test]
fn multiple_field_conditions_all_must_match() {
let p = ToolCallPattern {
tool: ToolMatcher::Exact("Tool".into()),
args: ArgMatcher::Fields(vec![
FieldCondition {
path: vec![PathSegment::Field("a".into())],
op: MatchOp::Exact,
value: "1".into(),
},
FieldCondition {
path: vec![PathSegment::Field("b".into())],
op: MatchOp::Exact,
value: "2".into(),
},
]),
};
assert!(pattern_matches(&p, "Tool", &json!({"a": "1", "b": "2"})).is_match());
assert!(!pattern_matches(&p, "Tool", &json!({"a": "1", "b": "3"})).is_match());
assert!(!pattern_matches(&p, "Tool", &json!({"a": "1"})).is_match());
}
#[test]
fn named_field_not_exact() {
let p = field_rule("Bash", "command", MatchOp::NotExact, "rm");
assert!(pattern_matches(&p, "Bash", &json!({"command": "ls"})).is_match());
assert!(!pattern_matches(&p, "Bash", &json!({"command": "rm"})).is_match());
}
#[test]
fn named_field_not_regex() {
let p = field_rule("Bash", "command", MatchOp::NotRegex, "^rm");
assert!(pattern_matches(&p, "Bash", &json!({"command": "ls"})).is_match());
assert!(!pattern_matches(&p, "Bash", &json!({"command": "rm -rf"})).is_match());
}
#[test]
fn infer_primary_from_non_object() {
let p = primary("Tool", "*hello*");
assert!(pattern_matches(&p, "Tool", &json!("hello world")).is_match());
}
#[test]
fn infer_primary_from_multi_key_object() {
let p = primary("Tool", "*a*b*");
assert!(pattern_matches(&p, "Tool", &json!({"a": 1, "b": 2})).is_match());
}
}