Skip to main content

lechange_core/
file_ops.rs

1//! File operations with symlink detection and caching
2
3use crate::error::{Error, Result};
4use parking_lot::RwLock;
5use std::collections::HashMap;
6use std::path::{Path, PathBuf};
7
8/// Bounded cache for symlink detection results.
9///
10/// Eviction is clear-on-full: when the map reaches `max_size` it is emptied
11/// wholesale. That is deliberate — entries are cheap to recompute (one lstat)
12/// and a CI run rarely revisits evicted paths, so tracking access order would
13/// cost more than it saves.
14struct BoundedCache<K, V> {
15    map: HashMap<K, V>,
16    max_size: usize,
17}
18
19impl<K: Eq + std::hash::Hash + Clone, V> BoundedCache<K, V> {
20    fn new(max_size: usize) -> Self {
21        Self {
22            map: HashMap::with_capacity(max_size),
23            max_size,
24        }
25    }
26
27    fn get(&self, key: &K) -> Option<&V> {
28        self.map.get(key)
29    }
30
31    fn put(&mut self, key: K, value: V) {
32        if self.map.len() >= self.max_size {
33            self.map.clear();
34        }
35        self.map.insert(key, value);
36    }
37}
38
39/// File operations handler with symlink caching
40pub struct FileOps {
41    symlink_cache: RwLock<BoundedCache<PathBuf, bool>>,
42}
43
44impl FileOps {
45    /// Create a new file operations handler
46    pub fn new() -> Self {
47        Self::with_cache_size(1024)
48    }
49
50    /// Create with custom cache size
51    pub fn with_cache_size(cache_size: usize) -> Self {
52        Self {
53            symlink_cache: RwLock::new(BoundedCache::new(cache_size)),
54        }
55    }
56
57    /// Check if a path is a symlink (sync, called from Rayon workers)
58    pub fn is_symlink_sync(&self, path: &Path) -> Result<bool> {
59        let path_buf = path.to_path_buf();
60
61        // Check cache first
62        {
63            let cache = self.symlink_cache.read();
64            if let Some(&cached) = cache.get(&path_buf) {
65                return Ok(cached);
66            }
67        }
68
69        // Check disk with std::fs
70        let metadata = std::fs::symlink_metadata(&path_buf).map_err(Error::Io)?;
71
72        let is_link = metadata.file_type().is_symlink();
73
74        // Cache result
75        {
76            let mut cache = self.symlink_cache.write();
77            cache.put(path_buf, is_link);
78        }
79
80        Ok(is_link)
81    }
82}
83
84impl Default for FileOps {
85    fn default() -> Self {
86        Self::new()
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use std::fs;
94    use tempfile::TempDir;
95
96    #[test]
97    fn test_is_symlink_sync() {
98        let dir = TempDir::new().unwrap();
99        let file_path = dir.path().join("regular_file.txt");
100        fs::write(&file_path, "content").unwrap();
101
102        let ops = FileOps::new();
103        let is_link = ops.is_symlink_sync(&file_path).unwrap();
104        assert!(!is_link);
105    }
106
107    #[test]
108    fn test_symlink_cache() {
109        let dir = TempDir::new().unwrap();
110        let file_path = dir.path().join("test.txt");
111        fs::write(&file_path, "content").unwrap();
112
113        let ops = FileOps::new();
114
115        // First call - not cached
116        let result1 = ops.is_symlink_sync(&file_path).unwrap();
117
118        // Second call - should be cached
119        let result2 = ops.is_symlink_sync(&file_path).unwrap();
120
121        assert_eq!(result1, result2);
122        assert!(!result1);
123    }
124
125    #[test]
126    fn test_cache_eviction_clears_when_full() {
127        let dir = TempDir::new().unwrap();
128        let ops = FileOps::with_cache_size(2);
129
130        for i in 0..5 {
131            let p = dir.path().join(format!("f{i}.txt"));
132            fs::write(&p, "content").unwrap();
133            assert!(!ops.is_symlink_sync(&p).unwrap());
134        }
135
136        // Entries remain resolvable after eviction cycles
137        let p0 = dir.path().join("f0.txt");
138        assert!(!ops.is_symlink_sync(&p0).unwrap());
139    }
140}