Skip to main content

dux_core/cache/
mod.rs

1mod metadata;
2
3pub use metadata::{CACHE_MAGIC, CACHE_VERSION, CacheMetadata, CachedScanConfig};
4
5use std::collections::hash_map::DefaultHasher;
6use std::fs::{self, File};
7use std::hash::{Hash, Hasher};
8use std::io::{Read, Write};
9use std::path::{Path, PathBuf};
10use std::time::SystemTime;
11
12use crate::Result;
13use crate::tree::DiskTree;
14
15/// Get the cache file path for a given root directory
16pub fn cache_path_for(root: &Path, cache_dir: &Path) -> PathBuf {
17    let hash = hash_path(root);
18    cache_dir.join(format!("{:016x}.dux", hash))
19}
20
21/// Hash a path to a u64 for cache filename
22fn hash_path(path: &Path) -> u64 {
23    let mut hasher = DefaultHasher::new();
24    path.hash(&mut hasher);
25    hasher.finish()
26}
27
28/// Save a tree to cache file
29///
30/// File format:
31/// [4B] Magic "DUXC"
32/// [4B] Version (u32 LE)
33/// [4B] Metadata length (u32 LE)
34/// [NB] Metadata (postcard)
35/// [4B] Tree length (u32 LE)
36/// [MB] Tree (postcard)
37/// [4B] CRC32 checksum of all preceding bytes
38pub fn save_cache(path: &Path, tree: &DiskTree, meta: &CacheMetadata) -> Result<()> {
39    // Ensure parent directory exists
40    if let Some(parent) = path.parent() {
41        fs::create_dir_all(parent)?;
42    }
43
44    let mut data = Vec::new();
45
46    // Magic
47    data.extend_from_slice(&CACHE_MAGIC);
48
49    // Version
50    data.extend_from_slice(&CACHE_VERSION.to_le_bytes());
51
52    // Metadata
53    let meta_bytes = postcard::to_allocvec(meta)
54        .map_err(|e| crate::DuxError::Cache(format!("Failed to serialize metadata: {}", e)))?;
55    data.extend_from_slice(&(meta_bytes.len() as u32).to_le_bytes());
56    data.extend_from_slice(&meta_bytes);
57
58    // Tree
59    let tree_bytes = postcard::to_allocvec(tree)
60        .map_err(|e| crate::DuxError::Cache(format!("Failed to serialize tree: {}", e)))?;
61    data.extend_from_slice(&(tree_bytes.len() as u32).to_le_bytes());
62    data.extend_from_slice(&tree_bytes);
63
64    // CRC32 checksum
65    let checksum = crc32fast::hash(&data);
66    data.extend_from_slice(&checksum.to_le_bytes());
67
68    // Write atomically by writing to temp file then renaming
69    let temp_path = path.with_extension("tmp");
70    let mut file = File::create(&temp_path)?;
71    file.write_all(&data)?;
72    file.sync_all()?;
73    drop(file);
74
75    fs::rename(&temp_path, path)?;
76
77    Ok(())
78}
79
80/// Load a tree from cache file
81pub fn load_cache(path: &Path) -> Result<(CacheMetadata, DiskTree)> {
82    let mut file = File::open(path)?;
83
84    let mut data = Vec::new();
85    file.read_to_end(&mut data)?;
86
87    // Need at least: magic(4) + version(4) + meta_len(4) + tree_len(4) + checksum(4) = 20 bytes
88    if data.len() < 20 {
89        return Err(crate::DuxError::Cache("Cache file too small".to_string()));
90    }
91
92    // Verify checksum (last 4 bytes)
93    let checksum_offset = data.len() - 4;
94    let stored_checksum = u32::from_le_bytes([
95        data[checksum_offset],
96        data[checksum_offset + 1],
97        data[checksum_offset + 2],
98        data[checksum_offset + 3],
99    ]);
100    let computed_checksum = crc32fast::hash(&data[..checksum_offset]);
101    if stored_checksum != computed_checksum {
102        return Err(crate::DuxError::Cache(
103            "Cache checksum mismatch".to_string(),
104        ));
105    }
106
107    let mut offset = 0;
108
109    // Magic
110    let magic: [u8; 4] = data[offset..offset + 4].try_into().unwrap();
111    if magic != CACHE_MAGIC {
112        return Err(crate::DuxError::Cache("Invalid cache magic".to_string()));
113    }
114    offset += 4;
115
116    // Version
117    let version = u32::from_le_bytes(data[offset..offset + 4].try_into().unwrap());
118    if version != CACHE_VERSION {
119        return Err(crate::DuxError::Cache(format!(
120            "Cache version mismatch: expected {}, got {}",
121            CACHE_VERSION, version
122        )));
123    }
124    offset += 4;
125
126    // Metadata
127    let meta_len = u32::from_le_bytes(data[offset..offset + 4].try_into().unwrap()) as usize;
128    offset += 4;
129    if offset + meta_len > checksum_offset {
130        return Err(crate::DuxError::Cache(
131            "Invalid metadata length".to_string(),
132        ));
133    }
134    let meta: CacheMetadata = postcard::from_bytes(&data[offset..offset + meta_len])
135        .map_err(|e| crate::DuxError::Cache(format!("Failed to deserialize metadata: {}", e)))?;
136    offset += meta_len;
137
138    // Tree
139    let tree_len = u32::from_le_bytes(data[offset..offset + 4].try_into().unwrap()) as usize;
140    offset += 4;
141    if offset + tree_len > checksum_offset {
142        return Err(crate::DuxError::Cache("Invalid tree length".to_string()));
143    }
144    let mut tree: DiskTree = postcard::from_bytes(&data[offset..offset + tree_len])
145        .map_err(|e| crate::DuxError::Cache(format!("Failed to deserialize tree: {}", e)))?;
146
147    // Reconstruct paths (not serialized to save space)
148    tree.rebuild_paths();
149
150    Ok((meta, tree))
151}
152
153/// Check if a cache is still valid for the given configuration
154pub fn is_cache_valid(meta: &CacheMetadata, root: &Path, config: &CachedScanConfig) -> bool {
155    // Config must match
156    if meta.config != *config {
157        return false;
158    }
159
160    // Root path must match
161    if meta.root_path != root {
162        return false;
163    }
164
165    // Check root directory mtime hasn't changed
166    // This is a quick heuristic - if the root dir mtime changed, something in it changed
167    if let Ok(root_meta) = fs::metadata(root) {
168        if let Ok(mtime) = root_meta.modified() {
169            if mtime != meta.root_mtime {
170                return false;
171            }
172        } else {
173            return false;
174        }
175    } else {
176        return false;
177    }
178
179    true
180}
181
182/// Get the modification time of a path
183pub fn get_mtime(path: &Path) -> Option<SystemTime> {
184    fs::metadata(path).ok()?.modified().ok()
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use std::time::Duration;
191    use tempfile::TempDir;
192
193    #[test]
194    fn test_cache_path_generation() {
195        let cache_dir = PathBuf::from("/tmp/dux-cache");
196        let path1 = cache_path_for(Path::new("/home/user/data"), &cache_dir);
197        let path2 = cache_path_for(Path::new("/home/user/other"), &cache_dir);
198
199        assert!(path1.to_string_lossy().ends_with(".dux"));
200        assert!(path2.to_string_lossy().ends_with(".dux"));
201        assert_ne!(path1, path2);
202    }
203
204    #[test]
205    fn test_save_load_cache() {
206        let temp = TempDir::new().unwrap();
207        let cache_path = temp.path().join("test.dux");
208
209        // Create a simple tree
210        let tree = DiskTree::new(temp.path().to_path_buf());
211
212        let meta = CacheMetadata {
213            version: CACHE_VERSION,
214            root_path: temp.path().to_path_buf(),
215            scan_time: SystemTime::now(),
216            root_mtime: SystemTime::now() - Duration::from_secs(100),
217            total_size: 1024,
218            node_count: 1,
219            config: CachedScanConfig {
220                follow_symlinks: false,
221                same_filesystem: true,
222                max_depth: None,
223            },
224        };
225
226        // Save
227        save_cache(&cache_path, &tree, &meta).unwrap();
228
229        // Load
230        let (loaded_meta, loaded_tree) = load_cache(&cache_path).unwrap();
231
232        assert_eq!(loaded_meta.total_size, 1024);
233        assert_eq!(loaded_tree.len(), 1);
234    }
235
236    #[test]
237    fn test_paths_reconstructed_after_load() {
238        use crate::tree::NodeKind;
239
240        let temp = TempDir::new().unwrap();
241        let cache_path = temp.path().join("test.dux");
242        let root_path = temp.path().to_path_buf();
243
244        // Create a tree with nested structure
245        let mut tree = DiskTree::new(root_path.clone());
246        let subdir_id = tree.add_node(
247            "subdir".to_string(),
248            NodeKind::Directory,
249            root_path.join("subdir"),
250            crate::tree::NodeId::ROOT,
251        );
252        let file_id = tree.add_node(
253            "file.txt".to_string(),
254            NodeKind::File,
255            root_path.join("subdir").join("file.txt"),
256            subdir_id,
257        );
258
259        // Verify original paths
260        assert_eq!(tree.get(subdir_id).unwrap().path, root_path.join("subdir"));
261        assert_eq!(
262            tree.get(file_id).unwrap().path,
263            root_path.join("subdir").join("file.txt")
264        );
265
266        let meta = CacheMetadata {
267            version: CACHE_VERSION,
268            root_path: root_path.clone(),
269            scan_time: SystemTime::now(),
270            root_mtime: SystemTime::now(),
271            total_size: 0,
272            node_count: 3,
273            config: CachedScanConfig {
274                follow_symlinks: false,
275                same_filesystem: true,
276                max_depth: None,
277            },
278        };
279
280        // Save and reload
281        save_cache(&cache_path, &tree, &meta).unwrap();
282        let (_, loaded_tree) = load_cache(&cache_path).unwrap();
283
284        // Verify paths were reconstructed correctly
285        assert_eq!(loaded_tree.root().path, root_path);
286        assert_eq!(
287            loaded_tree.get(subdir_id).unwrap().path,
288            root_path.join("subdir")
289        );
290        assert_eq!(
291            loaded_tree.get(file_id).unwrap().path,
292            root_path.join("subdir").join("file.txt")
293        );
294    }
295}