pacsea/app/
sandbox_cache.rs1use crate::logic::sandbox::SandboxInfo;
4use serde::{Deserialize, Serialize};
5use std::fs;
6use std::io::ErrorKind;
7use std::path::PathBuf;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct SandboxCache {
16 pub install_list_signature: Vec<String>,
18 pub sandbox_info: Vec<SandboxInfo>,
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
39#[must_use]
54pub fn load_cache(path: &PathBuf, current_signature: &[String]) -> Option<Vec<SandboxInfo>> {
55 load_cache_partial(path, current_signature, false)
56}
57
58pub 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 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 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 let intersection: std::collections::HashSet<&String> =
126 cached_set.intersection(¤t_set).copied().collect();
127
128 if !intersection.is_empty() {
129 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
161pub 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 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 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 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 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 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 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}