Skip to main content

path_rs/
cache.rs

1//! Optional discovery caching.
2//!
3//! Caching is **opt-in** and must never be used as a security boundary.
4//! Stale entries are performance artifacts, not authoritative filesystem state.
5
6use crate::error::PathError;
7use crate::metadata::FileEntry;
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use std::sync::Mutex;
11use std::time::{Duration, SystemTime};
12
13#[cfg(feature = "search")]
14use crate::search::SearchRequest;
15
16/// Whether / how a search should consult the cache.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18#[non_exhaustive]
19pub enum CachePolicy {
20    /// Do not read or write the cache.
21    #[default]
22    Bypass,
23    /// Return a fresh cached value when present; otherwise scan and store.
24    ReadThrough,
25    /// Ignore any existing value, scan, and store the new result.
26    Refresh,
27}
28
29/// Cache storage mode.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31#[non_exhaustive]
32pub enum CacheMode {
33    /// Caching disabled.
34    #[default]
35    Disabled,
36    /// In-memory process-local cache.
37    Memory,
38    /// Persistent on-disk cache (requires `persistent-cache` feature).
39    Persistent,
40}
41
42/// Configuration for a discovery cache instance.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct CacheOptions {
45    /// Storage mode.
46    pub mode: CacheMode,
47    /// Optional time-to-live for entries.
48    pub ttl: Option<Duration>,
49    /// Maximum number of cached keys.
50    pub max_entries: usize,
51    /// Reserved for future metadata revalidation (directory mtime, etc.).
52    ///
53    /// Directory timestamps are **not** a perfect invalidation mechanism and are
54    /// not used as a security signal even when enabled in future versions.
55    pub validate_metadata: bool,
56}
57
58impl Default for CacheOptions {
59    fn default() -> Self {
60        Self {
61            mode: CacheMode::Disabled,
62            ttl: None,
63            max_entries: 128,
64            validate_metadata: false,
65        }
66    }
67}
68
69impl CacheOptions {
70    /// Create default options (`Self::default()`).
71    pub fn new() -> Self {
72        Self::default()
73    }
74}
75
76/// Cache key capturing all inputs that affect discovery results.
77#[derive(Debug, Clone, PartialEq, Eq, Hash)]
78pub struct CacheKey {
79    /// Search / list root.
80    pub root: PathBuf,
81    /// Glob patterns (empty for pure listing cache use).
82    pub patterns: Vec<String>,
83    /// Exclude patterns.
84    pub exclude_patterns: Vec<String>,
85    /// Recursive walk.
86    pub recursive: bool,
87    /// Follow symlinks.
88    pub follow_symlinks: bool,
89    /// Include hidden.
90    pub include_hidden: bool,
91    /// Include files.
92    pub include_files: bool,
93    /// Include directories.
94    pub include_directories: bool,
95    /// Include symlinks.
96    pub include_symlinks: bool,
97    /// Max depth.
98    pub max_depth: Option<usize>,
99    /// Sort mode used for the discovery result.
100    pub sort: crate::metadata::SortMode,
101}
102
103impl CacheKey {
104    /// Build a key from a search request.
105    #[cfg(feature = "search")]
106    pub fn from_search(request: &SearchRequest) -> Self {
107        Self {
108            root: request.root.clone(),
109            patterns: request.patterns.clone(),
110            exclude_patterns: request.exclude_patterns.clone(),
111            recursive: request.options.recursive,
112            follow_symlinks: request.options.follow_symlinks,
113            include_hidden: request.options.include_hidden,
114            include_files: request.options.include_files,
115            include_directories: request.options.include_directories,
116            include_symlinks: request.options.include_symlinks,
117            max_depth: request.options.max_depth,
118            sort: request.options.sort,
119        }
120    }
121}
122
123/// Cached discovery result.
124#[derive(Debug, Clone)]
125pub struct CacheValue {
126    /// Cached entries.
127    pub entries: Vec<FileEntry>,
128    /// When the value was stored.
129    pub stored_at: SystemTime,
130    /// Optional TTL override for this value.
131    pub ttl: Option<Duration>,
132}
133
134impl CacheValue {
135    /// Returns true if the entry is past its TTL.
136    pub fn is_expired(&self) -> bool {
137        let Some(ttl) = self.ttl else {
138            return false;
139        };
140        self.stored_at.elapsed().map(|e| e > ttl).unwrap_or(true)
141    }
142
143    /// Returns true if expired given a default TTL when `self.ttl` is `None`.
144    pub fn is_expired_with_default(&self, default_ttl: Option<Duration>) -> bool {
145        let ttl = self.ttl.or(default_ttl);
146        let Some(ttl) = ttl else {
147            return false;
148        };
149        self.stored_at.elapsed().map(|e| e > ttl).unwrap_or(true)
150    }
151}
152
153/// Trait for discovery result caches.
154pub trait DiscoveryCache: Send + Sync {
155    /// Get a cached value by key.
156    fn get(&self, key: &CacheKey) -> Result<Option<CacheValue>, PathError>;
157    /// Store a value.
158    fn put(&self, key: CacheKey, value: CacheValue) -> Result<(), PathError>;
159    /// Invalidate all keys whose root equals or is under `root`.
160    fn invalidate(&self, root: &Path) -> Result<(), PathError>;
161    /// Clear the entire cache.
162    fn clear(&self) -> Result<(), PathError>;
163}
164
165/// Thread-safe in-memory discovery cache.
166#[derive(Debug)]
167pub struct MemoryCache {
168    options: CacheOptions,
169    inner: Mutex<HashMap<CacheKey, CacheValue>>,
170}
171
172impl MemoryCache {
173    /// Create a new memory cache with the given options.
174    ///
175    /// If `mode` is [`CacheMode::Disabled`], get always returns `None` and put is a no-op.
176    pub fn new(options: CacheOptions) -> Self {
177        Self {
178            options,
179            inner: Mutex::new(HashMap::new()),
180        }
181    }
182
183    /// Number of entries currently stored (for tests).
184    pub fn len(&self) -> Result<usize, PathError> {
185        let guard = self
186            .inner
187            .lock()
188            .map_err(|_| PathError::cache("memory cache lock poisoned"))?;
189        Ok(guard.len())
190    }
191
192    /// Returns true if the cache is empty.
193    pub fn is_empty(&self) -> Result<bool, PathError> {
194        Ok(self.len()? == 0)
195    }
196
197    #[cfg(feature = "persistent-cache")]
198    pub(crate) fn snapshot(&self) -> Result<Vec<(CacheKey, CacheValue)>, PathError> {
199        let guard = self
200            .inner
201            .lock()
202            .map_err(|_| PathError::cache("memory cache lock poisoned"))?;
203        Ok(guard.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
204    }
205}
206
207impl DiscoveryCache for MemoryCache {
208    fn get(&self, key: &CacheKey) -> Result<Option<CacheValue>, PathError> {
209        if self.options.mode == CacheMode::Disabled {
210            return Ok(None);
211        }
212        let mut guard = self
213            .inner
214            .lock()
215            .map_err(|_| PathError::cache("memory cache lock poisoned"))?;
216
217        if let Some(value) = guard.get(key) {
218            if value.is_expired_with_default(self.options.ttl) {
219                guard.remove(key);
220                return Ok(None);
221            }
222            return Ok(Some(value.clone()));
223        }
224        Ok(None)
225    }
226
227    fn put(&self, key: CacheKey, mut value: CacheValue) -> Result<(), PathError> {
228        if self.options.mode == CacheMode::Disabled {
229            return Ok(());
230        }
231        if value.ttl.is_none() {
232            value.ttl = self.options.ttl;
233        }
234        let mut guard = self
235            .inner
236            .lock()
237            .map_err(|_| PathError::cache("memory cache lock poisoned"))?;
238
239        if guard.len() >= self.options.max_entries && !guard.contains_key(&key) {
240            // Evict an arbitrary entry to enforce the bound (not LRU).
241            if let Some(evict) = guard.keys().next().cloned() {
242                guard.remove(&evict);
243            }
244        }
245        guard.insert(key, value);
246        Ok(())
247    }
248
249    fn invalidate(&self, root: &Path) -> Result<(), PathError> {
250        let mut guard = self
251            .inner
252            .lock()
253            .map_err(|_| PathError::cache("memory cache lock poisoned"))?;
254        guard.retain(|k, _| k.root != root && !k.root.starts_with(root));
255        Ok(())
256    }
257
258    fn clear(&self) -> Result<(), PathError> {
259        let mut guard = self
260            .inner
261            .lock()
262            .map_err(|_| PathError::cache("memory cache lock poisoned"))?;
263        guard.clear();
264        Ok(())
265    }
266}
267
268/// Persistent on-disk cache (feature `persistent-cache`).
269///
270/// Schema is versioned. Corrupt or mismatched files are treated as a miss
271/// (and optionally replaced on next put). Atomic writes use a temp file + rename.
272///
273/// Store persistent cache under the platform cache directory, not beside source files.
274#[cfg(feature = "persistent-cache")]
275#[derive(Debug)]
276pub struct PersistentCache {
277    path: PathBuf,
278    options: CacheOptions,
279    memory: MemoryCache,
280}
281
282#[cfg(feature = "persistent-cache")]
283mod persistent_schema {
284    use serde::{Deserialize, Serialize};
285
286    pub const SCHEMA_VERSION: u32 = 1;
287
288    #[derive(Serialize, Deserialize)]
289    pub struct PersistentCacheFile {
290        pub schema_version: u32,
291        pub entries: Vec<PersistentCacheRecord>,
292    }
293
294    #[derive(Serialize, Deserialize)]
295    pub struct PersistentCacheRecord {
296        pub key: PersistentKey,
297        pub paths: Vec<String>,
298        pub stored_at_epoch_ms: u128,
299        pub ttl_ms: Option<u128>,
300    }
301
302    #[derive(Serialize, Deserialize, Clone)]
303    pub struct PersistentKey {
304        pub root: String,
305        pub patterns: Vec<String>,
306        pub exclude_patterns: Vec<String>,
307        pub recursive: bool,
308        pub follow_symlinks: bool,
309        pub include_hidden: bool,
310        pub include_files: bool,
311        pub include_directories: bool,
312        pub include_symlinks: bool,
313        pub max_depth: Option<usize>,
314        pub sort: u8,
315    }
316}
317
318#[cfg(feature = "persistent-cache")]
319fn sort_to_u8(sort: crate::metadata::SortMode) -> u8 {
320    use crate::metadata::SortMode;
321    match sort {
322        SortMode::None => 0,
323        SortMode::Path => 1,
324        SortMode::Name => 2,
325        SortMode::DirsFirst => 3,
326    }
327}
328
329#[cfg(feature = "persistent-cache")]
330fn sort_from_u8(v: u8) -> crate::metadata::SortMode {
331    use crate::metadata::SortMode;
332    match v {
333        0 => SortMode::None,
334        2 => SortMode::Name,
335        3 => SortMode::DirsFirst,
336        _ => SortMode::Path,
337    }
338}
339
340#[cfg(feature = "persistent-cache")]
341impl PersistentCache {
342    /// Open or create a persistent cache file under the platform cache directory.
343    pub fn open(app_name: &str, file_name: &str, options: CacheOptions) -> Result<Self, PathError> {
344        use std::fs;
345        let dir = crate::dirs::cache_dir(app_name)?;
346        fs::create_dir_all(&dir).map_err(|e| PathError::filesystem(&dir, e))?;
347        let path = dir.join(file_name);
348        let memory = MemoryCache::new(CacheOptions {
349            mode: CacheMode::Memory,
350            ttl: options.ttl,
351            max_entries: options.max_entries,
352            validate_metadata: options.validate_metadata,
353        });
354        let cache = Self {
355            path,
356            options,
357            memory,
358        };
359        let _ = cache.load_into_memory();
360        Ok(cache)
361    }
362
363    /// Path to the on-disk cache file.
364    pub fn path(&self) -> &Path {
365        &self.path
366    }
367
368    fn load_into_memory(&self) -> Result<(), PathError> {
369        use persistent_schema::{PersistentCacheFile, SCHEMA_VERSION};
370        use std::fs;
371
372        if !self.path.exists() {
373            return Ok(());
374        }
375        let data = fs::read(&self.path).map_err(|e| PathError::filesystem(&self.path, e))?;
376        let parsed: PersistentCacheFile = match serde_json::from_slice(&data) {
377            Ok(v) => v,
378            Err(_) => return Ok(()),
379        };
380        if parsed.schema_version != SCHEMA_VERSION {
381            return Ok(());
382        }
383        for rec in parsed.entries {
384            let key = CacheKey {
385                root: PathBuf::from(&rec.key.root),
386                patterns: rec.key.patterns,
387                exclude_patterns: rec.key.exclude_patterns,
388                recursive: rec.key.recursive,
389                follow_symlinks: rec.key.follow_symlinks,
390                include_hidden: rec.key.include_hidden,
391                include_files: rec.key.include_files,
392                include_directories: rec.key.include_directories,
393                include_symlinks: rec.key.include_symlinks,
394                max_depth: rec.key.max_depth,
395                sort: sort_from_u8(rec.key.sort),
396            };
397            let stored_at =
398                SystemTime::UNIX_EPOCH + Duration::from_millis(rec.stored_at_epoch_ms as u64);
399            let ttl = rec.ttl_ms.map(|ms| Duration::from_millis(ms as u64));
400            let entries = rec
401                .paths
402                .into_iter()
403                .map(|p| FileEntry::new(PathBuf::from(p), crate::metadata::EntryKind::Other))
404                .collect();
405            let value = CacheValue {
406                entries,
407                stored_at,
408                ttl,
409            };
410            if !value.is_expired_with_default(self.options.ttl) {
411                self.memory.put(key, value)?;
412            }
413        }
414        Ok(())
415    }
416
417    fn flush(&self) -> Result<(), PathError> {
418        use persistent_schema::{
419            PersistentCacheFile, PersistentCacheRecord, PersistentKey, SCHEMA_VERSION,
420        };
421        use std::fs;
422        use std::io::Write;
423
424        let snapshot = self.memory.snapshot()?;
425        let mut records = Vec::new();
426        for (key, value) in snapshot {
427            if value.is_expired_with_default(self.options.ttl) {
428                continue;
429            }
430            let stored_at_epoch_ms = value
431                .stored_at
432                .duration_since(SystemTime::UNIX_EPOCH)
433                .map(|d| d.as_millis())
434                .unwrap_or(0);
435            records.push(PersistentCacheRecord {
436                key: PersistentKey {
437                    root: key.root.to_string_lossy().into_owned(),
438                    patterns: key.patterns.clone(),
439                    exclude_patterns: key.exclude_patterns.clone(),
440                    recursive: key.recursive,
441                    follow_symlinks: key.follow_symlinks,
442                    include_hidden: key.include_hidden,
443                    include_files: key.include_files,
444                    include_directories: key.include_directories,
445                    include_symlinks: key.include_symlinks,
446                    max_depth: key.max_depth,
447                    sort: sort_to_u8(key.sort),
448                },
449                paths: value
450                    .entries
451                    .iter()
452                    .map(|e| e.path.to_string_lossy().into_owned())
453                    .collect(),
454                stored_at_epoch_ms,
455                ttl_ms: value.ttl.map(|d| d.as_millis()),
456            });
457        }
458
459        let file = PersistentCacheFile {
460            schema_version: SCHEMA_VERSION,
461            entries: records,
462        };
463        let json = serde_json::to_vec_pretty(&file)
464            .map_err(|e| PathError::cache(format!("serialize failed: {e}")))?;
465
466        let tmp_path = self.path.with_extension("json.tmp");
467        {
468            let mut f =
469                fs::File::create(&tmp_path).map_err(|e| PathError::filesystem(&tmp_path, e))?;
470            f.write_all(&json)
471                .map_err(|e| PathError::filesystem(&tmp_path, e))?;
472            f.sync_all()
473                .map_err(|e| PathError::filesystem(&tmp_path, e))?;
474        }
475        fs::rename(&tmp_path, &self.path).map_err(|e| PathError::filesystem(&self.path, e))?;
476        Ok(())
477    }
478}
479
480#[cfg(feature = "persistent-cache")]
481impl DiscoveryCache for PersistentCache {
482    fn get(&self, key: &CacheKey) -> Result<Option<CacheValue>, PathError> {
483        if matches!(self.options.mode, CacheMode::Disabled) {
484            return Ok(None);
485        }
486        self.memory.get(key)
487    }
488
489    fn put(&self, key: CacheKey, value: CacheValue) -> Result<(), PathError> {
490        if matches!(self.options.mode, CacheMode::Disabled) {
491            return Ok(());
492        }
493        self.memory.put(key, value)?;
494        self.flush()
495    }
496
497    fn invalidate(&self, root: &Path) -> Result<(), PathError> {
498        self.memory.invalidate(root)?;
499        self.flush()
500    }
501
502    fn clear(&self) -> Result<(), PathError> {
503        use std::fs;
504        self.memory.clear()?;
505        if self.path.exists() {
506            fs::remove_file(&self.path).map_err(|e| PathError::filesystem(&self.path, e))?;
507        }
508        Ok(())
509    }
510}