Skip to main content

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