1use std::fmt;
4
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum PathSegment {
14 Field(String),
16 Index(usize),
18 AnyIndex,
20 Wildcard,
22}
23
24impl fmt::Display for PathSegment {
25 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26 match self {
27 Self::Field(name) => write!(f, "{name}"),
28 Self::Index(i) => write!(f, "[{i}]"),
29 Self::AnyIndex => write!(f, "[*]"),
30 Self::Wildcard => write!(f, "*"),
31 }
32 }
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41pub enum MatchOp {
42 #[serde(rename = "glob")]
44 Glob,
45 #[serde(rename = "exact")]
47 Exact,
48 #[serde(rename = "regex")]
50 Regex,
51 #[serde(rename = "not_glob")]
53 NotGlob,
54 #[serde(rename = "not_exact")]
56 NotExact,
57 #[serde(rename = "not_regex")]
59 NotRegex,
60}
61
62impl fmt::Display for MatchOp {
63 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64 match self {
65 Self::Glob => write!(f, "~"),
66 Self::Exact => write!(f, "="),
67 Self::Regex => write!(f, "=~"),
68 Self::NotGlob => write!(f, "!~"),
69 Self::NotExact => write!(f, "!="),
70 Self::NotRegex => write!(f, "!=~"),
71 }
72 }
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct FieldCondition {
82 pub path: Vec<PathSegment>,
84 pub op: MatchOp,
86 pub value: String,
88}
89
90impl fmt::Display for FieldCondition {
91 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92 for (i, seg) in self.path.iter().enumerate() {
93 match seg {
94 PathSegment::Index(_) | PathSegment::AnyIndex => {
95 write!(f, "{seg}")?;
96 }
97 _ => {
98 if i > 0 {
99 write!(f, ".")?;
100 }
101 write!(f, "{seg}")?;
102 }
103 }
104 }
105 write!(f, " {} \"{}\"", self.op, self.value)
106 }
107}
108
109#[derive(Debug, Clone)]
115pub enum ToolMatcher {
116 Exact(String),
118 Glob(String),
120 Regex(regex::Regex),
122}
123
124impl PartialEq for ToolMatcher {
125 fn eq(&self, other: &Self) -> bool {
126 match (self, other) {
127 (Self::Exact(a), Self::Exact(b)) => a == b,
128 (Self::Glob(a), Self::Glob(b)) => a == b,
129 (Self::Regex(a), Self::Regex(b)) => a.as_str() == b.as_str(),
130 _ => false,
131 }
132 }
133}
134
135impl Eq for ToolMatcher {}
136
137impl fmt::Display for ToolMatcher {
138 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139 match self {
140 Self::Exact(s) | Self::Glob(s) => write!(f, "{s}"),
141 Self::Regex(re) => write!(f, "/{}/", re.as_str()),
142 }
143 }
144}
145
146#[derive(Debug, Clone, PartialEq, Eq)]
152pub enum ArgMatcher {
153 Any,
155 Primary { op: MatchOp, value: String },
157 Fields(Vec<FieldCondition>),
159}
160
161impl fmt::Display for ArgMatcher {
162 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
163 match self {
164 Self::Any => write!(f, "*"),
165 Self::Primary { op, value } => match op {
166 MatchOp::Glob => write!(f, "{value}"),
167 _ => write!(f, "{op} \"{value}\""),
168 },
169 Self::Fields(conditions) => {
170 for (i, cond) in conditions.iter().enumerate() {
171 if i > 0 {
172 write!(f, ", ")?;
173 }
174 write!(f, "{cond}")?;
175 }
176 Ok(())
177 }
178 }
179 }
180}
181
182#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct ToolCallPattern {
189 pub tool: ToolMatcher,
191 pub args: ArgMatcher,
193}
194
195impl fmt::Display for ToolCallPattern {
196 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
197 write!(f, "{}", self.tool)?;
198 match &self.args {
199 ArgMatcher::Any => Ok(()),
200 other => write!(f, "({other})"),
201 }
202 }
203}
204
205impl ToolCallPattern {
206 #[must_use]
217 pub fn tool(name: impl Into<String>) -> Self {
218 Self {
219 tool: ToolMatcher::Exact(name.into()),
220 args: ArgMatcher::Any,
221 }
222 }
223
224 #[must_use]
226 pub fn tool_with_primary(name: impl Into<String>, pattern: impl Into<String>) -> Self {
227 Self {
228 tool: ToolMatcher::Exact(name.into()),
229 args: ArgMatcher::Primary {
230 op: MatchOp::Glob,
231 value: pattern.into(),
232 },
233 }
234 }
235
236 #[must_use]
238 pub fn tool_glob(pattern: impl Into<String>) -> Self {
239 Self {
240 tool: ToolMatcher::Glob(pattern.into()),
241 args: ArgMatcher::Any,
242 }
243 }
244
245 #[must_use]
247 pub fn with_args(mut self, args: ArgMatcher) -> Self {
248 self.args = args;
249 self
250 }
251}
252
253#[derive(Debug, Clone, PartialEq, Eq)]
259pub enum MatchResult {
260 NoMatch,
262 Match { specificity: Specificity },
264}
265
266impl MatchResult {
267 #[must_use]
268 pub fn is_match(&self) -> bool {
269 matches!(self, Self::Match { .. })
270 }
271}
272
273#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
279pub struct Specificity {
280 pub tool_kind: u8,
282 pub has_args: bool,
284 pub field_count: u8,
286 pub field_precision: u8,
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293
294 #[test]
299 fn path_segment_display_field() {
300 assert_eq!(PathSegment::Field("name".into()).to_string(), "name");
301 }
302
303 #[test]
304 fn path_segment_display_index() {
305 assert_eq!(PathSegment::Index(3).to_string(), "[3]");
306 }
307
308 #[test]
309 fn path_segment_display_any_index() {
310 assert_eq!(PathSegment::AnyIndex.to_string(), "[*]");
311 }
312
313 #[test]
314 fn path_segment_display_wildcard() {
315 assert_eq!(PathSegment::Wildcard.to_string(), "*");
316 }
317
318 #[test]
323 fn match_op_display() {
324 assert_eq!(MatchOp::Glob.to_string(), "~");
325 assert_eq!(MatchOp::Exact.to_string(), "=");
326 assert_eq!(MatchOp::Regex.to_string(), "=~");
327 assert_eq!(MatchOp::NotGlob.to_string(), "!~");
328 assert_eq!(MatchOp::NotExact.to_string(), "!=");
329 assert_eq!(MatchOp::NotRegex.to_string(), "!=~");
330 }
331
332 #[test]
337 fn field_condition_display_simple() {
338 let cond = FieldCondition {
339 path: vec![PathSegment::Field("command".into())],
340 op: MatchOp::Glob,
341 value: "npm *".into(),
342 };
343 assert_eq!(cond.to_string(), "command ~ \"npm *\"");
344 }
345
346 #[test]
347 fn field_condition_display_nested_with_index() {
348 let cond = FieldCondition {
349 path: vec![
350 PathSegment::Field("items".into()),
351 PathSegment::Index(0),
352 PathSegment::Field("name".into()),
353 ],
354 op: MatchOp::Exact,
355 value: "foo".into(),
356 };
357 assert_eq!(cond.to_string(), "items[0].name = \"foo\"");
358 }
359
360 #[test]
361 fn field_condition_display_any_index() {
362 let cond = FieldCondition {
363 path: vec![
364 PathSegment::Field("arr".into()),
365 PathSegment::AnyIndex,
366 PathSegment::Field("val".into()),
367 ],
368 op: MatchOp::Regex,
369 value: ".*test.*".into(),
370 };
371 assert_eq!(cond.to_string(), "arr[*].val =~ \".*test.*\"");
372 }
373
374 #[test]
375 fn field_condition_display_wildcard_path() {
376 let cond = FieldCondition {
377 path: vec![PathSegment::Wildcard, PathSegment::Field("id".into())],
378 op: MatchOp::NotExact,
379 value: "secret".into(),
380 };
381 assert_eq!(cond.to_string(), "*.id != \"secret\"");
382 }
383
384 #[test]
385 fn field_condition_display_not_glob() {
386 let cond = FieldCondition {
387 path: vec![PathSegment::Field("cmd".into())],
388 op: MatchOp::NotGlob,
389 value: "rm *".into(),
390 };
391 assert_eq!(cond.to_string(), "cmd !~ \"rm *\"");
392 }
393
394 #[test]
395 fn field_condition_display_not_regex() {
396 let cond = FieldCondition {
397 path: vec![PathSegment::Field("cmd".into())],
398 op: MatchOp::NotRegex,
399 value: "^evil".into(),
400 };
401 assert_eq!(cond.to_string(), "cmd !=~ \"^evil\"");
402 }
403
404 #[test]
409 fn tool_matcher_display_exact() {
410 assert_eq!(ToolMatcher::Exact("Bash".into()).to_string(), "Bash");
411 }
412
413 #[test]
414 fn tool_matcher_display_glob() {
415 assert_eq!(ToolMatcher::Glob("mcp__*".into()).to_string(), "mcp__*");
416 }
417
418 #[test]
419 fn tool_matcher_display_regex() {
420 let re = regex::Regex::new(r"foo|bar").unwrap();
421 assert_eq!(ToolMatcher::Regex(re).to_string(), "/foo|bar/");
422 }
423
424 #[test]
425 fn tool_matcher_eq_exact() {
426 assert_eq!(
427 ToolMatcher::Exact("A".into()),
428 ToolMatcher::Exact("A".into())
429 );
430 assert_ne!(
431 ToolMatcher::Exact("A".into()),
432 ToolMatcher::Exact("B".into())
433 );
434 }
435
436 #[test]
437 fn tool_matcher_eq_glob() {
438 assert_eq!(ToolMatcher::Glob("*".into()), ToolMatcher::Glob("*".into()));
439 assert_ne!(
440 ToolMatcher::Glob("a*".into()),
441 ToolMatcher::Glob("b*".into())
442 );
443 }
444
445 #[test]
446 fn tool_matcher_eq_regex() {
447 let r1 = regex::Regex::new("abc").unwrap();
448 let r2 = regex::Regex::new("abc").unwrap();
449 let r3 = regex::Regex::new("def").unwrap();
450 assert_eq!(ToolMatcher::Regex(r1), ToolMatcher::Regex(r2));
451 assert_ne!(
452 ToolMatcher::Regex(r3),
453 ToolMatcher::Regex(regex::Regex::new("abc").unwrap())
454 );
455 }
456
457 #[test]
458 fn tool_matcher_eq_cross_variant() {
459 assert_ne!(
460 ToolMatcher::Exact("foo".into()),
461 ToolMatcher::Glob("foo".into())
462 );
463 assert_ne!(
464 ToolMatcher::Glob("foo".into()),
465 ToolMatcher::Regex(regex::Regex::new("foo").unwrap())
466 );
467 assert_ne!(
468 ToolMatcher::Exact("foo".into()),
469 ToolMatcher::Regex(regex::Regex::new("foo").unwrap())
470 );
471 }
472
473 #[test]
478 fn arg_matcher_display_any() {
479 assert_eq!(ArgMatcher::Any.to_string(), "*");
480 }
481
482 #[test]
483 fn arg_matcher_display_primary_glob() {
484 let m = ArgMatcher::Primary {
485 op: MatchOp::Glob,
486 value: "npm *".into(),
487 };
488 assert_eq!(m.to_string(), "npm *");
489 }
490
491 #[test]
492 fn arg_matcher_display_primary_non_glob() {
493 let m = ArgMatcher::Primary {
494 op: MatchOp::Exact,
495 value: "ls".into(),
496 };
497 assert_eq!(m.to_string(), "= \"ls\"");
498 }
499
500 #[test]
501 fn arg_matcher_display_primary_regex() {
502 let m = ArgMatcher::Primary {
503 op: MatchOp::Regex,
504 value: "^npm".into(),
505 };
506 assert_eq!(m.to_string(), "=~ \"^npm\"");
507 }
508
509 #[test]
510 fn arg_matcher_display_fields_single() {
511 let m = ArgMatcher::Fields(vec![FieldCondition {
512 path: vec![PathSegment::Field("cmd".into())],
513 op: MatchOp::Glob,
514 value: "npm *".into(),
515 }]);
516 assert_eq!(m.to_string(), "cmd ~ \"npm *\"");
517 }
518
519 #[test]
520 fn arg_matcher_display_fields_multiple() {
521 let m = ArgMatcher::Fields(vec![
522 FieldCondition {
523 path: vec![PathSegment::Field("f1".into())],
524 op: MatchOp::Glob,
525 value: "a*".into(),
526 },
527 FieldCondition {
528 path: vec![PathSegment::Field("f2".into())],
529 op: MatchOp::Exact,
530 value: "b".into(),
531 },
532 ]);
533 assert_eq!(m.to_string(), "f1 ~ \"a*\", f2 = \"b\"");
534 }
535
536 #[test]
541 fn pattern_display_no_args() {
542 let p = ToolCallPattern::tool("Bash");
543 assert_eq!(p.to_string(), "Bash");
544 }
545
546 #[test]
547 fn pattern_display_with_primary() {
548 let p = ToolCallPattern::tool_with_primary("Bash", "npm *");
549 assert_eq!(p.to_string(), "Bash(npm *)");
550 }
551
552 #[test]
553 fn pattern_display_with_fields() {
554 let p = ToolCallPattern {
555 tool: ToolMatcher::Exact("Edit".into()),
556 args: ArgMatcher::Fields(vec![FieldCondition {
557 path: vec![PathSegment::Field("file_path".into())],
558 op: MatchOp::Glob,
559 value: "src/**".into(),
560 }]),
561 };
562 assert_eq!(p.to_string(), "Edit(file_path ~ \"src/**\")");
563 }
564
565 #[test]
566 fn pattern_display_regex_tool() {
567 let p = ToolCallPattern {
568 tool: ToolMatcher::Regex(regex::Regex::new(r"mcp__.*").unwrap()),
569 args: ArgMatcher::Any,
570 };
571 assert_eq!(p.to_string(), "/mcp__.*/");
572 }
573
574 #[test]
575 fn tool_glob_builder() {
576 let p = ToolCallPattern::tool_glob("mcp__*");
577 assert_eq!(p.tool, ToolMatcher::Glob("mcp__*".into()));
578 assert_eq!(p.args, ArgMatcher::Any);
579 }
580
581 #[test]
582 fn with_args_builder() {
583 let p = ToolCallPattern::tool("Bash").with_args(ArgMatcher::Primary {
584 op: MatchOp::Glob,
585 value: "npm *".into(),
586 });
587 assert_eq!(
588 p.args,
589 ArgMatcher::Primary {
590 op: MatchOp::Glob,
591 value: "npm *".into()
592 }
593 );
594 }
595
596 #[test]
597 fn with_args_replaces_previous() {
598 let p = ToolCallPattern::tool_with_primary("Bash", "npm *").with_args(ArgMatcher::Any);
599 assert_eq!(p.args, ArgMatcher::Any);
600 }
601
602 #[test]
607 fn match_result_no_match() {
608 assert!(!MatchResult::NoMatch.is_match());
609 }
610
611 #[test]
612 fn match_result_match() {
613 let r = MatchResult::Match {
614 specificity: Specificity {
615 tool_kind: 3,
616 has_args: false,
617 field_count: 0,
618 field_precision: 0,
619 },
620 };
621 assert!(r.is_match());
622 }
623
624 #[test]
629 fn specificity_ordering() {
630 let low = Specificity {
631 tool_kind: 1,
632 has_args: false,
633 field_count: 0,
634 field_precision: 0,
635 };
636 let mid = Specificity {
637 tool_kind: 2,
638 has_args: false,
639 field_count: 0,
640 field_precision: 0,
641 };
642 let high = Specificity {
643 tool_kind: 3,
644 has_args: true,
645 field_count: 2,
646 field_precision: 6,
647 };
648 assert!(low < mid);
649 assert!(mid < high);
650 assert!(low < high);
651 }
652
653 #[test]
654 fn specificity_has_args_higher() {
655 let without = Specificity {
656 tool_kind: 3,
657 has_args: false,
658 field_count: 0,
659 field_precision: 0,
660 };
661 let with = Specificity {
662 tool_kind: 3,
663 has_args: true,
664 field_count: 1,
665 field_precision: 2,
666 };
667 assert!(with > without);
668 }
669}