1use std::path::{Path, PathBuf};
14
15use adler_core::CheckOutcome;
16use serde::{Deserialize, Serialize};
17use tokio::fs;
18
19use crate::error::{Error, Result};
20use crate::scan::{FinishedScan, ScanId, Summary};
21
22pub(crate) const MAX_PERSISTED_SCANS: usize = 200;
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct PersistedScan {
31 pub scan_id: ScanId,
33 pub username: String,
35 pub site_count: usize,
37 pub created_at_ms: u64,
39 pub summary: Summary,
41 pub outcomes: Vec<CheckOutcome>,
43 pub elapsed_ms: u64,
45}
46
47impl PersistedScan {
48 #[must_use]
50 pub fn from_finished(
51 scan_id: ScanId,
52 username: String,
53 site_count: usize,
54 created_at_ms: u64,
55 finished: FinishedScan,
56 ) -> Self {
57 Self {
58 scan_id,
59 username,
60 site_count,
61 created_at_ms,
62 summary: finished.summary,
63 outcomes: finished.outcomes,
64 elapsed_ms: finished.elapsed_ms,
65 }
66 }
67}
68
69#[must_use]
75pub fn default_dir() -> PathBuf {
76 if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME") {
77 return PathBuf::from(xdg).join("adler").join("scans");
78 }
79 if let Some(home) = std::env::var_os("HOME") {
80 return PathBuf::from(home)
81 .join(".cache")
82 .join("adler")
83 .join("scans");
84 }
85 PathBuf::from("adler-scans")
86}
87
88pub(crate) async fn save(dir: &Path, scan: &PersistedScan) -> Result<()> {
90 fs::create_dir_all(dir).await.map_err(Error::Persist)?;
91 let path = dir.join(format!("{}.json", scan.scan_id));
92 let tmp = dir.join(format!("{}.json.tmp", scan.scan_id));
93 let body = serde_json::to_vec_pretty(scan).map_err(Error::PersistEncode)?;
94 fs::write(&tmp, &body).await.map_err(Error::Persist)?;
95 fs::rename(&tmp, &path).await.map_err(Error::Persist)?;
96 Ok(())
97}
98
99pub(crate) async fn load(dir: &Path, scan_id: &ScanId) -> Option<PersistedScan> {
103 let path = dir.join(format!("{scan_id}.json"));
104 let bytes = fs::read(&path).await.ok()?;
105 serde_json::from_slice(&bytes).ok()
106}
107
108pub(crate) async fn load_all(dir: &Path) -> Vec<PersistedScan> {
112 let Ok(mut entries) = fs::read_dir(dir).await else {
113 return Vec::new();
114 };
115 let mut out = Vec::new();
116 while let Ok(Some(entry)) = entries.next_entry().await {
117 let path = entry.path();
118 if path.extension().and_then(|s| s.to_str()) != Some("json") {
119 continue;
120 }
121 let Ok(bytes) = fs::read(&path).await else {
122 continue;
123 };
124 let Ok(scan) = serde_json::from_slice::<PersistedScan>(&bytes) else {
125 continue;
126 };
127 out.push(scan);
128 }
129 out.sort_by_key(|s| std::cmp::Reverse(s.created_at_ms));
130 out
131}
132
133pub(crate) async fn prune(dir: &Path, keep_newest: usize) -> usize {
136 let scans = load_all(dir).await;
137 if scans.len() <= keep_newest {
138 return 0;
139 }
140 let mut removed = 0;
141 for s in &scans[keep_newest..] {
142 let path = dir.join(format!("{}.json", s.scan_id));
143 if fs::remove_file(&path).await.is_ok() {
144 removed += 1;
145 }
146 }
147 removed
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153 use adler_core::MatchKind;
154 use std::collections::BTreeMap;
155 use tempfile::TempDir;
156
157 fn sample(scan_id: &str, ts: u64) -> PersistedScan {
158 PersistedScan {
159 scan_id: ScanId::from(scan_id.to_owned()),
160 username: "alice".into(),
161 site_count: 2,
162 created_at_ms: ts,
163 summary: Summary {
164 found: 1,
165 not_found: 1,
166 uncertain: 0,
167 },
168 outcomes: vec![
169 CheckOutcome {
170 site: "GitHub".into(),
171 url: "https://github.com/alice".into(),
172 kind: MatchKind::Found,
173 reason: None,
174 elapsed_ms: 120,
175 enrichment: BTreeMap::new(),
176 evidence: vec!["HTTP 200 (status_found)".into()],
177 transport: None,
178 escalations: 0,
179 },
180 CheckOutcome {
181 site: "GitLab".into(),
182 url: "https://gitlab.com/alice".into(),
183 kind: MatchKind::NotFound,
184 reason: None,
185 elapsed_ms: 90,
186 enrichment: BTreeMap::new(),
187 evidence: vec!["HTTP 404 (status_not_found)".into()],
188 transport: None,
189 escalations: 0,
190 },
191 ],
192 elapsed_ms: 210,
193 }
194 }
195
196 #[tokio::test]
197 async fn save_then_load_roundtrips() {
198 let tmp = TempDir::new().unwrap();
199 let s = sample("abc123", 1_700_000_000_000);
200 save(tmp.path(), &s).await.unwrap();
201
202 let loaded = load(tmp.path(), &s.scan_id).await.expect("loaded");
203 assert_eq!(loaded.scan_id, s.scan_id);
204 assert_eq!(loaded.username, "alice");
205 assert_eq!(loaded.outcomes.len(), 2);
206 assert_eq!(loaded.outcomes[0].site, "GitHub");
207 assert_eq!(loaded.summary.found, 1);
208 }
209
210 #[tokio::test]
211 async fn load_all_returns_newest_first() {
212 let tmp = TempDir::new().unwrap();
213 save(tmp.path(), &sample("old", 1_000)).await.unwrap();
214 save(tmp.path(), &sample("mid", 2_000)).await.unwrap();
215 save(tmp.path(), &sample("new", 3_000)).await.unwrap();
216 let all = load_all(tmp.path()).await;
217 assert_eq!(all.len(), 3);
218 assert_eq!(all[0].scan_id.as_str(), "new");
219 assert_eq!(all[1].scan_id.as_str(), "mid");
220 assert_eq!(all[2].scan_id.as_str(), "old");
221 }
222
223 #[tokio::test]
224 async fn load_returns_none_for_missing() {
225 let tmp = TempDir::new().unwrap();
226 let missing = load(tmp.path(), &ScanId::from("nope".to_owned())).await;
227 assert!(missing.is_none());
228 }
229
230 #[tokio::test]
231 async fn load_all_skips_unrelated_files() {
232 let tmp = TempDir::new().unwrap();
233 fs::write(tmp.path().join("README"), b"not json")
235 .await
236 .unwrap();
237 fs::write(tmp.path().join("broken.json"), b"{ invalid")
238 .await
239 .unwrap();
240 save(tmp.path(), &sample("good", 9_999)).await.unwrap();
241 let all = load_all(tmp.path()).await;
242 assert_eq!(all.len(), 1);
243 assert_eq!(all[0].scan_id.as_str(), "good");
244 }
245
246 #[tokio::test]
247 async fn prune_keeps_only_newest_n() {
248 let tmp = TempDir::new().unwrap();
249 for i in 0u64..5 {
250 save(tmp.path(), &sample(&format!("s{i}"), i * 1_000))
251 .await
252 .unwrap();
253 }
254 let removed = prune(tmp.path(), 2).await;
255 assert_eq!(removed, 3);
256 let remaining = load_all(tmp.path()).await;
257 assert_eq!(remaining.len(), 2);
258 assert_eq!(remaining[0].scan_id.as_str(), "s4");
259 assert_eq!(remaining[1].scan_id.as_str(), "s3");
260 }
261}