pacsea/app/
services_cache.rs1use crate::state::modal::ServiceImpact;
4use serde::{Deserialize, Serialize};
5use std::fs;
6use std::io::ErrorKind;
7use std::path::PathBuf;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ServiceCache {
16 pub install_list_signature: Vec<String>,
18 pub services: Vec<ServiceImpact>,
20}
21
22#[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
39pub 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 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
95pub 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 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 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 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}