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
15pub 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
21fn hash_path(path: &Path) -> u64 {
23 let mut hasher = DefaultHasher::new();
24 path.hash(&mut hasher);
25 hasher.finish()
26}
27
28pub fn save_cache(path: &Path, tree: &DiskTree, meta: &CacheMetadata) -> Result<()> {
39 if let Some(parent) = path.parent() {
41 fs::create_dir_all(parent)?;
42 }
43
44 let mut data = Vec::new();
45
46 data.extend_from_slice(&CACHE_MAGIC);
48
49 data.extend_from_slice(&CACHE_VERSION.to_le_bytes());
51
52 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 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 let checksum = crc32fast::hash(&data);
66 data.extend_from_slice(&checksum.to_le_bytes());
67
68 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
80pub 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 if data.len() < 20 {
89 return Err(crate::DuxError::Cache("Cache file too small".to_string()));
90 }
91
92 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 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 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 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 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 tree.rebuild_paths();
149
150 Ok((meta, tree))
151}
152
153pub fn is_cache_valid(meta: &CacheMetadata, root: &Path, config: &CachedScanConfig) -> bool {
155 if meta.config != *config {
157 return false;
158 }
159
160 if meta.root_path != root {
162 return false;
163 }
164
165 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
182pub 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 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_cache(&cache_path, &tree, &meta).unwrap();
228
229 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 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 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_cache(&cache_path, &tree, &meta).unwrap();
282 let (_, loaded_tree) = load_cache(&cache_path).unwrap();
283
284 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}