Skip to main content

saf_core/
manifest.rs

1//! Cache manifest for tracking module fingerprints across analysis runs.
2//!
3//! The manifest records which modules were analyzed in a previous run
4//! and their content fingerprints, enabling fast change detection on
5//! subsequent runs.
6
7use std::collections::BTreeMap;
8use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11
12use crate::ids::{ModuleId, ProgramId};
13
14/// Per-module entry in the manifest.
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
16pub struct ManifestEntry {
17    /// The module's content-derived ID.
18    pub module_id: ModuleId,
19
20    /// BLAKE3 fingerprint of the input file (hex-encoded).
21    pub fingerprint: String,
22
23    /// Original input file path (for display/debugging).
24    pub input_path: String,
25
26    /// BLAKE3 hash of the serialized `ModuleConstraints` (hex-encoded).
27    ///
28    /// `None` if constraints have not been computed yet for this module.
29    /// Used to detect constraint-level staleness even when the input
30    /// file fingerprint is unchanged (e.g., due to upstream module changes
31    /// affecting cross-module references).
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub constraint_hash: Option<String>,
34}
35
36/// Persisted manifest recording the state of a previous analysis run.
37#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
38pub struct CacheManifest {
39    /// Program ID from the previous run.
40    pub program_id: Option<ProgramId>,
41
42    /// Per-module entries keyed by input file path.
43    pub modules: BTreeMap<String, ManifestEntry>,
44}
45
46impl CacheManifest {
47    /// Load manifest from a cache directory. Returns default if not found.
48    pub fn load(cache_dir: &Path) -> Self {
49        let path = Self::manifest_path(cache_dir);
50        match std::fs::read_to_string(&path) {
51            Ok(json) => serde_json::from_str(&json).unwrap_or_default(),
52            Err(_) => Self::default(),
53        }
54    }
55
56    /// Save manifest to a cache directory.
57    ///
58    /// # Errors
59    ///
60    /// Returns `std::io::Error` if the cache directory cannot be created
61    /// or the manifest file cannot be written.
62    pub fn save(&self, cache_dir: &Path) -> Result<(), std::io::Error> {
63        let path = Self::manifest_path(cache_dir);
64        if let Some(parent) = path.parent() {
65            std::fs::create_dir_all(parent)?;
66        }
67        let json = serde_json::to_string_pretty(self)
68            .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
69        std::fs::write(&path, json)
70    }
71
72    fn manifest_path(cache_dir: &Path) -> PathBuf {
73        cache_dir.join("manifest.json")
74    }
75
76    /// Compare this manifest (previous run) against current fingerprints.
77    ///
78    /// Returns lists of unchanged, changed, added, and removed input paths.
79    /// Does not check constraint-level staleness; use
80    /// [`diff_with_constraints`](Self::diff_with_constraints) for that.
81    pub fn diff(&self, current: &BTreeMap<String, String>) -> ManifestDiff {
82        self.diff_with_constraints(current, &BTreeMap::new())
83    }
84
85    /// Compare this manifest against current fingerprints **and** constraint hashes.
86    ///
87    /// `current_fingerprints` maps input path to file fingerprint.
88    /// `current_constraint_hashes` maps input path to constraint hash.
89    ///
90    /// A file is classified as `constraint_stale` when its fingerprint is
91    /// unchanged but its constraint hash differs from the previous run.
92    pub fn diff_with_constraints(
93        &self,
94        current_fingerprints: &BTreeMap<String, String>,
95        current_constraint_hashes: &BTreeMap<String, String>,
96    ) -> ManifestDiff {
97        let mut unchanged = Vec::new();
98        let mut changed = Vec::new();
99        let mut added = Vec::new();
100        let mut removed = Vec::new();
101        let mut constraint_stale = Vec::new();
102
103        // Check current against previous
104        for (path, fingerprint) in current_fingerprints {
105            match self.modules.get(path) {
106                Some(entry) if entry.fingerprint == *fingerprint => {
107                    // Fingerprint unchanged — check constraint hash
108                    if let Some(cur_hash) = current_constraint_hashes.get(path) {
109                        if entry.constraint_hash.as_deref() != Some(cur_hash.as_str()) {
110                            constraint_stale.push(path.clone());
111                        }
112                    }
113                    unchanged.push(path.clone());
114                }
115                Some(_) => {
116                    changed.push(path.clone());
117                }
118                None => {
119                    added.push(path.clone());
120                }
121            }
122        }
123
124        // Check for removed files
125        for path in self.modules.keys() {
126            if !current_fingerprints.contains_key(path) {
127                removed.push(path.clone());
128            }
129        }
130
131        ManifestDiff {
132            unchanged,
133            changed,
134            added,
135            removed,
136            constraint_stale,
137        }
138    }
139}
140
141/// Result of comparing previous manifest against current fingerprints.
142#[derive(Debug, Clone, Default, PartialEq)]
143pub struct ManifestDiff {
144    /// Files whose fingerprint matches the previous run.
145    pub unchanged: Vec<String>,
146
147    /// Files whose fingerprint changed since the previous run.
148    pub changed: Vec<String>,
149
150    /// Files present now but not in the previous run.
151    pub added: Vec<String>,
152
153    /// Files in the previous run but not present now.
154    pub removed: Vec<String>,
155
156    /// Files whose fingerprint is unchanged but whose constraint hash
157    /// differs from the previous run (constraint-level staleness).
158    pub constraint_stale: Vec<String>,
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn empty_manifest_diff_shows_all_added() {
167        let manifest = CacheManifest::default();
168        let mut current = BTreeMap::new();
169        current.insert("a.ll".to_string(), "aaa".to_string());
170        current.insert("b.ll".to_string(), "bbb".to_string());
171
172        let diff = manifest.diff(&current);
173        assert_eq!(diff.added.len(), 2);
174        assert!(diff.unchanged.is_empty());
175        assert!(diff.changed.is_empty());
176        assert!(diff.removed.is_empty());
177    }
178
179    #[test]
180    fn same_fingerprints_show_unchanged() {
181        let mut manifest = CacheManifest::default();
182        manifest.modules.insert(
183            "a.ll".to_string(),
184            ManifestEntry {
185                module_id: ModuleId::new(1),
186                fingerprint: "aaa".to_string(),
187                input_path: "a.ll".to_string(),
188                constraint_hash: None,
189            },
190        );
191
192        let mut current = BTreeMap::new();
193        current.insert("a.ll".to_string(), "aaa".to_string());
194
195        let diff = manifest.diff(&current);
196        assert_eq!(diff.unchanged, vec!["a.ll"]);
197        assert!(diff.changed.is_empty());
198    }
199
200    #[test]
201    fn changed_fingerprint_detected() {
202        let mut manifest = CacheManifest::default();
203        manifest.modules.insert(
204            "a.ll".to_string(),
205            ManifestEntry {
206                module_id: ModuleId::new(1),
207                fingerprint: "old".to_string(),
208                input_path: "a.ll".to_string(),
209                constraint_hash: None,
210            },
211        );
212
213        let mut current = BTreeMap::new();
214        current.insert("a.ll".to_string(), "new".to_string());
215
216        let diff = manifest.diff(&current);
217        assert_eq!(diff.changed, vec!["a.ll"]);
218        assert!(diff.unchanged.is_empty());
219    }
220
221    #[test]
222    fn removed_file_detected() {
223        let mut manifest = CacheManifest::default();
224        manifest.modules.insert(
225            "deleted.ll".to_string(),
226            ManifestEntry {
227                module_id: ModuleId::new(1),
228                fingerprint: "xxx".to_string(),
229                input_path: "deleted.ll".to_string(),
230                constraint_hash: None,
231            },
232        );
233
234        let current = BTreeMap::new(); // empty — file was deleted
235
236        let diff = manifest.diff(&current);
237        assert_eq!(diff.removed, vec!["deleted.ll"]);
238    }
239
240    #[test]
241    fn manifest_roundtrip_through_filesystem() {
242        let tmp = tempfile::tempdir().unwrap();
243        let mut manifest = CacheManifest::default();
244        manifest.modules.insert(
245            "test.ll".to_string(),
246            ManifestEntry {
247                module_id: ModuleId::new(42),
248                fingerprint: "deadbeef".to_string(),
249                input_path: "test.ll".to_string(),
250                constraint_hash: Some("abc123".to_string()),
251            },
252        );
253
254        manifest.save(tmp.path()).unwrap();
255        let loaded = CacheManifest::load(tmp.path());
256        assert_eq!(manifest, loaded);
257    }
258
259    #[test]
260    fn same_fingerprint_same_constraint_hash_is_unchanged() {
261        let mut manifest = CacheManifest::default();
262        manifest.modules.insert(
263            "a.ll".to_string(),
264            ManifestEntry {
265                module_id: ModuleId::new(1),
266                fingerprint: "aaa".to_string(),
267                input_path: "a.ll".to_string(),
268                constraint_hash: Some("hash1".to_string()),
269            },
270        );
271
272        let mut fingerprints = BTreeMap::new();
273        fingerprints.insert("a.ll".to_string(), "aaa".to_string());
274
275        let mut constraint_hashes = BTreeMap::new();
276        constraint_hashes.insert("a.ll".to_string(), "hash1".to_string());
277
278        let diff = manifest.diff_with_constraints(&fingerprints, &constraint_hashes);
279        assert_eq!(diff.unchanged, vec!["a.ll"]);
280        assert!(diff.constraint_stale.is_empty());
281    }
282
283    #[test]
284    fn same_fingerprint_different_constraint_hash_is_constraint_stale() {
285        let mut manifest = CacheManifest::default();
286        manifest.modules.insert(
287            "a.ll".to_string(),
288            ManifestEntry {
289                module_id: ModuleId::new(1),
290                fingerprint: "aaa".to_string(),
291                input_path: "a.ll".to_string(),
292                constraint_hash: Some("old_hash".to_string()),
293            },
294        );
295
296        let mut fingerprints = BTreeMap::new();
297        fingerprints.insert("a.ll".to_string(), "aaa".to_string());
298
299        let mut constraint_hashes = BTreeMap::new();
300        constraint_hashes.insert("a.ll".to_string(), "new_hash".to_string());
301
302        let diff = manifest.diff_with_constraints(&fingerprints, &constraint_hashes);
303        // File is still in unchanged (fingerprint matches)
304        assert_eq!(diff.unchanged, vec!["a.ll"]);
305        // But also flagged as constraint-stale
306        assert_eq!(diff.constraint_stale, vec!["a.ll"]);
307    }
308
309    #[test]
310    fn no_previous_constraint_hash_with_current_is_stale() {
311        let mut manifest = CacheManifest::default();
312        manifest.modules.insert(
313            "a.ll".to_string(),
314            ManifestEntry {
315                module_id: ModuleId::new(1),
316                fingerprint: "aaa".to_string(),
317                input_path: "a.ll".to_string(),
318                constraint_hash: None,
319            },
320        );
321
322        let mut fingerprints = BTreeMap::new();
323        fingerprints.insert("a.ll".to_string(), "aaa".to_string());
324
325        let mut constraint_hashes = BTreeMap::new();
326        constraint_hashes.insert("a.ll".to_string(), "new_hash".to_string());
327
328        let diff = manifest.diff_with_constraints(&fingerprints, &constraint_hashes);
329        assert_eq!(diff.unchanged, vec!["a.ll"]);
330        // No previous hash means it differs from the current hash
331        assert_eq!(diff.constraint_stale, vec!["a.ll"]);
332    }
333
334    #[test]
335    fn constraint_hash_not_checked_when_fingerprint_changed() {
336        let mut manifest = CacheManifest::default();
337        manifest.modules.insert(
338            "a.ll".to_string(),
339            ManifestEntry {
340                module_id: ModuleId::new(1),
341                fingerprint: "old_fp".to_string(),
342                input_path: "a.ll".to_string(),
343                constraint_hash: Some("old_hash".to_string()),
344            },
345        );
346
347        let mut fingerprints = BTreeMap::new();
348        fingerprints.insert("a.ll".to_string(), "new_fp".to_string());
349
350        let mut constraint_hashes = BTreeMap::new();
351        constraint_hashes.insert("a.ll".to_string(), "new_hash".to_string());
352
353        let diff = manifest.diff_with_constraints(&fingerprints, &constraint_hashes);
354        assert_eq!(diff.changed, vec!["a.ll"]);
355        // No constraint staleness — the file itself changed
356        assert!(diff.constraint_stale.is_empty());
357    }
358
359    #[test]
360    fn constraint_hash_skipped_when_not_in_current() {
361        let mut manifest = CacheManifest::default();
362        manifest.modules.insert(
363            "a.ll".to_string(),
364            ManifestEntry {
365                module_id: ModuleId::new(1),
366                fingerprint: "aaa".to_string(),
367                input_path: "a.ll".to_string(),
368                constraint_hash: Some("old_hash".to_string()),
369            },
370        );
371
372        let mut fingerprints = BTreeMap::new();
373        fingerprints.insert("a.ll".to_string(), "aaa".to_string());
374
375        // No constraint hashes provided — skip constraint check
376        let diff = manifest.diff_with_constraints(&fingerprints, &BTreeMap::new());
377        assert_eq!(diff.unchanged, vec!["a.ll"]);
378        assert!(diff.constraint_stale.is_empty());
379    }
380}