claude_agent/context/
rule_index.rs

1//! Rule Index for Progressive Disclosure
2//!
3//! Rules are loaded on-demand based on file path matching.
4//! Only indices (metadata) are loaded at startup; full content is lazy-loaded.
5//!
6//! # Architecture
7//!
8//! RuleIndex implements both `Index` and `PathMatched` traits:
9//! - `Index`: Provides lazy content loading and priority-based override
10//! - `PathMatched`: Enables path-based filtering for context-sensitive rules
11//!
12//! Rules are stored in `IndexRegistry<RuleIndex>` and use `find_matching(path)`
13//! to get all rules that apply to a specific file.
14
15use 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/// Frontmatter schema for rule files.
26///
27/// Used with the generic `parse_frontmatter<RuleFrontmatter>()` parser.
28#[derive(Debug, Default, Deserialize)]
29pub struct RuleFrontmatter {
30    /// Human-readable description of the rule.
31    #[serde(default)]
32    pub description: String,
33
34    /// Path patterns this rule applies to (glob syntax).
35    #[serde(default)]
36    pub paths: Option<Vec<String>>,
37
38    /// Explicit priority for ordering.
39    #[serde(default)]
40    pub priority: i32,
41}
42
43/// Rule index entry - minimal metadata for progressive disclosure.
44///
45/// Contains only metadata needed for system prompt injection.
46/// Full rule content is loaded on-demand via `load_content()`.
47#[derive(Clone, Debug, Serialize, Deserialize)]
48pub struct RuleIndex {
49    /// Rule name (unique identifier).
50    pub name: String,
51
52    /// Human-readable description of what this rule does.
53    #[serde(default)]
54    pub description: String,
55
56    /// Path patterns this rule applies to (glob syntax).
57    /// `None` means this is a global rule that applies to all files.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub paths: Option<Vec<String>>,
60
61    /// Compiled glob patterns for efficient matching.
62    #[serde(skip)]
63    compiled_patterns: Vec<Pattern>,
64
65    /// Explicit priority for ordering. Higher values take precedence.
66    /// This is separate from source_type-based priority in the Index trait.
67    #[serde(default)]
68    pub priority: i32,
69
70    /// Content source for lazy loading.
71    pub source: ContentSource,
72
73    /// Source type (builtin, user, project).
74    #[serde(default)]
75    pub source_type: SourceType,
76}
77
78impl RuleIndex {
79    /// Create a new rule index entry.
80    ///
81    /// Uses `ContentSource::default()` (empty InMemory) as placeholder.
82    /// Call `with_source()` to set the actual content source.
83    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    /// Set the rule description.
96    pub fn with_description(mut self, description: impl Into<String>) -> Self {
97        self.description = description.into();
98        self
99    }
100
101    /// Set path patterns this rule applies to.
102    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    /// Set the explicit priority.
109    pub fn with_priority(mut self, priority: i32) -> Self {
110        self.priority = priority;
111        self
112    }
113
114    /// Set the content source.
115    pub fn with_source(mut self, source: ContentSource) -> Self {
116        self.source = source;
117        self
118    }
119
120    /// Set the source type.
121    pub fn with_source_type(mut self, source_type: SourceType) -> Self {
122        self.source_type = source_type;
123        self
124    }
125
126    /// Load rule from a file path.
127    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    /// Parse rule from content with frontmatter.
133    ///
134    /// Uses the generic `parse_frontmatter<RuleFrontmatter>()` parser.
135    /// Falls back to defaults if frontmatter is missing or invalid.
136    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        // Try parsing frontmatter, use defaults if missing/invalid
144        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
166// ============================================================================
167// Trait implementations
168// ============================================================================
169
170impl 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    /// Override priority to use explicit field instead of source_type-based.
187    ///
188    /// Rules need explicit ordering independent of their source type.
189    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        // Strip frontmatter for file sources
218        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; // Global rule matches all files
234        }
235        let path_str = file_path.to_string_lossy();
236        self.compiled_patterns.iter().any(|p| p.matches(&path_str))
237    }
238}
239
240// ============================================================================
241// Tests
242// ============================================================================
243
244#[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        // Use the generic parser via parse_with_frontmatter
293        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        // Use the common strip_frontmatter function
312        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        // Priority should be explicit, not source_type-based
365        let rule = RuleIndex::new("test")
366            .with_priority(100)
367            .with_source_type(SourceType::Builtin); // Builtin would be 0 normally
368
369        assert_eq!(rule.priority(), 100); // Should use explicit priority
370    }
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        // Index trait
383        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        // PathMatched trait
389        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}