Skip to main content

agent_code_lib/services/
file_cache.rs

1//! File state cache.
2//!
3//! Caches file contents to avoid redundant reads during a session.
4//! Files are evicted when their modification time changes or the
5//! cache reaches its size limit.
6
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use std::time::SystemTime;
10
11/// Maximum cache size in bytes (50MB).
12const MAX_CACHE_BYTES: usize = 50 * 1024 * 1024;
13
14/// Cached state of a single file.
15#[derive(Debug, Clone)]
16struct CachedFile {
17    content: String,
18    modified: SystemTime,
19    size: usize,
20}
21
22/// In-memory cache of recently read files.
23pub struct FileCache {
24    entries: HashMap<PathBuf, CachedFile>,
25    total_bytes: usize,
26}
27
28impl FileCache {
29    pub fn new() -> Self {
30        Self {
31            entries: HashMap::new(),
32            total_bytes: 0,
33        }
34    }
35
36    /// Read a file, returning cached content if still fresh.
37    pub fn read(&mut self, path: &Path) -> Result<String, String> {
38        let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
39
40        // Check if cached entry is still valid.
41        if let Some(cached) = self.entries.get(&canonical)
42            && let Ok(meta) = std::fs::metadata(&canonical)
43            && let Ok(modified) = meta.modified()
44            && modified == cached.modified
45        {
46            return Ok(cached.content.clone());
47        }
48
49        // Read fresh.
50        let content =
51            std::fs::read_to_string(&canonical).map_err(|e| format!("read error: {e}"))?;
52
53        let meta = std::fs::metadata(&canonical).map_err(|e| format!("metadata error: {e}"))?;
54
55        let modified = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
56        let size = content.len();
57
58        // Evict if over budget.
59        while self.total_bytes + size > MAX_CACHE_BYTES && !self.entries.is_empty() {
60            // Remove the first entry (arbitrary eviction).
61            if let Some(key) = self.entries.keys().next().cloned()
62                && let Some(evicted) = self.entries.remove(&key)
63            {
64                self.total_bytes -= evicted.size;
65            }
66        }
67
68        self.total_bytes += size;
69        self.entries.insert(
70            canonical,
71            CachedFile {
72                content: content.clone(),
73                modified,
74                size,
75            },
76        );
77
78        Ok(content)
79    }
80
81    /// Invalidate the cache entry for a file (e.g., after a write).
82    pub fn invalidate(&mut self, path: &Path) {
83        let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
84
85        if let Some(evicted) = self.entries.remove(&canonical) {
86            self.total_bytes -= evicted.size;
87        }
88    }
89
90    /// Clear the entire cache.
91    pub fn clear(&mut self) {
92        self.entries.clear();
93        self.total_bytes = 0;
94    }
95
96    /// Number of cached files.
97    pub fn len(&self) -> usize {
98        self.entries.len()
99    }
100
101    /// Total cached bytes.
102    pub fn bytes(&self) -> usize {
103        self.total_bytes
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn test_cache_read_and_invalidate() {
113        let dir = tempfile::tempdir().unwrap();
114        let file = dir.path().join("test.txt");
115        std::fs::write(&file, "hello").unwrap();
116
117        let mut cache = FileCache::new();
118
119        // First read — cache miss.
120        let content = cache.read(&file).unwrap();
121        assert_eq!(content, "hello");
122        assert_eq!(cache.len(), 1);
123
124        // Second read — cache hit (same content).
125        let content2 = cache.read(&file).unwrap();
126        assert_eq!(content2, "hello");
127
128        // Invalidate.
129        cache.invalidate(&file);
130        assert_eq!(cache.len(), 0);
131    }
132}