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).map_err(std::io::Error::other)?;
68        std::fs::write(&path, json)
69    }
70
71    fn manifest_path(cache_dir: &Path) -> PathBuf {
72        cache_dir.join("manifest.json")
73    }
74
75    /// Compare this manifest (previous run) against current fingerprints.
76    ///
77    /// Returns lists of unchanged, changed, added, and removed input paths.
78    /// Does not check constraint-level staleness; use
79    /// [`diff_with_constraints`](Self::diff_with_constraints) for that.
80    pub fn diff(&self, current: &BTreeMap<String, String>) -> ManifestDiff {
81        self.diff_with_constraints(current, &BTreeMap::new())
82    }
83
84    /// Compare this manifest against current fingerprints **and** constraint hashes.
85    ///
86    /// `current_fingerprints` maps input path to file fingerprint.
87    /// `current_constraint_hashes` maps input path to constraint hash.
88    ///
89    /// A file is classified as `constraint_stale` when its fingerprint is
90    /// unchanged but its constraint hash differs from the previous run.
91    pub fn diff_with_constraints(
92        &self,
93        current_fingerprints: &BTreeMap<String, String>,
94        current_constraint_hashes: &BTreeMap<String, String>,
95    ) -> ManifestDiff {
96        let mut unchanged = Vec::new();
97        let mut changed = Vec::new();
98        let mut added = Vec::new();
99        let mut removed = Vec::new();
100        let mut constraint_stale = Vec::new();
101
102        // Check current against previous
103        for (path, fingerprint) in current_fingerprints {
104            match self.modules.get(path) {
105                Some(entry) if entry.fingerprint == *fingerprint => {
106                    // Fingerprint unchanged — check constraint hash
107                    if let Some(cur_hash) = current_constraint_hashes.get(path) {
108                        if entry.constraint_hash.as_deref() != Some(cur_hash.as_str()) {
109                            constraint_stale.push(path.clone());
110                        }
111                    }
112                    unchanged.push(path.clone());
113                }
114                Some(_) => {
115                    changed.push(path.clone());
116                }
117                None => {
118                    added.push(path.clone());
119                }
120            }
121        }
122
123        // Check for removed files
124        for path in self.modules.keys() {
125            if !current_fingerprints.contains_key(path) {
126                removed.push(path.clone());
127            }
128        }
129
130        ManifestDiff {
131            unchanged,
132            changed,
133            added,
134            removed,
135            constraint_stale,
136        }
137    }
138}
139
140/// Result of comparing previous manifest against current fingerprints.
141#[derive(Debug, Clone, Default, PartialEq)]
142pub struct ManifestDiff {
143    /// Files whose fingerprint matches the previous run.
144    pub unchanged: Vec<String>,
145
146    /// Files whose fingerprint changed since the previous run.
147    pub changed: Vec<String>,
148
149    /// Files present now but not in the previous run.
150    pub added: Vec<String>,
151
152    /// Files in the previous run but not present now.
153    pub removed: Vec<String>,
154
155    /// Files whose fingerprint is unchanged but whose constraint hash
156    /// differs from the previous run (constraint-level staleness).
157    pub constraint_stale: Vec<String>,
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn empty_manifest_diff_shows_all_added() {
166        let manifest = CacheManifest::default();
167        let mut current = BTreeMap::new();
168        current.insert("a.ll".to_string(), "aaa".to_string());
169        current.insert("b.ll".to_string(), "bbb".to_string());
170
171        let diff = manifest.diff(&current);
172        assert_eq!(diff.added.len(), 2);
173        assert!(diff.unchanged.is_empty());
174        assert!(diff.changed.is_empty());
175        assert!(diff.removed.is_empty());
176    }
177
178    #[test]
179    fn same_fingerprints_show_unchanged() {
180        let mut manifest = CacheManifest::default();
181        manifest.modules.insert(
182            "a.ll".to_string(),
183            ManifestEntry {
184                module_id: ModuleId::new(1),
185                fingerprint: "aaa".to_string(),
186                input_path: "a.ll".to_string(),
187                constraint_hash: None,
188            },
189        );
190
191        let mut current = BTreeMap::new();
192        current.insert("a.ll".to_string(), "aaa".to_string());
193
194        let diff = manifest.diff(&current);
195        assert_eq!(diff.unchanged, vec!["a.ll"]);
196        assert!(diff.changed.is_empty());
197    }
198
199    #[test]
200    fn changed_fingerprint_detected() {
201        let mut manifest = CacheManifest::default();
202        manifest.modules.insert(
203            "a.ll".to_string(),
204            ManifestEntry {
205                module_id: ModuleId::new(1),
206                fingerprint: "old".to_string(),
207                input_path: "a.ll".to_string(),
208                constraint_hash: None,
209            },
210        );
211
212        let mut current = BTreeMap::new();
213        current.insert("a.ll".to_string(), "new".to_string());
214
215        let diff = manifest.diff(&current);
216        assert_eq!(diff.changed, vec!["a.ll"]);
217        assert!(diff.unchanged.is_empty());
218    }
219
220    #[test]
221    fn removed_file_detected() {
222        let mut manifest = CacheManifest::default();
223        manifest.modules.insert(
224            "deleted.ll".to_string(),
225            ManifestEntry {
226                module_id: ModuleId::new(1),
227                fingerprint: "xxx".to_string(),
228                input_path: "deleted.ll".to_string(),
229                constraint_hash: None,
230            },
231        );
232
233        let current = BTreeMap::new(); // empty — file was deleted
234
235        let diff = manifest.diff(&current);
236        assert_eq!(diff.removed, vec!["deleted.ll"]);
237    }
238
239    #[test]
240    fn manifest_roundtrip_through_filesystem() {
241        let tmp = tempfile::tempdir().unwrap();
242        let mut manifest = CacheManifest::default();
243        manifest.modules.insert(
244            "test.ll".to_string(),
245            ManifestEntry {
246                module_id: ModuleId::new(42),
247                fingerprint: "deadbeef".to_string(),
248                input_path: "test.ll".to_string(),
249                constraint_hash: Some("abc123".to_string()),
250            },
251        );
252
253        manifest.save(tmp.path()).unwrap();
254        let loaded = CacheManifest::load(tmp.path());
255        assert_eq!(manifest, loaded);
256    }
257
258    #[test]
259    fn same_fingerprint_same_constraint_hash_is_unchanged() {
260        let mut manifest = CacheManifest::default();
261        manifest.modules.insert(
262            "a.ll".to_string(),
263            ManifestEntry {
264                module_id: ModuleId::new(1),
265                fingerprint: "aaa".to_string(),
266                input_path: "a.ll".to_string(),
267                constraint_hash: Some("hash1".to_string()),
268            },
269        );
270
271        let mut fingerprints = BTreeMap::new();
272        fingerprints.insert("a.ll".to_string(), "aaa".to_string());
273
274        let mut constraint_hashes = BTreeMap::new();
275        constraint_hashes.insert("a.ll".to_string(), "hash1".to_string());
276
277        let diff = manifest.diff_with_constraints(&fingerprints, &constraint_hashes);
278        assert_eq!(diff.unchanged, vec!["a.ll"]);
279        assert!(diff.constraint_stale.is_empty());
280    }
281
282    #[test]
283    fn same_fingerprint_different_constraint_hash_is_constraint_stale() {
284        let mut manifest = CacheManifest::default();
285        manifest.modules.insert(
286            "a.ll".to_string(),
287            ManifestEntry {
288                module_id: ModuleId::new(1),
289                fingerprint: "aaa".to_string(),
290                input_path: "a.ll".to_string(),
291                constraint_hash: Some("old_hash".to_string()),
292            },
293        );
294
295        let mut fingerprints = BTreeMap::new();
296        fingerprints.insert("a.ll".to_string(), "aaa".to_string());
297
298        let mut constraint_hashes = BTreeMap::new();
299        constraint_hashes.insert("a.ll".to_string(), "new_hash".to_string());
300
301        let diff = manifest.diff_with_constraints(&fingerprints, &constraint_hashes);
302        // File is still in unchanged (fingerprint matches)
303        assert_eq!(diff.unchanged, vec!["a.ll"]);
304        // But also flagged as constraint-stale
305        assert_eq!(diff.constraint_stale, vec!["a.ll"]);
306    }
307
308    #[test]
309    fn no_previous_constraint_hash_with_current_is_stale() {
310        let mut manifest = CacheManifest::default();
311        manifest.modules.insert(
312            "a.ll".to_string(),
313            ManifestEntry {
314                module_id: ModuleId::new(1),
315                fingerprint: "aaa".to_string(),
316                input_path: "a.ll".to_string(),
317                constraint_hash: None,
318            },
319        );
320
321        let mut fingerprints = BTreeMap::new();
322        fingerprints.insert("a.ll".to_string(), "aaa".to_string());
323
324        let mut constraint_hashes = BTreeMap::new();
325        constraint_hashes.insert("a.ll".to_string(), "new_hash".to_string());
326
327        let diff = manifest.diff_with_constraints(&fingerprints, &constraint_hashes);
328        assert_eq!(diff.unchanged, vec!["a.ll"]);
329        // No previous hash means it differs from the current hash
330        assert_eq!(diff.constraint_stale, vec!["a.ll"]);
331    }
332
333    #[test]
334    fn constraint_hash_not_checked_when_fingerprint_changed() {
335        let mut manifest = CacheManifest::default();
336        manifest.modules.insert(
337            "a.ll".to_string(),
338            ManifestEntry {
339                module_id: ModuleId::new(1),
340                fingerprint: "old_fp".to_string(),
341                input_path: "a.ll".to_string(),
342                constraint_hash: Some("old_hash".to_string()),
343            },
344        );
345
346        let mut fingerprints = BTreeMap::new();
347        fingerprints.insert("a.ll".to_string(), "new_fp".to_string());
348
349        let mut constraint_hashes = BTreeMap::new();
350        constraint_hashes.insert("a.ll".to_string(), "new_hash".to_string());
351
352        let diff = manifest.diff_with_constraints(&fingerprints, &constraint_hashes);
353        assert_eq!(diff.changed, vec!["a.ll"]);
354        // No constraint staleness — the file itself changed
355        assert!(diff.constraint_stale.is_empty());
356    }
357
358    #[test]
359    fn constraint_hash_skipped_when_not_in_current() {
360        let mut manifest = CacheManifest::default();
361        manifest.modules.insert(
362            "a.ll".to_string(),
363            ManifestEntry {
364                module_id: ModuleId::new(1),
365                fingerprint: "aaa".to_string(),
366                input_path: "a.ll".to_string(),
367                constraint_hash: Some("old_hash".to_string()),
368            },
369        );
370
371        let mut fingerprints = BTreeMap::new();
372        fingerprints.insert("a.ll".to_string(), "aaa".to_string());
373
374        // No constraint hashes provided — skip constraint check
375        let diff = manifest.diff_with_constraints(&fingerprints, &BTreeMap::new());
376        assert_eq!(diff.unchanged, vec!["a.ll"]);
377        assert!(diff.constraint_stale.is_empty());
378    }
379}