Skip to main content

ryo_symbol/
content_cache.rs

1//! Content cache for file freshness tracking
2//!
3//! Manages content hashes and modification times for cache invalidation.
4//! Separate from file path identity (WorkspaceFilePath handles that).
5
6use std::collections::HashMap;
7use std::fs;
8use std::io;
9use std::path::PathBuf;
10use std::time::SystemTime;
11
12use crate::file_path::WorkspaceFilePath;
13
14/// Cache entry for a single file
15#[derive(Debug, Clone)]
16pub struct CacheEntry {
17    /// Content hash (e.g., xxhash of file contents)
18    pub hash: u64,
19    /// Last modification time
20    pub mtime: SystemTime,
21}
22
23impl CacheEntry {
24    /// Create a new cache entry
25    pub fn new(hash: u64, mtime: SystemTime) -> Self {
26        Self { hash, mtime }
27    }
28
29    /// Check if entry is fresh compared to current mtime
30    pub fn is_fresh(&self, current_mtime: SystemTime) -> bool {
31        self.mtime == current_mtime
32    }
33}
34
35/// Freshness status for a file
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum Freshness {
38    /// File is up to date (mtime matches)
39    Fresh,
40    /// File has been modified (mtime changed)
41    Stale,
42    /// File is not in cache
43    NotCached,
44    /// File no longer exists
45    Missing,
46}
47
48/// Content cache for tracking file freshness
49///
50/// # Design
51///
52/// - Keyed by WorkspaceFilePath (relative path for portability)
53/// - Stores hash + mtime for cache invalidation
54/// - Separate from path identity concerns
55///
56/// # Usage
57///
58/// ```ignore
59/// let mut cache = ContentCache::new();
60///
61/// // Register a file
62/// let path = resolver.resolve_relative("src/lib.rs");
63/// cache.register(&path, hash, mtime);
64///
65/// // Check freshness
66/// match cache.check_freshness(&path) {
67///     Freshness::Fresh => println!("Up to date"),
68///     Freshness::Stale => println!("Needs rebuild"),
69///     _ => {}
70/// }
71/// ```
72#[derive(Debug, Clone, Default)]
73pub struct ContentCache {
74    entries: HashMap<PathBuf, CacheEntry>,
75}
76
77impl ContentCache {
78    /// Create a new empty cache
79    pub fn new() -> Self {
80        Self {
81            entries: HashMap::new(),
82        }
83    }
84
85    /// Create cache with pre-allocated capacity
86    pub fn with_capacity(capacity: usize) -> Self {
87        Self {
88            entries: HashMap::with_capacity(capacity),
89        }
90    }
91
92    /// Register or update a file in the cache
93    pub fn register(&mut self, path: &WorkspaceFilePath, hash: u64, mtime: SystemTime) {
94        let key = path.as_relative().to_path_buf();
95        self.entries.insert(key, CacheEntry::new(hash, mtime));
96    }
97
98    /// Register a file by reading its current mtime (hash provided)
99    pub fn register_with_current_mtime(
100        &mut self,
101        path: &WorkspaceFilePath,
102        hash: u64,
103    ) -> io::Result<()> {
104        let absolute = path.to_absolute();
105        let metadata = fs::metadata(&absolute)?;
106        let mtime = metadata.modified()?;
107        self.register(path, hash, mtime);
108        Ok(())
109    }
110
111    /// Get cache entry for a file
112    pub fn get(&self, path: &WorkspaceFilePath) -> Option<&CacheEntry> {
113        self.entries.get(path.as_relative())
114    }
115
116    /// Check if a file is in the cache
117    pub fn contains(&self, path: &WorkspaceFilePath) -> bool {
118        self.entries.contains_key(path.as_relative())
119    }
120
121    /// Remove a file from the cache
122    pub fn remove(&mut self, path: &WorkspaceFilePath) -> Option<CacheEntry> {
123        self.entries.remove(path.as_relative())
124    }
125
126    /// Check freshness of a file
127    ///
128    /// Compares cached mtime with current file mtime.
129    pub fn check_freshness(&self, path: &WorkspaceFilePath) -> Freshness {
130        let entry = match self.entries.get(path.as_relative()) {
131            Some(e) => e,
132            None => return Freshness::NotCached,
133        };
134
135        let absolute = path.to_absolute();
136        let metadata = match fs::metadata(&absolute) {
137            Ok(m) => m,
138            Err(_) => return Freshness::Missing,
139        };
140
141        let current_mtime = match metadata.modified() {
142            Ok(t) => t,
143            Err(_) => return Freshness::Stale, // Can't get mtime, assume stale
144        };
145
146        if entry.is_fresh(current_mtime) {
147            Freshness::Fresh
148        } else {
149            Freshness::Stale
150        }
151    }
152
153    /// Get all stale files (files that have been modified)
154    pub fn get_stale_files<'a>(
155        &self,
156        paths: &'a [WorkspaceFilePath],
157    ) -> Vec<&'a WorkspaceFilePath> {
158        paths
159            .iter()
160            .filter(|p| self.check_freshness(p) == Freshness::Stale)
161            .collect()
162    }
163
164    /// Clear all entries
165    pub fn clear(&mut self) {
166        self.entries.clear();
167    }
168
169    /// Get number of cached entries
170    pub fn len(&self) -> usize {
171        self.entries.len()
172    }
173
174    /// Check if cache is empty
175    pub fn is_empty(&self) -> bool {
176        self.entries.is_empty()
177    }
178
179    /// Iterate over all entries
180    pub fn iter(&self) -> impl Iterator<Item = (&PathBuf, &CacheEntry)> {
181        self.entries.iter()
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    fn make_path(relative: &str) -> WorkspaceFilePath {
190        WorkspaceFilePath::new_for_test(relative, "/workspace", "test_crate")
191    }
192
193    #[test]
194    fn test_register_and_get() {
195        let mut cache = ContentCache::new();
196        let path = make_path("src/lib.rs");
197        let mtime = SystemTime::now();
198
199        cache.register(&path, 12345, mtime);
200
201        let entry = cache.get(&path).unwrap();
202        assert_eq!(entry.hash, 12345);
203        assert_eq!(entry.mtime, mtime);
204    }
205
206    #[test]
207    fn test_contains() {
208        let mut cache = ContentCache::new();
209        let path = make_path("src/lib.rs");
210
211        assert!(!cache.contains(&path));
212
213        cache.register(&path, 12345, SystemTime::now());
214
215        assert!(cache.contains(&path));
216    }
217
218    #[test]
219    fn test_remove() {
220        let mut cache = ContentCache::new();
221        let path = make_path("src/lib.rs");
222
223        cache.register(&path, 12345, SystemTime::now());
224        assert!(cache.contains(&path));
225
226        let removed = cache.remove(&path);
227        assert!(removed.is_some());
228        assert!(!cache.contains(&path));
229    }
230
231    #[test]
232    fn test_freshness_not_cached() {
233        let cache = ContentCache::new();
234        let path = make_path("src/lib.rs");
235
236        assert_eq!(cache.check_freshness(&path), Freshness::NotCached);
237    }
238
239    #[test]
240    fn test_entry_is_fresh() {
241        let mtime = SystemTime::now();
242        let entry = CacheEntry::new(12345, mtime);
243
244        assert!(entry.is_fresh(mtime));
245
246        // Different mtime should not be fresh
247        let different_mtime = mtime + std::time::Duration::from_secs(1);
248        assert!(!entry.is_fresh(different_mtime));
249    }
250}