claude_agent/common/
index_registry.rs

1//! Index registry for managing collections of index entries.
2//!
3//! `IndexRegistry` provides a generic container for index entries with:
4//! - Priority-based override semantics
5//! - Cached content loading
6//! - Summary generation for system prompts
7//! - Optional path-matching for `PathMatched` types
8
9use std::collections::HashMap;
10use std::path::Path;
11use std::sync::Arc;
12
13use tokio::sync::RwLock;
14
15use super::{Index, PathMatched};
16
17/// Generic registry for index entries.
18///
19/// Provides:
20/// - Named lookup with priority-based overrides
21/// - Lazy content loading with caching
22/// - Summary generation for system prompts
23///
24/// # Example
25///
26/// ```ignore
27/// let registry = IndexRegistry::new();
28/// registry.register(skill_index);
29///
30/// // Get metadata (always fast)
31/// let idx = registry.get("commit").unwrap();
32///
33/// // Load full content (lazy, cached)
34/// let content = registry.load_content("commit").await?;
35///
36/// // Generate summary for system prompt
37/// let summary = registry.build_summary();
38/// ```
39pub struct IndexRegistry<I: Index> {
40    indices: HashMap<String, I>,
41    content_cache: Arc<RwLock<HashMap<String, String>>>,
42}
43
44impl<I: Index> IndexRegistry<I> {
45    /// Create a new empty registry.
46    pub fn new() -> Self {
47        Self {
48            indices: HashMap::new(),
49            content_cache: Arc::new(RwLock::new(HashMap::new())),
50        }
51    }
52
53    /// Register an index entry.
54    ///
55    /// If an entry with the same name exists, it's replaced only if the
56    /// new entry has equal or higher priority.
57    pub fn register(&mut self, index: I) {
58        let name = index.name().to_string();
59
60        if let Some(existing) = self.indices.get(&name) {
61            if index.priority() >= existing.priority() {
62                self.indices.insert(name, index);
63            }
64        } else {
65            self.indices.insert(name, index);
66        }
67    }
68
69    /// Register multiple index entries.
70    pub fn register_all(&mut self, indices: impl IntoIterator<Item = I>) {
71        for index in indices {
72            self.register(index);
73        }
74    }
75
76    /// Get an index entry by name.
77    pub fn get(&self, name: &str) -> Option<&I> {
78        self.indices.get(name)
79    }
80
81    /// List all registered names.
82    pub fn list(&self) -> Vec<&str> {
83        self.indices.keys().map(String::as_str).collect()
84    }
85
86    /// Iterate over all index entries.
87    pub fn iter(&self) -> impl Iterator<Item = &I> {
88        self.indices.values()
89    }
90
91    /// Get the number of registered entries.
92    pub fn len(&self) -> usize {
93        self.indices.len()
94    }
95
96    /// Check if the registry is empty.
97    pub fn is_empty(&self) -> bool {
98        self.indices.is_empty()
99    }
100
101    /// Check if an entry with the given name exists.
102    pub fn contains(&self, name: &str) -> bool {
103        self.indices.contains_key(name)
104    }
105
106    /// Remove an entry by name.
107    ///
108    /// Also clears the cached content for this entry.
109    pub async fn remove(&mut self, name: &str) -> Option<I> {
110        // Clear cached content
111        self.content_cache.write().await.remove(name);
112        self.indices.remove(name)
113    }
114
115    /// Clear all entries.
116    ///
117    /// Also clears all cached content.
118    pub async fn clear(&mut self) {
119        self.indices.clear();
120        self.content_cache.write().await.clear();
121    }
122
123    /// Load content for an index entry with caching.
124    ///
125    /// Returns cached content if available, otherwise loads from source.
126    pub async fn load_content(&self, name: &str) -> crate::Result<String> {
127        // Check cache first
128        {
129            let cache = self.content_cache.read().await;
130            if let Some(content) = cache.get(name) {
131                return Ok(content.clone());
132            }
133        }
134
135        // Load from source
136        let index = self
137            .indices
138            .get(name)
139            .ok_or_else(|| crate::Error::Config(format!("Index entry '{}' not found", name)))?;
140
141        let content = index.load_content().await?;
142
143        // Cache the result
144        {
145            let mut cache = self.content_cache.write().await;
146            cache.insert(name.to_string(), content.clone());
147        }
148
149        Ok(content)
150    }
151
152    /// Invalidate cached content for an entry.
153    pub async fn invalidate_cache(&self, name: &str) {
154        let mut cache = self.content_cache.write().await;
155        cache.remove(name);
156    }
157
158    /// Clear all cached content.
159    pub async fn clear_cache(&self) {
160        let mut cache = self.content_cache.write().await;
161        cache.clear();
162    }
163
164    /// Build a summary of all entries for system prompt injection.
165    ///
166    /// Returns a formatted string with one summary line per entry.
167    pub fn build_summary(&self) -> String {
168        let mut lines: Vec<_> = self
169            .indices
170            .values()
171            .map(|idx| idx.to_summary_line())
172            .collect();
173        lines.sort();
174        lines.join("\n")
175    }
176
177    /// Build a summary with a custom formatter.
178    pub fn build_summary_with<F>(&self, formatter: F) -> String
179    where
180        F: Fn(&I) -> String,
181    {
182        let mut lines: Vec<_> = self.indices.values().map(formatter).collect();
183        lines.sort();
184        lines.join("\n")
185    }
186
187    /// Get entries sorted by priority (highest first).
188    pub fn sorted_by_priority(&self) -> Vec<&I> {
189        let mut items: Vec<_> = self.indices.values().collect();
190        items.sort_by_key(|i| std::cmp::Reverse(i.priority()));
191        items
192    }
193
194    /// Filter entries by a predicate.
195    pub fn filter<F>(&self, predicate: F) -> Vec<&I>
196    where
197        F: Fn(&I) -> bool,
198    {
199        self.indices.values().filter(|i| predicate(i)).collect()
200    }
201}
202
203// ============================================================================
204// PathMatched support
205// ============================================================================
206
207/// Loaded entry with index metadata and content.
208#[derive(Clone, Debug)]
209pub struct LoadedEntry<I: Index> {
210    /// The index metadata.
211    pub index: I,
212    /// The loaded content.
213    pub content: String,
214}
215
216impl<I: Index + PathMatched> IndexRegistry<I> {
217    /// Find all entries that match the given file path.
218    ///
219    /// Returns entries sorted by priority (highest first).
220    ///
221    /// # Example
222    ///
223    /// ```ignore
224    /// let matching = registry.find_matching(Path::new("src/lib.rs"));
225    /// for entry in matching {
226    ///     println!("Matched: {}", entry.name());
227    /// }
228    /// ```
229    pub fn find_matching(&self, path: &Path) -> Vec<&I> {
230        let mut matches: Vec<_> = self
231            .indices
232            .values()
233            .filter(|i| i.matches_path(path))
234            .collect();
235        // Sort by priority (highest first)
236        matches.sort_by_key(|i| std::cmp::Reverse(i.priority()));
237        matches
238    }
239
240    /// Load content for all entries matching the given file path.
241    ///
242    /// Returns loaded entries sorted by priority (highest first).
243    /// Entries that fail to load are skipped.
244    pub async fn load_matching(&self, path: &Path) -> Vec<LoadedEntry<I>> {
245        let matching = self.find_matching(path);
246        let mut results = Vec::with_capacity(matching.len());
247
248        for index in matching {
249            let name = index.name();
250            match self.load_content(name).await {
251                Ok(content) => {
252                    results.push(LoadedEntry {
253                        index: index.clone(),
254                        content,
255                    });
256                }
257                Err(e) => {
258                    tracing::warn!("Failed to load content for '{}': {}", name, e);
259                }
260            }
261        }
262
263        results
264    }
265
266    /// Check if any entry matches the given file path.
267    pub fn has_matching(&self, path: &Path) -> bool {
268        self.indices.values().any(|i| i.matches_path(path))
269    }
270
271    /// Build summary for entries matching a specific path.
272    pub fn build_matching_summary(&self, path: &Path) -> String {
273        let matching = self.find_matching(path);
274        if matching.is_empty() {
275            return String::new();
276        }
277
278        matching
279            .into_iter()
280            .map(|i| i.to_summary_line())
281            .collect::<Vec<_>>()
282            .join("\n")
283    }
284}
285
286impl<I: Index> Default for IndexRegistry<I> {
287    fn default() -> Self {
288        Self::new()
289    }
290}
291
292impl<I: Index> Clone for IndexRegistry<I> {
293    fn clone(&self) -> Self {
294        Self {
295            indices: self.indices.clone(),
296            content_cache: Arc::new(RwLock::new(HashMap::new())),
297        }
298    }
299}
300
301impl<I: Index> FromIterator<I> for IndexRegistry<I> {
302    fn from_iter<T: IntoIterator<Item = I>>(iter: T) -> Self {
303        let mut registry = Self::new();
304        registry.register_all(iter);
305        registry
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use async_trait::async_trait;
312
313    use super::*;
314    use crate::common::{ContentSource, Named, SourceType};
315
316    #[derive(Clone, Debug)]
317    struct TestIndex {
318        name: String,
319        desc: String,
320        source: ContentSource,
321        source_type: SourceType,
322    }
323
324    impl TestIndex {
325        fn new(name: &str, desc: &str, source_type: SourceType) -> Self {
326            Self {
327                name: name.into(),
328                desc: desc.into(),
329                source: ContentSource::in_memory(format!("Content for {}", name)),
330                source_type,
331            }
332        }
333    }
334
335    impl Named for TestIndex {
336        fn name(&self) -> &str {
337            &self.name
338        }
339    }
340
341    #[async_trait]
342    impl Index for TestIndex {
343        fn source(&self) -> &ContentSource {
344            &self.source
345        }
346
347        fn source_type(&self) -> SourceType {
348            self.source_type
349        }
350
351        fn to_summary_line(&self) -> String {
352            format!("- {}: {}", self.name, self.desc)
353        }
354    }
355
356    #[test]
357    fn test_basic_operations() {
358        let mut registry = IndexRegistry::new();
359
360        registry.register(TestIndex::new("a", "Desc A", SourceType::User));
361        registry.register(TestIndex::new("b", "Desc B", SourceType::User));
362
363        assert_eq!(registry.len(), 2);
364        assert!(registry.contains("a"));
365        assert!(registry.contains("b"));
366        assert!(!registry.contains("c"));
367    }
368
369    #[test]
370    fn test_priority_override() {
371        let mut registry = IndexRegistry::new();
372
373        // Register builtin first
374        registry.register(TestIndex::new("test", "Builtin", SourceType::Builtin));
375        assert_eq!(registry.get("test").unwrap().desc, "Builtin");
376
377        // User should override builtin
378        registry.register(TestIndex::new("test", "User", SourceType::User));
379        assert_eq!(registry.get("test").unwrap().desc, "User");
380
381        // Project should override user
382        registry.register(TestIndex::new("test", "Project", SourceType::Project));
383        assert_eq!(registry.get("test").unwrap().desc, "Project");
384
385        // Builtin should NOT override project
386        registry.register(TestIndex::new("test", "Builtin2", SourceType::Builtin));
387        assert_eq!(registry.get("test").unwrap().desc, "Project");
388    }
389
390    #[tokio::test]
391    async fn test_content_loading() {
392        let mut registry = IndexRegistry::new();
393        registry.register(TestIndex::new("test", "Desc", SourceType::User));
394
395        let content = registry.load_content("test").await.unwrap();
396        assert_eq!(content, "Content for test");
397    }
398
399    #[tokio::test]
400    async fn test_content_caching() {
401        let mut registry = IndexRegistry::new();
402        registry.register(TestIndex::new("test", "Desc", SourceType::User));
403
404        // First load
405        let content1 = registry.load_content("test").await.unwrap();
406
407        // Second load should use cache
408        let content2 = registry.load_content("test").await.unwrap();
409
410        assert_eq!(content1, content2);
411    }
412
413    #[test]
414    fn test_build_summary() {
415        let mut registry = IndexRegistry::new();
416        registry.register(TestIndex::new("commit", "Create commits", SourceType::User));
417        registry.register(TestIndex::new("review", "Review code", SourceType::User));
418
419        let summary = registry.build_summary();
420        assert!(summary.contains("- commit: Create commits"));
421        assert!(summary.contains("- review: Review code"));
422    }
423
424    #[test]
425    fn test_from_iterator() {
426        let indices = vec![
427            TestIndex::new("a", "A", SourceType::User),
428            TestIndex::new("b", "B", SourceType::User),
429        ];
430
431        let registry: IndexRegistry<TestIndex> = indices.into_iter().collect();
432        assert_eq!(registry.len(), 2);
433    }
434
435    #[test]
436    fn test_filter() {
437        let mut registry = IndexRegistry::new();
438        registry.register(TestIndex::new("builtin1", "B1", SourceType::Builtin));
439        registry.register(TestIndex::new("user1", "U1", SourceType::User));
440        registry.register(TestIndex::new("project1", "P1", SourceType::Project));
441
442        let users = registry.filter(|i| i.source_type() == SourceType::User);
443        assert_eq!(users.len(), 1);
444        assert_eq!(users[0].name(), "user1");
445    }
446
447    #[test]
448    fn test_sorted_by_priority() {
449        let mut registry = IndexRegistry::new();
450        registry.register(TestIndex::new("builtin", "B", SourceType::Builtin));
451        registry.register(TestIndex::new("user", "U", SourceType::User));
452        registry.register(TestIndex::new("project", "P", SourceType::Project));
453
454        let sorted = registry.sorted_by_priority();
455        assert_eq!(sorted[0].name(), "project");
456        assert_eq!(sorted[1].name(), "user");
457        assert_eq!(sorted[2].name(), "builtin");
458    }
459
460    // ========================================================================
461    // PathMatched tests
462    // ========================================================================
463
464    #[derive(Clone, Debug)]
465    struct PathMatchedIndex {
466        name: String,
467        desc: String,
468        patterns: Option<Vec<String>>,
469        source: ContentSource,
470        source_type: SourceType,
471    }
472
473    impl PathMatchedIndex {
474        fn new(name: &str, patterns: Option<Vec<&str>>, source_type: SourceType) -> Self {
475            Self {
476                name: name.into(),
477                desc: format!("Desc for {}", name),
478                patterns: patterns.map(|p| p.into_iter().map(String::from).collect()),
479                source: ContentSource::in_memory(format!("Content for {}", name)),
480                source_type,
481            }
482        }
483    }
484
485    impl Named for PathMatchedIndex {
486        fn name(&self) -> &str {
487            &self.name
488        }
489    }
490
491    #[async_trait]
492    impl Index for PathMatchedIndex {
493        fn source(&self) -> &ContentSource {
494            &self.source
495        }
496
497        fn source_type(&self) -> SourceType {
498            self.source_type
499        }
500
501        fn to_summary_line(&self) -> String {
502            format!("- {}: {}", self.name, self.desc)
503        }
504    }
505
506    impl PathMatched for PathMatchedIndex {
507        fn path_patterns(&self) -> Option<&[String]> {
508            self.patterns.as_deref()
509        }
510
511        fn matches_path(&self, path: &Path) -> bool {
512            match &self.patterns {
513                None => true, // Global
514                Some(patterns) if patterns.is_empty() => false,
515                Some(patterns) => {
516                    let path_str = path.to_string_lossy();
517                    patterns.iter().any(|p| {
518                        glob::Pattern::new(p)
519                            .map(|pat| pat.matches(&path_str))
520                            .unwrap_or(false)
521                    })
522                }
523            }
524        }
525    }
526
527    #[test]
528    fn test_find_matching_global() {
529        let mut registry = IndexRegistry::new();
530        registry.register(PathMatchedIndex::new("global", None, SourceType::User));
531
532        let matches = registry.find_matching(Path::new("any/file.rs"));
533        assert_eq!(matches.len(), 1);
534        assert_eq!(matches[0].name(), "global");
535    }
536
537    #[test]
538    fn test_find_matching_with_patterns() {
539        let mut registry = IndexRegistry::new();
540        registry.register(PathMatchedIndex::new(
541            "rust",
542            Some(vec!["**/*.rs"]),
543            SourceType::User,
544        ));
545        registry.register(PathMatchedIndex::new(
546            "typescript",
547            Some(vec!["**/*.ts", "**/*.tsx"]),
548            SourceType::User,
549        ));
550
551        let rust_matches = registry.find_matching(Path::new("src/lib.rs"));
552        assert_eq!(rust_matches.len(), 1);
553        assert_eq!(rust_matches[0].name(), "rust");
554
555        let ts_matches = registry.find_matching(Path::new("src/app.tsx"));
556        assert_eq!(ts_matches.len(), 1);
557        assert_eq!(ts_matches[0].name(), "typescript");
558    }
559
560    #[test]
561    fn test_find_matching_sorted_by_priority() {
562        let mut registry = IndexRegistry::new();
563        registry.register(PathMatchedIndex::new("builtin", None, SourceType::Builtin));
564        registry.register(PathMatchedIndex::new("user", None, SourceType::User));
565        registry.register(PathMatchedIndex::new("project", None, SourceType::Project));
566
567        let matches = registry.find_matching(Path::new("any/file.rs"));
568        assert_eq!(matches.len(), 3);
569        // Should be sorted by priority (highest first)
570        assert_eq!(matches[0].name(), "project");
571        assert_eq!(matches[1].name(), "user");
572        assert_eq!(matches[2].name(), "builtin");
573    }
574
575    #[test]
576    fn test_has_matching() {
577        let mut registry = IndexRegistry::new();
578        registry.register(PathMatchedIndex::new(
579            "rust",
580            Some(vec!["**/*.rs"]),
581            SourceType::User,
582        ));
583
584        assert!(registry.has_matching(Path::new("src/lib.rs")));
585        assert!(!registry.has_matching(Path::new("src/lib.ts")));
586    }
587
588    #[tokio::test]
589    async fn test_load_matching() {
590        let mut registry = IndexRegistry::new();
591        registry.register(PathMatchedIndex::new(
592            "rust",
593            Some(vec!["**/*.rs"]),
594            SourceType::User,
595        ));
596        registry.register(PathMatchedIndex::new("global", None, SourceType::User));
597
598        let loaded = registry.load_matching(Path::new("src/lib.rs")).await;
599        assert_eq!(loaded.len(), 2);
600
601        // Both should have loaded content
602        for entry in &loaded {
603            assert!(entry.content.starts_with("Content for"));
604        }
605    }
606}