pacsea/app/
services_cache.rs

1//! Service cache persistence for install list service impacts.
2
3use crate::state::modal::ServiceImpact;
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 service impact metadata.
10///
11/// Details:
12/// - `install_list_signature` mirrors package names used for cache validation.
13/// - `services` preserves the last known service impact data for reuse.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ServiceCache {
16    /// Sorted list of package names from install list (used as signature).
17    pub install_list_signature: Vec<String>,
18    /// Cached resolved service impacts.
19    pub services: Vec<ServiceImpact>,
20}
21
22/// What: Generate a deterministic signature for service 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 service impact data when the stored signature matches the current list.
40///
41/// Inputs:
42/// - `path`: Filesystem location of the serialized `ServiceCache` JSON.
43/// - `current_signature`: Signature derived from the current install list for validation.
44///
45/// Output:
46/// - `Some(Vec<ServiceImpact>)` 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 service impact data.
52pub fn load_cache(path: &PathBuf, current_signature: &[String]) -> Option<Vec<ServiceImpact>> {
53    let raw = match fs::read_to_string(path) {
54        Ok(contents) => contents,
55        Err(e) if e.kind() == ErrorKind::NotFound => {
56            tracing::debug!(path = %path.display(), "[Cache] Service cache not found");
57            return None;
58        }
59        Err(e) => {
60            tracing::warn!(
61                path = %path.display(),
62                error = %e,
63                "[Cache] Failed to read service cache"
64            );
65            return None;
66        }
67    };
68
69    let cache: ServiceCache = match serde_json::from_str(&raw) {
70        Ok(cache) => cache,
71        Err(e) => {
72            tracing::warn!(
73                path = %path.display(),
74                error = %e,
75                "[Cache] Failed to parse service cache"
76            );
77            return None;
78        }
79    };
80
81    // Check if signature matches
82    let mut cached_sig = cache.install_list_signature.clone();
83    cached_sig.sort();
84    let mut current_sig = current_signature.to_vec();
85    current_sig.sort();
86
87    if cached_sig == current_sig {
88        tracing::info!(path = %path.display(), count = cache.services.len(), "loaded service cache");
89        return Some(cache.services);
90    }
91    tracing::debug!(path = %path.display(), "service cache signature mismatch, ignoring");
92    None
93}
94
95/// What: Persist service impact cache payload and signature to disk as JSON.
96///
97/// Inputs:
98/// - `path`: Destination file for the serialized cache contents.
99/// - `signature`: Current install list signature to store alongside the payload.
100/// - `services`: Service impact metadata being cached.
101///
102/// Output:
103/// - No return value; writes to disk best-effort and logs a debug message when successful.
104///
105/// Details:
106/// - Serializes the data to JSON, writes it to `path`, and includes the record count in logs.
107pub fn save_cache(path: &PathBuf, signature: &[String], services: &[ServiceImpact]) {
108    let cache = ServiceCache {
109        install_list_signature: signature.to_vec(),
110        services: services.to_vec(),
111    };
112    if let Ok(s) = serde_json::to_string(&cache) {
113        let _ = fs::write(path, s);
114        tracing::debug!(path = %path.display(), count = services.len(), "saved service cache");
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use crate::state::modal::{ServiceImpact, ServiceRestartDecision};
122    use crate::state::{PackageItem, Source};
123    use std::fs;
124    use std::time::{SystemTime, UNIX_EPOCH};
125
126    fn temp_path(label: &str) -> std::path::PathBuf {
127        let mut path = std::env::temp_dir();
128        path.push(format!(
129            "pacsea_services_cache_{label}_{}_{}.json",
130            std::process::id(),
131            SystemTime::now()
132                .duration_since(UNIX_EPOCH)
133                .expect("System time is before UNIX epoch")
134                .as_nanos()
135        ));
136        path
137    }
138
139    fn sample_packages() -> Vec<PackageItem> {
140        vec![
141            PackageItem {
142                name: "sshd".into(),
143                version: "9.0.0".into(),
144                description: String::new(),
145                source: Source::Official {
146                    repo: "core".into(),
147                    arch: "x86_64".into(),
148                },
149                popularity: None,
150                out_of_date: None,
151                orphaned: false,
152            },
153            PackageItem {
154                name: "nginx".into(),
155                version: "1.24.0".into(),
156                description: String::new(),
157                source: Source::Official {
158                    repo: "extra".into(),
159                    arch: "x86_64".into(),
160                },
161                popularity: None,
162                out_of_date: None,
163                orphaned: false,
164            },
165        ]
166    }
167
168    fn sample_services() -> Vec<ServiceImpact> {
169        vec![ServiceImpact {
170            unit_name: "sshd.service".into(),
171            providers: vec!["sshd".into()],
172            is_active: true,
173            needs_restart: true,
174            recommended_decision: ServiceRestartDecision::Restart,
175            restart_decision: ServiceRestartDecision::Restart,
176        }]
177    }
178
179    #[test]
180    /// What: Ensure `compute_signature` normalizes package name ordering.
181    /// Inputs:
182    /// - Install list cloned from the sample data but iterated in reverse.
183    ///
184    /// Output:
185    /// - Signature equals `["nginx", "sshd"]`.
186    fn compute_signature_orders_package_names() {
187        let mut packages = sample_packages();
188        packages.reverse();
189        let signature = compute_signature(&packages);
190        assert_eq!(signature, vec![String::from("nginx"), String::from("sshd")]);
191    }
192
193    #[test]
194    /// What: Confirm `load_cache` rejects persisted caches whose signature does not match.
195    /// Inputs:
196    /// - Cache saved for `["nginx", "sshd"]` but reloaded with signature `["sshd", "httpd"]`.
197    ///
198    /// Output:
199    /// - `None`.
200    fn load_cache_rejects_signature_mismatch() {
201        let path = temp_path("mismatch");
202        let packages = sample_packages();
203        let signature = compute_signature(&packages);
204        let services = sample_services();
205        save_cache(&path, &signature, &services);
206
207        let mismatched_signature = vec!["sshd".into(), "httpd".into()];
208        assert!(load_cache(&path, &mismatched_signature).is_none());
209        let _ = fs::remove_file(&path);
210    }
211
212    #[test]
213    /// What: Verify cached service metadata survives a save/load round trip.
214    /// Inputs:
215    /// - Sample `sshd.service` impact written to disk and reloaded with matching signature.
216    ///
217    /// Output:
218    /// - Reloaded metadata matches the original unit name and properties.
219    fn save_and_load_cache_roundtrip() {
220        let path = temp_path("roundtrip");
221        let packages = sample_packages();
222        let signature = compute_signature(&packages);
223        let services = sample_services();
224        save_cache(&path, &signature, &services);
225
226        let reloaded = load_cache(&path, &signature).expect("expected cache to load");
227        assert_eq!(reloaded.len(), services.len());
228        assert_eq!(reloaded[0].unit_name, services[0].unit_name);
229        assert_eq!(reloaded[0].providers, services[0].providers);
230        assert_eq!(reloaded[0].is_active, services[0].is_active);
231        assert_eq!(reloaded[0].needs_restart, services[0].needs_restart);
232
233        let _ = fs::remove_file(&path);
234    }
235}