Skip to main content

vtcode_core/tools/
grep_cache.rs

1//! Caching layer for grep file search results to avoid redundant searches
2//!
3//! This module provides a cache for search results, keyed by search parameters
4//! to eliminate duplicate searches for identical patterns and paths.
5//!
6//! Uses `UnifiedCache` from `crate::cache` for LRU eviction, TTL, and stats.
7
8use super::grep_file::{GrepSearchInput, GrepSearchResult};
9use crate::cache::{CacheKey, DEFAULT_CACHE_TTL, EvictionPolicy, UnifiedCache, estimate_json_size};
10use std::sync::Arc;
11
12/// Cache key for search results - includes all parameters that affect search results
13#[derive(Debug, Clone, Hash, PartialEq, Eq)]
14struct SearchCacheKey {
15    pattern: String,
16    path: String,
17    case_sensitive: bool,
18    max_results: usize,
19    glob_pattern: Option<String>,
20    type_pattern: Option<String>,
21    max_result_bytes: Option<usize>,
22    respect_ignore_files: bool,
23    search_hidden: bool,
24    search_binary: bool,
25    literal: bool,
26}
27
28impl CacheKey for SearchCacheKey {
29    fn to_cache_key(&self) -> String {
30        format!(
31            "grep:{}:{}:{}:{}",
32            self.pattern, self.path, self.case_sensitive, self.max_results
33        )
34    }
35}
36
37impl From<&GrepSearchInput> for SearchCacheKey {
38    fn from(input: &GrepSearchInput) -> Self {
39        Self {
40            pattern: input.pattern.clone(),
41            path: input.path.clone(),
42            case_sensitive: input.case_sensitive.unwrap_or(false),
43            max_results: input.max_results.unwrap_or(5), // AGENTS.md requires max 5 results
44            glob_pattern: input.glob_pattern.clone(),
45            type_pattern: input.type_pattern.clone(),
46            max_result_bytes: input.max_result_bytes,
47            respect_ignore_files: input.respect_ignore_files.unwrap_or(true),
48            search_hidden: input.search_hidden.unwrap_or(false),
49            search_binary: input.search_binary.unwrap_or(false),
50            literal: input.literal.unwrap_or(false),
51        }
52    }
53}
54
55/// Thread-safe cache for search results backed by `UnifiedCache`
56pub struct GrepSearchCache {
57    cache: UnifiedCache<SearchCacheKey, GrepSearchResult>,
58}
59
60impl GrepSearchCache {
61    /// Create a new cache with the specified capacity
62    pub fn new(capacity: usize) -> Self {
63        Self {
64            cache: UnifiedCache::new(capacity, DEFAULT_CACHE_TTL, EvictionPolicy::Lru),
65        }
66    }
67
68    /// Get cached result if available
69    pub fn get(&self, input: &GrepSearchInput) -> Option<Arc<GrepSearchResult>> {
70        let key = SearchCacheKey::from(input);
71        self.cache.get(&key)
72    }
73
74    /// Cache a search result
75    pub fn put(&self, input: &GrepSearchInput, result: GrepSearchResult) {
76        let key = SearchCacheKey::from(input);
77        let size_bytes = size_of::<GrepSearchResult>() as u64
78            + result.query.len() as u64
79            + result.matches.iter().map(estimate_json_size).sum::<u64>();
80        self.cache.insert(key, result, size_bytes);
81    }
82
83    /// Check if this search should be cached (only cache successful, non-empty results)
84    pub fn should_cache(result: &GrepSearchResult) -> bool {
85        !result.matches.is_empty()
86    }
87
88    /// Clear the cache
89    pub fn clear(&self) {
90        self.cache.clear();
91    }
92
93    /// Get cache statistics
94    pub fn stats(&self) -> (usize, usize) {
95        let stats = self.cache.stats();
96        (stats.current_size, stats.max_size)
97    }
98}
99
100impl Default for GrepSearchCache {
101    fn default() -> Self {
102        Self::new(100) // Default to 100 entries
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    fn make_test_input(pattern: &str, path: &str) -> GrepSearchInput {
111        GrepSearchInput {
112            pattern: pattern.to_string(),
113            path: path.to_string(),
114            case_sensitive: Some(true),
115            literal: None,
116            glob_pattern: Some("*.rs".to_string()),
117            context_lines: None,
118            include_hidden: None,
119            max_results: Some(5), // AGENTS.md requires max 5 results
120            respect_ignore_files: None,
121            max_file_size: None,
122            search_hidden: None,
123            search_binary: None,
124            files_with_matches: None,
125            type_pattern: None,
126            invert_match: None,
127            word_boundaries: None,
128            line_number: None,
129            column: None,
130            only_matching: None,
131            trim: None,
132            max_result_bytes: None,
133            timeout: None,
134            extra_ignore_globs: None,
135        }
136    }
137
138    #[test]
139    fn test_cache_key_equality() {
140        let input1 = make_test_input("test", "/path");
141        let input2 = make_test_input("test", "/path");
142
143        let key1 = SearchCacheKey::from(&input1);
144        let key2 = SearchCacheKey::from(&input2);
145        assert_eq!(key1, key2);
146    }
147
148    #[test]
149    fn test_cache_operations() {
150        let cache = GrepSearchCache::new(10);
151
152        let input = make_test_input("test", "/path");
153
154        let result = GrepSearchResult {
155            query: "test".to_string(),
156            matches: vec![serde_json::json!({"file": "test.rs", "line": 1})],
157            truncated: false,
158            total_matches: None,
159        };
160
161        // Cache miss
162        assert!(cache.get(&input).is_none());
163
164        // Cache result
165        cache.put(&input, result.clone());
166
167        // Cache hit
168        let cached = cache.get(&input).unwrap();
169        assert_eq!(cached.query, result.query);
170        assert_eq!(cached.matches.len(), result.matches.len());
171        assert_eq!(cached.truncated, result.truncated);
172    }
173}