Skip to main content

pacsea/app/
services_cache.rs

1//! Service cache persistence for install list service impacts.
2
3use super::cache_common;
4use crate::state::modal::ServiceImpact;
5use serde::{Deserialize, Serialize};
6use std::path::PathBuf;
7
8/// What: Cache blob combining install list signature with resolved service impact metadata.
9///
10/// Details:
11/// - `install_list_signature` mirrors package names used for cache validation.
12/// - `services` preserves the last known service impact data for reuse.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct ServiceCache {
15    /// Sorted list of package names from install list (used as signature).
16    pub install_list_signature: Vec<String>,
17    /// Cached resolved service impacts.
18    pub services: Vec<ServiceImpact>,
19}
20
21/// What: Generate a deterministic signature for service cache comparisons.
22///
23/// Inputs:
24/// - `packages`: Slice of install list entries contributing their package names.
25///
26/// Output:
27/// - Sorted vector of package names that can be compared for cache validity checks.
28///
29/// Details:
30/// - Delegates to [`cache_common::compute_signature`], which sorts the cloned
31///   package names alphabetically to create an order-agnostic key.
32#[must_use]
33pub fn compute_signature(packages: &[crate::state::PackageItem]) -> Vec<String> {
34    cache_common::compute_signature(packages)
35}
36
37/// What: Load cached service impact data when the stored signature matches the current list.
38///
39/// Inputs:
40/// - `path`: Filesystem location of the serialized `ServiceCache` JSON.
41/// - `current_signature`: Signature derived from the current install list for validation.
42///
43/// Output:
44/// - `Some(Vec<ServiceImpact>)` when the cache exists, deserializes, and signatures agree;
45///   `None` otherwise.
46///
47/// Details:
48/// - Reads the JSON, deserializes it, sorts both signatures, and compares them before
49///   returning the cached service impact data.
50// `&PathBuf` is kept (rather than `&Path`) so `runtime::init` can keep passing
51// this function as `impl Fn(&PathBuf, &[String]) -> Option<T>` unchanged.
52#[allow(clippy::ptr_arg)]
53#[must_use]
54pub fn load_cache(path: &PathBuf, current_signature: &[String]) -> Option<Vec<ServiceImpact>> {
55    let ServiceCache {
56        install_list_signature,
57        services,
58    } = cache_common::load_signed_cache(path, "Service", "service")?;
59    cache_common::take_exact_match(
60        path,
61        "service",
62        current_signature,
63        &install_list_signature,
64        services,
65    )
66}
67
68/// What: Persist service impact cache payload and signature to disk as JSON.
69///
70/// Inputs:
71/// - `path`: Destination file for the serialized cache contents.
72/// - `signature`: Current install list signature to store alongside the payload.
73/// - `services`: Service impact metadata being cached.
74///
75/// Output:
76/// - No return value; writes to disk best-effort and logs a debug message when successful.
77///
78/// Details:
79/// - Serializes the data to JSON, writes it to `path`, and includes the record count in logs.
80// `&PathBuf` is kept (rather than `&Path`) to preserve the historical public
81// signature shared by all install-list cache modules and their callers.
82#[allow(clippy::ptr_arg)]
83pub fn save_cache(path: &PathBuf, signature: &[String], services: &[ServiceImpact]) {
84    let cache = ServiceCache {
85        install_list_signature: signature.to_vec(),
86        services: services.to_vec(),
87    };
88    cache_common::save_signed_cache(path, &cache, services.len(), "service");
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use crate::state::modal::{ServiceImpact, ServiceRestartDecision};
95    use crate::state::{PackageItem, Source};
96    use std::fs;
97    use std::time::{SystemTime, UNIX_EPOCH};
98
99    fn temp_path(label: &str) -> std::path::PathBuf {
100        let mut path = std::env::temp_dir();
101        path.push(format!(
102            "pacsea_services_cache_{label}_{}_{}.json",
103            std::process::id(),
104            SystemTime::now()
105                .duration_since(UNIX_EPOCH)
106                .expect("System time is before UNIX epoch")
107                .as_nanos()
108        ));
109        path
110    }
111
112    fn sample_packages() -> Vec<PackageItem> {
113        vec![
114            PackageItem {
115                name: "sshd".into(),
116                version: "9.0.0".into(),
117                description: String::new(),
118                source: Source::Official {
119                    repo: "core".into(),
120                    arch: "x86_64".into(),
121                },
122                popularity: None,
123                out_of_date: None,
124                orphaned: false,
125            },
126            PackageItem {
127                name: "nginx".into(),
128                version: "1.24.0".into(),
129                description: String::new(),
130                source: Source::Official {
131                    repo: "extra".into(),
132                    arch: "x86_64".into(),
133                },
134                popularity: None,
135                out_of_date: None,
136                orphaned: false,
137            },
138        ]
139    }
140
141    fn sample_services() -> Vec<ServiceImpact> {
142        vec![ServiceImpact {
143            unit_name: "sshd.service".into(),
144            providers: vec!["sshd".into()],
145            is_active: true,
146            needs_restart: true,
147            recommended_decision: ServiceRestartDecision::Restart,
148            restart_decision: ServiceRestartDecision::Restart,
149        }]
150    }
151
152    #[test]
153    /// What: Ensure `compute_signature` normalizes package name ordering.
154    /// Inputs:
155    /// - Install list cloned from the sample data but iterated in reverse.
156    ///
157    /// Output:
158    /// - Signature equals `["nginx", "sshd"]`.
159    fn compute_signature_orders_package_names() {
160        let mut packages = sample_packages();
161        packages.reverse();
162        let signature = compute_signature(&packages);
163        assert_eq!(signature, vec![String::from("nginx"), String::from("sshd")]);
164    }
165
166    #[test]
167    /// What: Confirm `load_cache` rejects persisted caches whose signature does not match.
168    /// Inputs:
169    /// - Cache saved for `["nginx", "sshd"]` but reloaded with signature `["sshd", "httpd"]`.
170    ///
171    /// Output:
172    /// - `None`.
173    fn load_cache_rejects_signature_mismatch() {
174        let path = temp_path("mismatch");
175        let packages = sample_packages();
176        let signature = compute_signature(&packages);
177        let services = sample_services();
178        save_cache(&path, &signature, &services);
179
180        let mismatched_signature = vec!["sshd".into(), "httpd".into()];
181        assert!(load_cache(&path, &mismatched_signature).is_none());
182        let _ = fs::remove_file(&path);
183    }
184
185    #[test]
186    /// What: Verify cached service metadata survives a save/load round trip.
187    /// Inputs:
188    /// - Sample `sshd.service` impact written to disk and reloaded with matching signature.
189    ///
190    /// Output:
191    /// - Reloaded metadata matches the original unit name and properties.
192    fn save_and_load_cache_roundtrip() {
193        let path = temp_path("roundtrip");
194        let packages = sample_packages();
195        let signature = compute_signature(&packages);
196        let services = sample_services();
197        save_cache(&path, &signature, &services);
198
199        let reloaded = load_cache(&path, &signature).expect("expected cache to load");
200        assert_eq!(reloaded.len(), services.len());
201        assert_eq!(reloaded[0].unit_name, services[0].unit_name);
202        assert_eq!(reloaded[0].providers, services[0].providers);
203        assert_eq!(reloaded[0].is_active, services[0].is_active);
204        assert_eq!(reloaded[0].needs_restart, services[0].needs_restart);
205
206        let _ = fs::remove_file(&path);
207    }
208}