Skip to main content

gity_daemon/
fsmonitor_cache.rs

1//! Memory-mapped fsmonitor cache for zero-copy response delivery.
2//!
3//! This module provides a file-based cache that allows the fsmonitor-helper
4//! to read snapshot data directly without IPC roundtrip to the daemon.
5//!
6//! File format:
7//! ```text
8//! +----------------+
9//! | Header (24B)   |
10//! |  - magic (4B)  |  "GITY"
11//! |  - version (1B)|  1
12//! |  - reserved(3B)|  0
13//! |  - gen (8B)    |  generation counter (little-endian)
14//! |  - count (4B)  |  number of paths (little-endian)
15//! |  - total_len(4B)| total bytes of path data (little-endian)
16//! +----------------+
17//! | Path entries   |
18//! |  - len (2B)    |  path length (little-endian)
19//! |  - path (var)  |  UTF-8 path bytes
20//! +----------------+
21//! ```
22
23use 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
33/// Memory-mapped fsmonitor cache for fast snapshot access.
34pub struct FsMonitorCache {
35    cache_dir: PathBuf,
36}
37
38impl FsMonitorCache {
39    /// Create a new cache with the given directory.
40    pub fn new(cache_dir: PathBuf) -> io::Result<Self> {
41        fs::create_dir_all(&cache_dir)?;
42        Ok(Self { cache_dir })
43    }
44
45    /// Generate cache file path for a repository.
46    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    /// Write a snapshot to the cache atomically.
54    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        // Calculate total size needed
59        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        // Write to temp file
67        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        // Write header
76        buffer.extend_from_slice(MAGIC);
77        buffer.push(VERSION);
78        buffer.extend_from_slice(&[0u8; 3]); // reserved
79        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        // Write paths
84        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        // Atomic rename
96        fs::rename(&temp_path, &cache_path)?;
97
98        Ok(())
99    }
100
101    /// Read a snapshot from the cache.
102    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        // Validate header
119        if &buffer[0..4] != MAGIC {
120            return Ok(None);
121        }
122        if buffer[4] != VERSION {
123            return Ok(None);
124        }
125
126        // Parse header
127        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        // Parse paths
132        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    /// Read only the generation number from cache (fast path).
158    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        // Validate magic
173        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    /// Remove cached snapshot for a repository.
182    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}