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        // Encode every discriminating field so `remove_where` prefix/suffix/
31        // contains invalidation buckets entries correctly. The previous key
32        // omitted 7 fields, so invalidation could drop or retain the wrong
33        // entries for queries that differed only in those fields.
34        format!(
35            "grep\u{1f}{}\u{1f}{}\u{1f}{}\u{1f}{}\u{1f}{:?}\u{1f}{:?}\u{1f}{:?}\u{1f}{}\u{1f}{}\u{1f}{}\u{1f}{}",
36            self.pattern,
37            self.path,
38            self.case_sensitive,
39            self.max_results,
40            self.glob_pattern,
41            self.type_pattern,
42            self.max_result_bytes,
43            self.respect_ignore_files,
44            self.search_hidden,
45            self.search_binary,
46            self.literal,
47        )
48    }
49}
50
51impl From<&GrepSearchInput> for SearchCacheKey {
52    fn from(input: &GrepSearchInput) -> Self {
53        Self {
54            pattern: input.pattern.clone(),
55            path: input.path.clone(),
56            case_sensitive: input.case_sensitive.unwrap_or(false),
57            max_results: input.max_results.unwrap_or(5), // AGENTS.md requires max 5 results
58            glob_pattern: input.glob_pattern.clone(),
59            type_pattern: input.type_pattern.clone(),
60            max_result_bytes: input.max_result_bytes,
61            respect_ignore_files: input.respect_ignore_files.unwrap_or(true),
62            search_hidden: input.search_hidden.unwrap_or(false),
63            search_binary: input.search_binary.unwrap_or(false),
64            literal: input.literal.unwrap_or(false),
65        }
66    }
67}
68
69/// Thread-safe cache for search results backed by `UnifiedCache`
70pub struct GrepSearchCache {
71    cache: UnifiedCache<SearchCacheKey, GrepSearchResult>,
72}
73
74impl GrepSearchCache {
75    /// Create a new cache with the specified capacity
76    pub fn new(capacity: usize) -> Self {
77        Self {
78            cache: UnifiedCache::new(capacity, DEFAULT_CACHE_TTL, EvictionPolicy::Lru),
79        }
80    }
81
82    /// Get cached result if available
83    pub fn get(&self, input: &GrepSearchInput) -> Option<Arc<GrepSearchResult>> {
84        let key = SearchCacheKey::from(input);
85        self.cache.get(&key)
86    }
87
88    /// Cache a search result
89    pub fn put(&self, input: &GrepSearchInput, result: GrepSearchResult) {
90        let key = SearchCacheKey::from(input);
91        let size_bytes = size_of::<GrepSearchResult>() as u64
92            + result.query.len() as u64
93            + result.matches.iter().map(estimate_json_size).sum::<u64>();
94        self.cache.insert(key, result, size_bytes);
95    }
96
97    /// Check if this search should be cached (only cache successful, non-empty results)
98    pub fn should_cache(result: &GrepSearchResult) -> bool {
99        !result.matches.is_empty()
100    }
101
102    /// Clear the cache
103    pub fn clear(&self) {
104        self.cache.clear();
105    }
106
107    /// Get cache statistics
108    pub fn stats(&self) -> (usize, usize) {
109        let stats = self.cache.stats();
110        (stats.current_size, stats.max_size)
111    }
112}
113
114impl Default for GrepSearchCache {
115    fn default() -> Self {
116        Self::new(100) // Default to 100 entries
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    fn make_test_input(pattern: &str, path: &str) -> GrepSearchInput {
125        GrepSearchInput {
126            pattern: pattern.to_string(),
127            path: path.to_string(),
128            case_sensitive: Some(true),
129            literal: None,
130            glob_pattern: Some("*.rs".to_string()),
131            context_lines: None,
132            include_hidden: None,
133            max_results: Some(5), // AGENTS.md requires max 5 results
134            respect_ignore_files: None,
135            max_file_size: None,
136            search_hidden: None,
137            search_binary: None,
138            files_with_matches: None,
139            type_pattern: None,
140            invert_match: None,
141            word_boundaries: None,
142            line_number: None,
143            column: None,
144            only_matching: None,
145            trim: None,
146            max_result_bytes: None,
147            timeout: None,
148            extra_ignore_globs: None,
149        }
150    }
151
152    #[test]
153    fn test_cache_key_equality() {
154        let input1 = make_test_input("test", "/path");
155        let input2 = make_test_input("test", "/path");
156
157        let key1 = SearchCacheKey::from(&input1);
158        let key2 = SearchCacheKey::from(&input2);
159        assert_eq!(key1, key2);
160    }
161
162    #[test]
163    fn test_cache_operations() {
164        let cache = GrepSearchCache::new(10);
165
166        let input = make_test_input("test", "/path");
167
168        let result = GrepSearchResult {
169            query: "test".to_string(),
170            matches: vec![serde_json::json!({"file": "test.rs", "line": 1})],
171            truncated: false,
172            total_matches: None,
173        };
174
175        // Cache miss
176        assert!(cache.get(&input).is_none());
177
178        // Cache result
179        cache.put(&input, result.clone());
180
181        // Cache hit
182        let cached = cache.get(&input).unwrap();
183        assert_eq!(cached.query, result.query);
184        assert_eq!(cached.matches.len(), result.matches.len());
185        assert_eq!(cached.truncated, result.truncated);
186    }
187}