claude_agent/context/
rule_index.rs1use std::path::Path;
16
17use async_trait::async_trait;
18use glob::Pattern;
19use serde::{Deserialize, Serialize};
20
21use crate::common::{
22 ContentSource, Index, Named, PathMatched, SourceType, parse_frontmatter, strip_frontmatter,
23};
24
25#[derive(Debug, Default, Deserialize)]
29pub struct RuleFrontmatter {
30 #[serde(default)]
32 pub description: String,
33
34 #[serde(default)]
36 pub paths: Option<Vec<String>>,
37
38 #[serde(default)]
40 pub priority: i32,
41}
42
43#[derive(Clone, Debug, Serialize, Deserialize)]
48pub struct RuleIndex {
49 pub name: String,
51
52 #[serde(default)]
54 pub description: String,
55
56 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub paths: Option<Vec<String>>,
60
61 #[serde(skip)]
63 compiled_patterns: Vec<Pattern>,
64
65 #[serde(default)]
68 pub priority: i32,
69
70 pub source: ContentSource,
72
73 #[serde(default)]
75 pub source_type: SourceType,
76}
77
78impl RuleIndex {
79 pub fn new(name: impl Into<String>) -> Self {
84 Self {
85 name: name.into(),
86 description: String::new(),
87 paths: None,
88 compiled_patterns: Vec::new(),
89 priority: 0,
90 source: ContentSource::default(),
91 source_type: SourceType::default(),
92 }
93 }
94
95 pub fn with_description(mut self, description: impl Into<String>) -> Self {
97 self.description = description.into();
98 self
99 }
100
101 pub fn with_paths(mut self, paths: Vec<String>) -> Self {
103 self.compiled_patterns = paths.iter().filter_map(|p| Pattern::new(p).ok()).collect();
104 self.paths = Some(paths);
105 self
106 }
107
108 pub fn with_priority(mut self, priority: i32) -> Self {
110 self.priority = priority;
111 self
112 }
113
114 pub fn with_source(mut self, source: ContentSource) -> Self {
116 self.source = source;
117 self
118 }
119
120 pub fn with_source_type(mut self, source_type: SourceType) -> Self {
122 self.source_type = source_type;
123 self
124 }
125
126 pub fn from_file(path: &Path) -> Option<Self> {
128 let content = std::fs::read_to_string(path).ok()?;
129 Self::parse_with_frontmatter(&content, path)
130 }
131
132 pub fn parse_with_frontmatter(content: &str, path: &Path) -> Option<Self> {
137 let name = path
138 .file_stem()
139 .and_then(|s| s.to_str())
140 .unwrap_or("unknown")
141 .to_string();
142
143 let fm = parse_frontmatter::<RuleFrontmatter>(content)
145 .map(|doc| doc.frontmatter)
146 .unwrap_or_default();
147
148 let compiled_patterns = fm
149 .paths
150 .as_ref()
151 .map(|p| p.iter().filter_map(|s| Pattern::new(s).ok()).collect())
152 .unwrap_or_default();
153
154 Some(Self {
155 name,
156 description: fm.description,
157 paths: fm.paths,
158 compiled_patterns,
159 priority: fm.priority,
160 source: ContentSource::file(path),
161 source_type: SourceType::default(),
162 })
163 }
164}
165
166impl Named for RuleIndex {
171 fn name(&self) -> &str {
172 &self.name
173 }
174}
175
176#[async_trait]
177impl Index for RuleIndex {
178 fn source(&self) -> &ContentSource {
179 &self.source
180 }
181
182 fn source_type(&self) -> SourceType {
183 self.source_type
184 }
185
186 fn priority(&self) -> i32 {
190 self.priority
191 }
192
193 fn to_summary_line(&self) -> String {
194 let scope = match &self.paths {
195 Some(p) if !p.is_empty() => p.join(", "),
196 _ => "all files".to_string(),
197 };
198 if self.description.is_empty() {
199 format!("- {}: applies to {}", self.name, scope)
200 } else {
201 format!(
202 "- {} ({}): applies to {}",
203 self.name, self.description, scope
204 )
205 }
206 }
207
208 fn description(&self) -> &str {
209 &self.description
210 }
211
212 async fn load_content(&self) -> crate::Result<String> {
213 let content = self.source.load().await.map_err(|e| {
214 crate::Error::Config(format!("Failed to load rule '{}': {}", self.name, e))
215 })?;
216
217 if self.source.is_file() {
219 Ok(strip_frontmatter(&content).to_string())
220 } else {
221 Ok(content)
222 }
223 }
224}
225
226impl PathMatched for RuleIndex {
227 fn path_patterns(&self) -> Option<&[String]> {
228 self.paths.as_deref()
229 }
230
231 fn matches_path(&self, file_path: &Path) -> bool {
232 if self.compiled_patterns.is_empty() {
233 return true; }
235 let path_str = file_path.to_string_lossy();
236 self.compiled_patterns.iter().any(|p| p.matches(&path_str))
237 }
238}
239
240#[cfg(test)]
245mod tests {
246 use super::*;
247 use tempfile::tempdir;
248 use tokio::fs;
249
250 #[test]
251 fn test_rule_index_creation() {
252 let rule = RuleIndex::new("typescript")
253 .with_description("TypeScript coding standards")
254 .with_paths(vec!["**/*.ts".into(), "**/*.tsx".into()])
255 .with_priority(10);
256
257 assert_eq!(rule.name, "typescript");
258 assert_eq!(rule.description, "TypeScript coding standards");
259 assert_eq!(rule.priority, 10);
260 }
261
262 #[test]
263 fn test_path_matching() {
264 let rule = RuleIndex::new("rust").with_paths(vec!["**/*.rs".into()]);
265
266 assert!(rule.matches_path(Path::new("src/lib.rs")));
267 assert!(rule.matches_path(Path::new("src/context/mod.rs")));
268 assert!(!rule.matches_path(Path::new("src/lib.ts")));
269 }
270
271 #[test]
272 fn test_global_rule() {
273 let rule = RuleIndex::new("security");
274 assert!(rule.is_global());
275 assert!(rule.matches_path(Path::new("any/file.rs")));
276 assert!(rule.matches_path(Path::new("another/file.js")));
277 }
278
279 #[test]
280 fn test_frontmatter_parsing() {
281 let content = r#"---
282description: "Rust coding standards"
283paths:
284 - src/**/*.rs
285 - tests/**/*.rs
286priority: 10
287---
288
289# Rust Guidelines
290Use snake_case for variables."#;
291
292 let rule =
294 RuleIndex::parse_with_frontmatter(content, std::path::Path::new("test.md")).unwrap();
295 assert_eq!(rule.priority, 10);
296 assert_eq!(rule.description, "Rust coding standards");
297 assert!(rule.paths.is_some());
298 let paths = rule.paths.unwrap();
299 assert!(paths.contains(&"src/**/*.rs".to_string()));
300 assert!(paths.contains(&"tests/**/*.rs".to_string()));
301 }
302
303 #[test]
304 fn test_strip_frontmatter() {
305 let content = r#"---
306paths: src/**/*.rs
307---
308
309# Content"#;
310
311 let stripped = strip_frontmatter(content);
313 assert_eq!(stripped, "# Content");
314 }
315
316 #[tokio::test]
317 async fn test_lazy_loading() {
318 let dir = tempdir().unwrap();
319 let rule_path = dir.path().join("test.md");
320 fs::write(
321 &rule_path,
322 r#"---
323description: "Test rule"
324paths:
325 - "**/*.rs"
326priority: 5
327---
328
329# Test Rule Content"#,
330 )
331 .await
332 .unwrap();
333
334 let index = RuleIndex::from_file(&rule_path).unwrap();
335 assert_eq!(index.name, "test");
336 assert_eq!(index.description, "Test rule");
337 assert_eq!(index.priority, 5);
338
339 let content = index.load_content().await.expect("Should load content");
340 assert_eq!(content, "# Test Rule Content");
341 }
342
343 #[test]
344 fn test_summary_line_with_description() {
345 let rule = RuleIndex::new("security")
346 .with_description("Security best practices")
347 .with_paths(vec!["**/*.rs".into()]);
348
349 let summary = rule.to_summary_line();
350 assert!(summary.contains("security"));
351 assert!(summary.contains("Security best practices"));
352 assert!(summary.contains("**/*.rs"));
353 }
354
355 #[test]
356 fn test_summary_line_without_description() {
357 let rule = RuleIndex::new("global-rule");
358 let summary = rule.to_summary_line();
359 assert_eq!(summary, "- global-rule: applies to all files");
360 }
361
362 #[test]
363 fn test_priority_override() {
364 let rule = RuleIndex::new("test")
366 .with_priority(100)
367 .with_source_type(SourceType::Builtin); assert_eq!(rule.priority(), 100); }
371
372 #[test]
373 fn test_implements_index_and_path_matched() {
374 use crate::common::{Index, PathMatched};
375
376 let rule = RuleIndex::new("test")
377 .with_description("Test")
378 .with_paths(vec!["**/*.rs".into()])
379 .with_source_type(SourceType::User)
380 .with_source(ContentSource::in_memory("Rule content"));
381
382 assert_eq!(rule.name(), "test");
384 assert_eq!(rule.source_type(), SourceType::User);
385 assert!(rule.to_summary_line().contains("test"));
386 assert_eq!(rule.description(), "Test");
387
388 assert!(!rule.is_global());
390 assert!(rule.matches_path(Path::new("src/lib.rs")));
391 assert!(!rule.matches_path(Path::new("src/lib.ts")));
392 }
393}