claude_agent/common/path_matched.rs
1//! Path-matching trait for context-sensitive index entries.
2//!
3//! The `PathMatched` trait enables index entries to be filtered based on file paths.
4//! This is essential for rules that apply only to specific file patterns.
5//!
6//! # Example
7//!
8//! ```ignore
9//! pub struct RuleIndex {
10//! paths: Option<Vec<String>>,
11//! compiled_patterns: Vec<Pattern>,
12//! // ...
13//! }
14//!
15//! impl PathMatched for RuleIndex {
16//! fn path_patterns(&self) -> Option<&[String]> {
17//! self.paths.as_deref()
18//! }
19//!
20//! fn matches_path(&self, path: &Path) -> bool {
21//! if self.compiled_patterns.is_empty() {
22//! return true; // Global rule matches all
23//! }
24//! let path_str = path.to_string_lossy();
25//! self.compiled_patterns.iter().any(|p| p.matches(&path_str))
26//! }
27//! }
28//! ```
29
30use std::path::Path;
31
32/// Trait for index entries that support path-based filtering.
33///
34/// Implementors can specify glob patterns that determine which files
35/// the entry applies to. Entries without patterns match all files.
36pub trait PathMatched {
37 /// Get the path patterns this entry matches.
38 ///
39 /// Returns `None` if this is a global entry that matches all files.
40 /// Returns `Some(&[])` if patterns were explicitly set to empty (matches nothing).
41 fn path_patterns(&self) -> Option<&[String]>;
42
43 /// Check if this entry matches the given file path.
44 ///
45 /// Default behavior:
46 /// - No patterns (`None`) → matches all files
47 /// - Empty patterns → matches no files
48 /// - Has patterns → matches if any pattern matches
49 fn matches_path(&self, path: &Path) -> bool;
50
51 /// Check if this is a global entry (matches all files).
52 fn is_global(&self) -> bool {
53 self.path_patterns().is_none()
54 }
55}
56
57#[cfg(test)]
58mod tests {
59 use super::*;
60
61 struct TestPathMatched {
62 patterns: Option<Vec<String>>,
63 }
64
65 impl PathMatched for TestPathMatched {
66 fn path_patterns(&self) -> Option<&[String]> {
67 self.patterns.as_deref()
68 }
69
70 fn matches_path(&self, path: &Path) -> bool {
71 match &self.patterns {
72 None => true, // Global
73 Some(patterns) if patterns.is_empty() => false,
74 Some(patterns) => {
75 let path_str = path.to_string_lossy();
76 patterns.iter().any(|p| {
77 glob::Pattern::new(p)
78 .map(|pat| pat.matches(&path_str))
79 .unwrap_or(false)
80 })
81 }
82 }
83 }
84 }
85
86 #[test]
87 fn test_global_matches_all() {
88 let global = TestPathMatched { patterns: None };
89 assert!(global.is_global());
90 assert!(global.matches_path(Path::new("any/file.rs")));
91 assert!(global.matches_path(Path::new("other/path.ts")));
92 }
93
94 #[test]
95 fn test_pattern_matching() {
96 let rust_only = TestPathMatched {
97 patterns: Some(vec!["**/*.rs".to_string()]),
98 };
99 assert!(!rust_only.is_global());
100 assert!(rust_only.matches_path(Path::new("src/lib.rs")));
101 assert!(rust_only.matches_path(Path::new("tests/integration.rs")));
102 assert!(!rust_only.matches_path(Path::new("src/lib.ts")));
103 }
104
105 #[test]
106 fn test_multiple_patterns() {
107 let web = TestPathMatched {
108 patterns: Some(vec!["**/*.ts".to_string(), "**/*.tsx".to_string()]),
109 };
110 assert!(web.matches_path(Path::new("src/app.ts")));
111 assert!(web.matches_path(Path::new("components/Button.tsx")));
112 assert!(!web.matches_path(Path::new("src/lib.rs")));
113 }
114
115 #[test]
116 fn test_empty_patterns_matches_nothing() {
117 let empty = TestPathMatched {
118 patterns: Some(vec![]),
119 };
120 assert!(!empty.is_global());
121 assert!(!empty.matches_path(Path::new("any/file.rs")));
122 }
123}