Skip to main content

provenant/cache/
incremental.rs

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