Skip to main content

provenant/cache/
incremental.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use std::collections::BTreeMap;
5use std::fs;
6use std::io;
7use std::path::{Path, PathBuf};
8use std::time::UNIX_EPOCH;
9
10use serde::{Deserialize, Serialize};
11
12use super::io::write_bytes_atomically;
13use super::locking::with_exclusive_cache_lock;
14use crate::models::{FileInfo, Sha256Digest};
15use crate::utils::hash::calculate_sha256;
16
17const INCREMENTAL_MANIFEST_VERSION: u32 = 2;
18const MANIFEST_FILE_NAME: &str = "manifest.json";
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub struct FileStateFingerprint {
22    pub size: u64,
23    pub modified_seconds: u64,
24    pub modified_nanos: u32,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct IncrementalManifestEntry {
29    pub state: FileStateFingerprint,
30    pub content_sha256: Sha256Digest,
31    pub file_info: FileInfo,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct IncrementalManifest {
36    pub version: u32,
37    pub options_fingerprint: String,
38    pub entries: BTreeMap<String, IncrementalManifestEntry>,
39}
40
41impl IncrementalManifest {
42    pub fn new(
43        options_fingerprint: String,
44        entries: BTreeMap<String, IncrementalManifestEntry>,
45    ) -> Self {
46        Self {
47            version: INCREMENTAL_MANIFEST_VERSION,
48            options_fingerprint,
49            entries,
50        }
51    }
52
53    pub fn entry(&self, relative_path: &str) -> Option<&IncrementalManifestEntry> {
54        self.entries.get(relative_path)
55    }
56
57    pub fn is_compatible_with(&self, options_fingerprint: &str) -> bool {
58        self.version == INCREMENTAL_MANIFEST_VERSION
59            && self.options_fingerprint == options_fingerprint
60    }
61}
62
63pub fn incremental_manifest_path(cache_root: &Path, manifest_key: &str) -> PathBuf {
64    cache_root
65        .join("incremental")
66        .join(manifest_key)
67        .join(MANIFEST_FILE_NAME)
68}
69
70pub fn metadata_fingerprint(metadata: &fs::Metadata) -> Option<FileStateFingerprint> {
71    let modified = metadata.modified().ok()?;
72    let duration = modified.duration_since(UNIX_EPOCH).ok()?;
73
74    Some(FileStateFingerprint {
75        size: metadata.len(),
76        modified_seconds: duration.as_secs(),
77        modified_nanos: duration.subsec_nanos(),
78    })
79}
80
81pub fn manifest_entry_matches_path(
82    entry: &IncrementalManifestEntry,
83    path: &Path,
84    metadata: &fs::Metadata,
85) -> io::Result<bool> {
86    if !metadata_fingerprint(metadata).is_some_and(|fingerprint| fingerprint == entry.state) {
87        return Ok(false);
88    }
89
90    let bytes = fs::read(path)?;
91    Ok(calculate_sha256(&bytes) == entry.content_sha256)
92}
93
94pub fn load_incremental_manifest(
95    manifest_path: &Path,
96    options_fingerprint: &str,
97) -> io::Result<Option<IncrementalManifest>> {
98    let bytes = match fs::read(manifest_path) {
99        Ok(bytes) => bytes,
100        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
101        Err(err) => return Err(err),
102    };
103
104    let manifest: IncrementalManifest = match serde_json::from_slice(&bytes) {
105        Ok(manifest) => manifest,
106        Err(_) => return Ok(None),
107    };
108
109    if !manifest.is_compatible_with(options_fingerprint) {
110        return Ok(None);
111    }
112
113    Ok(Some(manifest))
114}
115
116pub fn write_incremental_manifest(
117    cache_root: &Path,
118    manifest_path: &Path,
119    manifest: &IncrementalManifest,
120) -> io::Result<()> {
121    let bytes = serde_json::to_vec_pretty(manifest)
122        .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
123
124    with_exclusive_cache_lock(cache_root, || write_bytes_atomically(manifest_path, &bytes))
125}
126
127#[cfg(test)]
128mod tests {
129    use tempfile::TempDir;
130
131    use super::*;
132    use crate::models::{DiagnosticSeverity, FileInfo, FileType, ScanDiagnostic};
133
134    fn sample_manifest(options_fingerprint: &str) -> IncrementalManifest {
135        let mut entries = BTreeMap::new();
136        entries.insert(
137            "src/main.rs".to_string(),
138            IncrementalManifestEntry {
139                state: FileStateFingerprint {
140                    size: 12,
141                    modified_seconds: 10,
142                    modified_nanos: 20,
143                },
144                content_sha256: Sha256Digest::from_hex(
145                    "f2ca1bb6c7e907d06dafe4687e579fce9f2b2c8a179a4e7c1f6c5052d4f7d070",
146                )
147                .unwrap(),
148                file_info: FileInfo::new(
149                    "main.rs".to_string(),
150                    "main".to_string(),
151                    ".rs".to_string(),
152                    "/tmp/project/src/main.rs".to_string(),
153                    FileType::File,
154                    None,
155                    None,
156                    12,
157                    None,
158                    None,
159                    None,
160                    None,
161                    None,
162                    Vec::new(),
163                    None,
164                    Vec::new(),
165                    Vec::new(),
166                    Vec::new(),
167                    Vec::new(),
168                    Vec::new(),
169                    Vec::new(),
170                    Vec::new(),
171                    Vec::new(),
172                    Vec::new(),
173                ),
174            },
175        );
176
177        IncrementalManifest::new(options_fingerprint.to_string(), entries)
178    }
179
180    #[test]
181    fn test_load_incremental_manifest_returns_none_for_incompatible_options() {
182        let temp_dir = TempDir::new().expect("create temp dir");
183        let manifest_path = incremental_manifest_path(temp_dir.path(), "abc123");
184        let manifest = sample_manifest("options-v1");
185
186        write_incremental_manifest(temp_dir.path(), &manifest_path, &manifest)
187            .expect("write manifest");
188
189        let loaded =
190            load_incremental_manifest(&manifest_path, "options-v2").expect("load manifest");
191
192        assert!(loaded.is_none());
193    }
194
195    #[test]
196    fn test_load_incremental_manifest_returns_none_for_older_manifest_version() {
197        let temp_dir = TempDir::new().expect("create temp dir");
198        let manifest_path = incremental_manifest_path(temp_dir.path(), "abc123");
199        let mut manifest = sample_manifest("options-v1");
200        manifest.version = 1;
201
202        write_incremental_manifest(temp_dir.path(), &manifest_path, &manifest)
203            .expect("write manifest");
204
205        let loaded =
206            load_incremental_manifest(&manifest_path, "options-v1").expect("load manifest");
207
208        assert!(loaded.is_none());
209    }
210
211    #[test]
212    fn test_write_and_load_incremental_manifest_round_trip() {
213        let temp_dir = TempDir::new().expect("create temp dir");
214        let manifest_path = incremental_manifest_path(temp_dir.path(), "abc123");
215        let manifest = sample_manifest("options-v1");
216
217        write_incremental_manifest(temp_dir.path(), &manifest_path, &manifest)
218            .expect("write manifest");
219
220        let loaded = load_incremental_manifest(&manifest_path, "options-v1")
221            .expect("load manifest")
222            .expect("expected manifest");
223
224        assert_eq!(loaded.entries.len(), 1);
225        assert!(loaded.entry("src/main.rs").is_some());
226    }
227
228    #[test]
229    fn test_incremental_manifest_preserves_scan_diagnostic_severity() {
230        let temp_dir = TempDir::new().expect("create temp dir");
231        let manifest_path = incremental_manifest_path(temp_dir.path(), "diag");
232        let mut manifest = sample_manifest("options-v1");
233        let entry = manifest
234            .entries
235            .get_mut("src/main.rs")
236            .expect("manifest entry");
237        entry.file_info.scan_errors = vec!["custom recoverable warning".to_string()];
238        entry.file_info.scan_diagnostics =
239            vec![ScanDiagnostic::warning("custom recoverable warning")];
240
241        write_incremental_manifest(temp_dir.path(), &manifest_path, &manifest)
242            .expect("write manifest");
243
244        let loaded = load_incremental_manifest(&manifest_path, "options-v1")
245            .expect("load manifest")
246            .expect("expected manifest");
247
248        let loaded_entry = loaded.entry("src/main.rs").expect("loaded entry");
249        assert_eq!(loaded_entry.file_info.scan_diagnostics.len(), 1);
250        assert_eq!(
251            loaded_entry.file_info.scan_diagnostics[0].severity,
252            DiagnosticSeverity::Warning
253        );
254    }
255
256    #[test]
257    fn test_manifest_entry_matches_path_detects_content_changes() {
258        let temp_dir = TempDir::new().expect("create temp dir");
259        let file_path = temp_dir.path().join("src/main.rs");
260        fs::create_dir_all(file_path.parent().expect("parent")).expect("create parent");
261        fs::write(&file_path, "fn main() {}\n").expect("write file");
262        let metadata = fs::metadata(&file_path).expect("metadata");
263
264        let entry = IncrementalManifestEntry {
265            state: metadata_fingerprint(&metadata).expect("fingerprint"),
266            content_sha256: Sha256Digest::from_hex("not-the-real-hash")
267                .unwrap_or(Sha256Digest::EMPTY),
268            file_info: FileInfo::new(
269                "main.rs".to_string(),
270                "main".to_string(),
271                ".rs".to_string(),
272                file_path.to_string_lossy().to_string(),
273                FileType::File,
274                None,
275                None,
276                metadata.len(),
277                None,
278                None,
279                None,
280                None,
281                None,
282                Vec::new(),
283                None,
284                Vec::new(),
285                Vec::new(),
286                Vec::new(),
287                Vec::new(),
288                Vec::new(),
289                Vec::new(),
290                Vec::new(),
291                Vec::new(),
292                Vec::new(),
293            ),
294        };
295
296        assert!(!manifest_entry_matches_path(&entry, &file_path, &metadata).expect("compare path"));
297    }
298}