gity_daemon/
fsmonitor_cache.rs1use gity_ipc::FsMonitorSnapshot;
24use sha1::{Digest, Sha1};
25use std::fs::{self, File, OpenOptions};
26use std::io::{self, Read, Write};
27use std::path::{Path, PathBuf};
28
29const MAGIC: &[u8; 4] = b"GITY";
30const VERSION: u8 = 1;
31const HEADER_SIZE: usize = 24;
32
33pub struct FsMonitorCache {
35 cache_dir: PathBuf,
36}
37
38impl FsMonitorCache {
39 pub fn new(cache_dir: PathBuf) -> io::Result<Self> {
41 fs::create_dir_all(&cache_dir)?;
42 Ok(Self { cache_dir })
43 }
44
45 fn cache_path(&self, repo_path: &Path) -> PathBuf {
47 let mut hasher = Sha1::new();
48 hasher.update(repo_path.to_string_lossy().as_bytes());
49 let hash = hex::encode(hasher.finalize());
50 self.cache_dir.join(format!("{}.cache", &hash[..16]))
51 }
52
53 pub fn write(&self, repo_path: &Path, snapshot: &FsMonitorSnapshot) -> io::Result<()> {
55 let cache_path = self.cache_path(repo_path);
56 let temp_path = cache_path.with_extension("tmp");
57
58 let paths_size: usize = snapshot
60 .dirty_paths
61 .iter()
62 .map(|p| 2 + p.to_string_lossy().len())
63 .sum();
64 let total_size = HEADER_SIZE + paths_size;
65
66 let mut file = OpenOptions::new()
68 .write(true)
69 .create(true)
70 .truncate(true)
71 .open(&temp_path)?;
72
73 let mut buffer = Vec::with_capacity(total_size);
74
75 buffer.extend_from_slice(MAGIC);
77 buffer.push(VERSION);
78 buffer.extend_from_slice(&[0u8; 3]); buffer.extend_from_slice(&snapshot.generation.to_le_bytes());
80 buffer.extend_from_slice(&(snapshot.dirty_paths.len() as u32).to_le_bytes());
81 buffer.extend_from_slice(&(paths_size as u32).to_le_bytes());
82
83 for path in &snapshot.dirty_paths {
85 let path_str = path.to_string_lossy();
86 let path_bytes = path_str.as_bytes();
87 buffer.extend_from_slice(&(path_bytes.len() as u16).to_le_bytes());
88 buffer.extend_from_slice(path_bytes);
89 }
90
91 file.write_all(&buffer)?;
92 file.sync_all()?;
93 drop(file);
94
95 fs::rename(&temp_path, &cache_path)?;
97
98 Ok(())
99 }
100
101 pub fn read(&self, repo_path: &Path) -> io::Result<Option<FsMonitorSnapshot>> {
103 let cache_path = self.cache_path(repo_path);
104
105 let mut file = match File::open(&cache_path) {
106 Ok(f) => f,
107 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
108 Err(e) => return Err(e),
109 };
110
111 let mut buffer = Vec::new();
112 file.read_to_end(&mut buffer)?;
113
114 if buffer.len() < HEADER_SIZE {
115 return Ok(None);
116 }
117
118 if &buffer[0..4] != MAGIC {
120 return Ok(None);
121 }
122 if buffer[4] != VERSION {
123 return Ok(None);
124 }
125
126 let generation = u64::from_le_bytes(buffer[8..16].try_into().unwrap());
128 let count = u32::from_le_bytes(buffer[16..20].try_into().unwrap()) as usize;
129 let _paths_size = u32::from_le_bytes(buffer[20..24].try_into().unwrap()) as usize;
130
131 let mut dirty_paths = Vec::with_capacity(count);
133 let mut offset = HEADER_SIZE;
134
135 for _ in 0..count {
136 if offset + 2 > buffer.len() {
137 return Ok(None);
138 }
139 let path_len = u16::from_le_bytes(buffer[offset..offset + 2].try_into().unwrap()) as usize;
140 offset += 2;
141
142 if offset + path_len > buffer.len() {
143 return Ok(None);
144 }
145 let path_str = String::from_utf8_lossy(&buffer[offset..offset + path_len]);
146 dirty_paths.push(PathBuf::from(path_str.into_owned()));
147 offset += path_len;
148 }
149
150 Ok(Some(FsMonitorSnapshot {
151 repo_path: repo_path.to_path_buf(),
152 dirty_paths,
153 generation,
154 }))
155 }
156
157 pub fn read_generation(&self, repo_path: &Path) -> io::Result<Option<u64>> {
159 let cache_path = self.cache_path(repo_path);
160
161 let mut file = match File::open(&cache_path) {
162 Ok(f) => f,
163 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
164 Err(e) => return Err(e),
165 };
166
167 let mut header = [0u8; HEADER_SIZE];
168 if file.read_exact(&mut header).is_err() {
169 return Ok(None);
170 }
171
172 if &header[0..4] != MAGIC || header[4] != VERSION {
174 return Ok(None);
175 }
176
177 let generation = u64::from_le_bytes(header[8..16].try_into().unwrap());
178 Ok(Some(generation))
179 }
180
181 pub fn remove(&self, repo_path: &Path) -> io::Result<()> {
183 let cache_path = self.cache_path(repo_path);
184 match fs::remove_file(&cache_path) {
185 Ok(()) => Ok(()),
186 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
187 Err(e) => Err(e),
188 }
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195 use tempfile::TempDir;
196
197 #[test]
198 fn test_write_and_read() {
199 let temp = TempDir::new().unwrap();
200 let cache = FsMonitorCache::new(temp.path().join("cache")).unwrap();
201
202 let repo_path = PathBuf::from("/test/repo");
203 let snapshot = FsMonitorSnapshot {
204 repo_path: repo_path.clone(),
205 dirty_paths: vec![
206 PathBuf::from("src/main.rs"),
207 PathBuf::from("Cargo.toml"),
208 ],
209 generation: 42,
210 };
211
212 cache.write(&repo_path, &snapshot).unwrap();
213 let read_back = cache.read(&repo_path).unwrap().unwrap();
214
215 assert_eq!(read_back.generation, 42);
216 assert_eq!(read_back.dirty_paths.len(), 2);
217 assert_eq!(read_back.dirty_paths[0], PathBuf::from("src/main.rs"));
218 assert_eq!(read_back.dirty_paths[1], PathBuf::from("Cargo.toml"));
219 }
220
221 #[test]
222 fn test_read_generation_only() {
223 let temp = TempDir::new().unwrap();
224 let cache = FsMonitorCache::new(temp.path().join("cache")).unwrap();
225
226 let repo_path = PathBuf::from("/test/repo");
227 let snapshot = FsMonitorSnapshot {
228 repo_path: repo_path.clone(),
229 dirty_paths: vec![PathBuf::from("file.txt")],
230 generation: 123,
231 };
232
233 cache.write(&repo_path, &snapshot).unwrap();
234 let gen = cache.read_generation(&repo_path).unwrap().unwrap();
235
236 assert_eq!(gen, 123);
237 }
238
239 #[test]
240 fn test_read_missing() {
241 let temp = TempDir::new().unwrap();
242 let cache = FsMonitorCache::new(temp.path().join("cache")).unwrap();
243
244 let repo_path = PathBuf::from("/nonexistent/repo");
245 assert!(cache.read(&repo_path).unwrap().is_none());
246 assert!(cache.read_generation(&repo_path).unwrap().is_none());
247 }
248
249 #[test]
250 fn test_remove() {
251 let temp = TempDir::new().unwrap();
252 let cache = FsMonitorCache::new(temp.path().join("cache")).unwrap();
253
254 let repo_path = PathBuf::from("/test/repo");
255 let snapshot = FsMonitorSnapshot {
256 repo_path: repo_path.clone(),
257 dirty_paths: vec![],
258 generation: 1,
259 };
260
261 cache.write(&repo_path, &snapshot).unwrap();
262 assert!(cache.read(&repo_path).unwrap().is_some());
263
264 cache.remove(&repo_path).unwrap();
265 assert!(cache.read(&repo_path).unwrap().is_none());
266 }
267}