Skip to main content

awaken_tool_pattern/
types.rs

1//! Type definitions for tool call pattern matching.
2
3use std::fmt;
4
5use serde::{Deserialize, Serialize};
6
7// ---------------------------------------------------------------------------
8// Path segments for nested field access
9// ---------------------------------------------------------------------------
10
11/// A single segment in a dotted field path.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum PathSegment {
14    /// Named object key.
15    Field(String),
16    /// Specific array index: `[0]`, `[3]`.
17    Index(usize),
18    /// Any array element: `[*]`.
19    AnyIndex,
20    /// Any object key: `*` as a path segment (wildcard).
21    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// ---------------------------------------------------------------------------
36// Match operators
37// ---------------------------------------------------------------------------
38
39/// Comparison operator for field conditions.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41pub enum MatchOp {
42    /// `~` — glob pattern match.
43    #[serde(rename = "glob")]
44    Glob,
45    /// `=` — exact string equality.
46    #[serde(rename = "exact")]
47    Exact,
48    /// `=~` — regex match.
49    #[serde(rename = "regex")]
50    Regex,
51    /// `!~` — negated glob.
52    #[serde(rename = "not_glob")]
53    NotGlob,
54    /// `!=` — not equal.
55    #[serde(rename = "not_exact")]
56    NotExact,
57    /// `!=~` — negated regex.
58    #[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// ---------------------------------------------------------------------------
76// Field condition
77// ---------------------------------------------------------------------------
78
79/// A single field-level predicate: `path op "value"`.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct FieldCondition {
82    /// Dotted path to the JSON field.
83    pub path: Vec<PathSegment>,
84    /// Comparison operator.
85    pub op: MatchOp,
86    /// Pattern or literal value to compare against.
87    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// ---------------------------------------------------------------------------
110// Tool matcher
111// ---------------------------------------------------------------------------
112
113/// How to match the tool name portion of a call.
114#[derive(Debug, Clone)]
115pub enum ToolMatcher {
116    /// Exact string equality.
117    Exact(String),
118    /// Glob pattern (supports `*`, `?`, `[...]`).
119    Glob(String),
120    /// Compiled regex.
121    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// ---------------------------------------------------------------------------
147// Argument matcher
148// ---------------------------------------------------------------------------
149
150/// How to match the arguments portion of a tool call.
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub enum ArgMatcher {
153    /// `(*)` or omitted — matches any arguments.
154    Any,
155    /// Positional shorthand: `(npm *)` — implicit glob on primary field.
156    Primary { op: MatchOp, value: String },
157    /// One or more named field conditions (AND semantics).
158    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// ---------------------------------------------------------------------------
183// ToolCallPattern
184// ---------------------------------------------------------------------------
185
186/// A complete pattern matching tool calls by name and optionally by arguments.
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct ToolCallPattern {
189    /// Tool name matcher (exact, glob, or regex).
190    pub tool: ToolMatcher,
191    /// Argument matcher (any, primary, or named fields).
192    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    /// Exact tool name, any args.
207    ///
208    /// # Examples
209    ///
210    /// ```
211    /// use awaken_tool_pattern::ToolCallPattern;
212    ///
213    /// let pattern = ToolCallPattern::tool("read_file");
214    /// assert_eq!(format!("{}", pattern), "read_file");
215    /// ```
216    #[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    /// Exact tool name with a primary glob pattern.
225    #[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    /// Glob tool name, any args.
237    #[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    /// Set argument matcher.
246    #[must_use]
247    pub fn with_args(mut self, args: ArgMatcher) -> Self {
248        self.args = args;
249        self
250    }
251}
252
253// ---------------------------------------------------------------------------
254// Match result and specificity
255// ---------------------------------------------------------------------------
256
257/// Result of matching a pattern against a tool call.
258#[derive(Debug, Clone, PartialEq, Eq)]
259pub enum MatchResult {
260    /// The pattern does not match this tool call.
261    NoMatch,
262    /// The pattern matches with the given specificity.
263    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/// Specificity of a pattern match, used for priority ordering.
274///
275/// Higher values = more specific = higher priority.
276/// Ordering: exact tool > glob tool > regex tool,
277/// with-args > without-args, more fields > fewer fields.
278#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
279pub struct Specificity {
280    /// Tool name match precision: 3=exact, 2=glob, 1=regex.
281    pub tool_kind: u8,
282    /// Whether argument conditions are present.
283    pub has_args: bool,
284    /// Number of field conditions.
285    pub field_count: u8,
286    /// Sum of per-field precision (exact=3, glob=2, regex=1 per field).
287    pub field_precision: u8,
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    // -----------------------------------------------------------------------
295    // PathSegment Display
296    // -----------------------------------------------------------------------
297
298    #[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    // -----------------------------------------------------------------------
319    // MatchOp Display
320    // -----------------------------------------------------------------------
321
322    #[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    // -----------------------------------------------------------------------
333    // FieldCondition Display
334    // -----------------------------------------------------------------------
335
336    #[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    // -----------------------------------------------------------------------
405    // ToolMatcher Display and PartialEq
406    // -----------------------------------------------------------------------
407
408    #[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    // -----------------------------------------------------------------------
474    // ArgMatcher Display
475    // -----------------------------------------------------------------------
476
477    #[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    // -----------------------------------------------------------------------
537    // ToolCallPattern Display and builders
538    // -----------------------------------------------------------------------
539
540    #[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    // -----------------------------------------------------------------------
603    // MatchResult
604    // -----------------------------------------------------------------------
605
606    #[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    // -----------------------------------------------------------------------
625    // Specificity ordering
626    // -----------------------------------------------------------------------
627
628    #[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}