pacsea/app/
sandbox_cache.rs

1//! Sandbox cache persistence for install list sandbox analysis.
2
3use crate::logic::sandbox::SandboxInfo;
4use serde::{Deserialize, Serialize};
5use std::fs;
6use std::io::ErrorKind;
7use std::path::PathBuf;
8
9/// What: Cache blob combining install list signature with resolved sandbox metadata.
10///
11/// Details:
12/// - `install_list_signature` mirrors package names used for cache validation.
13/// - `sandbox_info` preserves the last known sandbox analysis data for reuse.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct SandboxCache {
16    /// Sorted list of package names from install list (used as signature).
17    pub install_list_signature: Vec<String>,
18    /// Cached resolved sandbox information.
19    pub sandbox_info: Vec<SandboxInfo>,
20}
21
22/// What: Generate a deterministic signature for sandbox cache comparisons.
23///
24/// Inputs:
25/// - `packages`: Slice of install list entries contributing their package names.
26///
27/// Output:
28/// - Sorted vector of package names that can be compared for cache validity checks.
29///
30/// Details:
31/// - Clones each package name and sorts the collection alphabetically.
32#[must_use]
33pub fn compute_signature(packages: &[crate::state::PackageItem]) -> Vec<String> {
34    let mut names: Vec<String> = packages.iter().map(|p| p.name.clone()).collect();
35    names.sort();
36    names
37}
38
39/// What: Load cached sandbox data when the stored signature matches the current list.
40///
41/// Inputs:
42/// - `path`: Filesystem location of the serialized `SandboxCache` JSON.
43/// - `current_signature`: Signature derived from the current install list for validation.
44///
45/// Output:
46/// - `Some(Vec<SandboxInfo>)` when the cache exists, deserializes, and signatures agree;
47///   `None` otherwise.
48///
49/// Details:
50/// - Reads the JSON, deserializes it, sorts both signatures, and compares them before
51///   returning the cached sandbox data.
52/// - Uses partial matching to load entries for packages that exist in both cache and current list.
53#[must_use]
54pub fn load_cache(path: &PathBuf, current_signature: &[String]) -> Option<Vec<SandboxInfo>> {
55    load_cache_partial(path, current_signature, false)
56}
57
58/// What: Load cached sandbox data with partial matching support.
59///
60/// Inputs:
61/// - `path`: Filesystem location of the serialized `SandboxCache` JSON.
62/// - `current_signature`: Signature derived from the current install list for validation.
63/// - `exact_match_only`: If true, only match when signatures are identical. If false, allow partial matching.
64///
65/// Output:
66/// - `Some(Vec<SandboxInfo>)` when the cache exists and matches (exact or partial);
67///   `None` otherwise.
68///
69/// Details:
70/// - If `exact_match_only` is false, loads entries for packages that exist in both
71///   the cached signature and the current signature (intersection matching).
72/// - This allows preserving sandbox data when packages are added to the install list.
73pub fn load_cache_partial(
74    path: &PathBuf,
75    current_signature: &[String],
76    exact_match_only: bool,
77) -> Option<Vec<SandboxInfo>> {
78    let raw = match fs::read_to_string(path) {
79        Ok(contents) => contents,
80        Err(e) if e.kind() == ErrorKind::NotFound => {
81            tracing::debug!(path = %path.display(), "[Cache] Sandbox cache not found");
82            return None;
83        }
84        Err(e) => {
85            tracing::warn!(
86                path = %path.display(),
87                error = %e,
88                "[Cache] Failed to read sandbox cache"
89            );
90            return None;
91        }
92    };
93
94    let cache: SandboxCache = match serde_json::from_str(&raw) {
95        Ok(cache) => cache,
96        Err(e) => {
97            tracing::warn!(
98                path = %path.display(),
99                error = %e,
100                "[Cache] Failed to parse sandbox cache"
101            );
102            return None;
103        }
104    };
105
106    // Check if signature matches exactly
107    let mut cached_sig = cache.install_list_signature.clone();
108    cached_sig.sort();
109    let mut current_sig = current_signature.to_vec();
110    current_sig.sort();
111
112    if cached_sig == current_sig {
113        tracing::info!(
114            path = %path.display(),
115            count = cache.sandbox_info.len(),
116            "loaded sandbox cache (exact match)"
117        );
118        return Some(cache.sandbox_info);
119    } else if !exact_match_only {
120        // Partial matching: load entries for packages that exist in both signatures
121        let cached_set: std::collections::HashSet<&String> = cached_sig.iter().collect();
122        let current_set: std::collections::HashSet<&String> = current_sig.iter().collect();
123
124        // Find intersection: packages that exist in both cache and current list
125        let intersection: std::collections::HashSet<&String> =
126            cached_set.intersection(&current_set).copied().collect();
127
128        if !intersection.is_empty() {
129            // Filter cached results to match packages in intersection
130            let intersection_names: std::collections::HashSet<&str> =
131                intersection.iter().map(|s| s.as_str()).collect();
132            let filtered: Vec<SandboxInfo> = cache
133                .sandbox_info
134                .iter()
135                .filter(|sandbox_info| {
136                    intersection_names.contains(sandbox_info.package_name.as_str())
137                })
138                .cloned()
139                .collect();
140
141            if !filtered.is_empty() {
142                tracing::info!(
143                    path = %path.display(),
144                    cached_count = cache.sandbox_info.len(),
145                    filtered_count = filtered.len(),
146                    intersection_packages = ?intersection.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
147                    "loaded sandbox cache (partial match)"
148                );
149                return Some(filtered);
150            }
151        }
152    }
153
154    tracing::debug!(
155        path = %path.display(),
156        "sandbox cache signature mismatch, ignoring"
157    );
158    None
159}
160
161/// What: Persist sandbox cache payload and signature to disk as JSON.
162///
163/// Inputs:
164/// - `path`: Destination file for the serialized cache contents.
165/// - `signature`: Current install list signature to store alongside the payload.
166/// - `sandbox_info`: Sandbox analysis metadata being cached.
167///
168/// Output:
169/// - No return value; writes to disk best-effort and logs a debug message when successful.
170///
171/// Details:
172/// - Serializes the data to JSON, writes it to `path`, and includes the record count in logs.
173pub fn save_cache(path: &PathBuf, signature: &[String], sandbox_info: &[SandboxInfo]) {
174    let cache = SandboxCache {
175        install_list_signature: signature.to_vec(),
176        sandbox_info: sandbox_info.to_vec(),
177    };
178    if let Ok(s) = serde_json::to_string(&cache) {
179        let _ = fs::write(path, s);
180        tracing::debug!(
181            path = %path.display(),
182            count = sandbox_info.len(),
183            "saved sandbox cache"
184        );
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use crate::logic::sandbox::{DependencyDelta, SandboxInfo};
192    use crate::state::{PackageItem, Source};
193    use std::fs;
194    use std::time::{SystemTime, UNIX_EPOCH};
195
196    fn temp_path(label: &str) -> std::path::PathBuf {
197        let mut path = std::env::temp_dir();
198        path.push(format!(
199            "pacsea_sandbox_cache_{label}_{}_{}.json",
200            std::process::id(),
201            SystemTime::now()
202                .duration_since(UNIX_EPOCH)
203                .expect("System time is before UNIX epoch")
204                .as_nanos()
205        ));
206        path
207    }
208
209    fn sample_packages() -> Vec<PackageItem> {
210        vec![PackageItem {
211            name: "yay".into(),
212            version: "12.0.0".into(),
213            description: String::new(),
214            source: Source::Aur,
215            popularity: None,
216            out_of_date: None,
217            orphaned: false,
218        }]
219    }
220
221    fn sample_sandbox_info() -> Vec<SandboxInfo> {
222        vec![SandboxInfo {
223            package_name: "yay".into(),
224            depends: vec![DependencyDelta {
225                name: "go".into(),
226                is_installed: true,
227                installed_version: Some("1.21.0".into()),
228                version_satisfied: true,
229            }],
230            makedepends: vec![],
231            checkdepends: vec![],
232            optdepends: vec![],
233        }]
234    }
235
236    #[test]
237    /// What: Ensure `compute_signature` normalizes package name ordering.
238    /// Inputs:
239    /// - Install list cloned from the sample data but iterated in reverse.
240    ///
241    /// Output:
242    /// - Signature equals `["yay"]`.
243    fn compute_signature_orders_package_names() {
244        let mut packages = sample_packages();
245        packages.reverse();
246        let signature = compute_signature(&packages);
247        assert_eq!(signature, vec![String::from("yay")]);
248    }
249
250    #[test]
251    /// What: Confirm `load_cache` rejects persisted caches whose signature does not match.
252    /// Inputs:
253    /// - Cache saved for `["yay"]` but reloaded with signature `["paru"]`.
254    ///
255    /// Output:
256    /// - `None`.
257    fn load_cache_rejects_signature_mismatch() {
258        let path = temp_path("mismatch");
259        let packages = sample_packages();
260        let signature = compute_signature(&packages);
261        let sandbox_info = sample_sandbox_info();
262        save_cache(&path, &signature, &sandbox_info);
263
264        let mismatched_signature = vec!["paru".into()];
265        assert!(load_cache(&path, &mismatched_signature).is_none());
266        let _ = fs::remove_file(&path);
267    }
268
269    #[test]
270    /// What: Verify cached sandbox metadata survives a save/load round trip.
271    /// Inputs:
272    /// - Sample `yay` sandbox info written to disk and reloaded with matching signature.
273    ///
274    /// Output:
275    /// - Reloaded metadata matches the original package name and properties.
276    fn save_and_load_cache_roundtrip() {
277        let path = temp_path("roundtrip");
278        let packages = sample_packages();
279        let signature = compute_signature(&packages);
280        let sandbox_info = sample_sandbox_info();
281        save_cache(&path, &signature, &sandbox_info);
282
283        let reloaded = load_cache(&path, &signature).expect("expected cache to load");
284        assert_eq!(reloaded.len(), sandbox_info.len());
285        assert_eq!(reloaded[0].package_name, sandbox_info[0].package_name);
286        assert_eq!(reloaded[0].depends.len(), sandbox_info[0].depends.len());
287
288        let _ = fs::remove_file(&path);
289    }
290
291    #[test]
292    /// What: Verify partial cache loading preserves entries when new packages are added.
293    /// Inputs:
294    /// - Cache saved for `["jujutsu-git"]` but reloaded with signature `["jujutsu-git", "pacsea-bin"]`.
295    ///
296    /// Output:
297    /// - Returns `Some(Vec<SandboxInfo>)` containing only `jujutsu-git` entry (partial match).
298    fn load_cache_partial_match() {
299        let path = temp_path("partial");
300        let jujutsu_sandbox = SandboxInfo {
301            package_name: "jujutsu-git".into(),
302            depends: vec![DependencyDelta {
303                name: "python".into(),
304                is_installed: true,
305                installed_version: Some("3.11.0".into()),
306                version_satisfied: true,
307            }],
308            makedepends: vec![],
309            checkdepends: vec![],
310            optdepends: vec![],
311        };
312        let signature = vec!["jujutsu-git".into()];
313        save_cache(&path, &signature, std::slice::from_ref(&jujutsu_sandbox));
314
315        // Try to load with expanded signature (new package added)
316        let expanded_signature = vec!["jujutsu-git".into(), "pacsea-bin".into()];
317        let reloaded =
318            load_cache(&path, &expanded_signature).expect("expected partial cache to load");
319
320        assert_eq!(reloaded.len(), 1);
321        assert_eq!(reloaded[0].package_name, "jujutsu-git");
322        assert_eq!(reloaded[0].depends.len(), 1);
323        assert_eq!(reloaded[0].depends[0].name, "python");
324
325        let _ = fs::remove_file(&path);
326    }
327
328    #[test]
329    /// What: Verify partial cache loading returns None when no packages overlap.
330    /// Inputs:
331    /// - Cache saved for `["jujutsu-git"]` but reloaded with signature `["pacsea-bin"]`.
332    ///
333    /// Output:
334    /// - Returns `None` (no overlap).
335    fn load_cache_partial_no_overlap() {
336        let path = temp_path("no_overlap");
337        let jujutsu_sandbox = sample_sandbox_info();
338        let signature = vec!["jujutsu-git".into()];
339        save_cache(&path, &signature, &jujutsu_sandbox);
340
341        let different_signature = vec!["pacsea-bin".into()];
342        assert!(load_cache(&path, &different_signature).is_none());
343
344        let _ = fs::remove_file(&path);
345    }
346}