1use std::cmp::Reverse;
5use std::path::{Path, PathBuf};
6
7use anyhow::Result;
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Serialize, Deserialize, Default)]
13pub struct ScanSummarySnapshot {
14 pub files_analyzed: u64,
15 pub files_skipped: u64,
16 pub total_physical_lines: u64,
17 pub code_lines: u64,
18 pub comment_lines: u64,
19 pub blank_lines: u64,
20 #[serde(default)]
21 pub functions: u64,
22 #[serde(default)]
23 pub classes: u64,
24 #[serde(default)]
25 pub variables: u64,
26 #[serde(default)]
27 pub imports: u64,
28 #[serde(default)]
29 pub test_count: u64,
30 #[serde(default)]
31 pub coverage_lines_found: u64,
32 #[serde(default)]
33 pub coverage_lines_hit: u64,
34 #[serde(default)]
35 pub coverage_functions_found: u64,
36 #[serde(default)]
37 pub coverage_functions_hit: u64,
38 #[serde(default)]
39 pub coverage_branches_found: u64,
40 #[serde(default)]
41 pub coverage_branches_hit: u64,
42}
43
44impl From<&crate::SummaryTotals> for ScanSummarySnapshot {
45 fn from(t: &crate::SummaryTotals) -> Self {
49 Self {
50 files_analyzed: t.files_analyzed,
51 files_skipped: t.files_skipped,
52 total_physical_lines: t.total_physical_lines,
53 code_lines: t.code_lines,
54 comment_lines: t.comment_lines,
55 blank_lines: t.blank_lines,
56 functions: t.functions,
57 classes: t.classes,
58 variables: t.variables,
59 imports: t.imports,
60 test_count: t.test_count,
61 coverage_lines_found: t.coverage_lines_found,
62 coverage_lines_hit: t.coverage_lines_hit,
63 coverage_functions_found: t.coverage_functions_found,
64 coverage_functions_hit: t.coverage_functions_hit,
65 coverage_branches_found: t.coverage_branches_found,
66 coverage_branches_hit: t.coverage_branches_hit,
67 }
68 }
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct RegistryEntry {
74 pub run_id: String,
75 pub timestamp_utc: DateTime<Utc>,
76 pub project_label: String,
77 pub input_roots: Vec<String>,
78 pub json_path: Option<PathBuf>,
79 pub html_path: Option<PathBuf>,
80 #[serde(default)]
81 pub pdf_path: Option<PathBuf>,
82 #[serde(default)]
83 pub csv_path: Option<PathBuf>,
84 #[serde(default)]
85 pub xlsx_path: Option<PathBuf>,
86 pub summary: ScanSummarySnapshot,
87 #[serde(default)]
89 pub git_branch: Option<String>,
90 #[serde(default)]
92 pub git_commit: Option<String>,
93 #[serde(default)]
95 pub git_commit_long: Option<String>,
96 #[serde(default)]
98 pub git_author: Option<String>,
99 #[serde(default)]
101 pub git_tags: Option<String>,
102 #[serde(default)]
104 pub git_nearest_tag: Option<String>,
105 #[serde(default)]
107 pub git_commit_date: Option<String>,
108 #[serde(default)]
111 pub scan_os: Option<String>,
112 #[serde(default)]
114 pub scan_host: Option<String>,
115 #[serde(default)]
117 pub scan_user: Option<String>,
118 #[serde(default)]
120 pub scan_ci: Option<String>,
121}
122
123impl RegistryEntry {
124 #[must_use]
129 pub fn performed_by(&self) -> String {
130 if let Some(ci) = self.scan_ci.as_deref().filter(|s| !s.is_empty()) {
131 return ci.to_string();
132 }
133 match (
134 self.scan_user.as_deref().filter(|s| !s.is_empty()),
135 self.scan_host.as_deref().filter(|s| !s.is_empty()),
136 ) {
137 (Some(u), Some(h)) => format!("{u} / {h}"),
138 (Some(u), None) => u.to_string(),
139 (None, Some(h)) => h.to_string(),
140 (None, None) => "unknown".to_string(),
141 }
142 }
143}
144
145#[derive(Debug, Default, Serialize, Deserialize)]
148pub struct WatchedDirsStore {
149 pub dirs: Vec<PathBuf>,
150}
151
152impl WatchedDirsStore {
153 #[must_use]
154 pub fn load(path: &Path) -> Self {
155 std::fs::read_to_string(path)
156 .ok()
157 .and_then(|s| serde_json::from_str(&s).ok())
158 .unwrap_or_default()
159 }
160
161 pub fn save(&self, path: &Path) -> Result<()> {
165 let path = crate::pathsafe::reject_traversal(path)?;
166 if let Some(parent) = path.parent() {
167 std::fs::create_dir_all(parent)?;
168 }
169 std::fs::write(&path, serde_json::to_string_pretty(self)?)?;
170 Ok(())
171 }
172
173 pub fn add(&mut self, dir: PathBuf) {
174 if !self.dirs.contains(&dir) {
175 self.dirs.push(dir);
176 }
177 }
178
179 pub fn remove(&mut self, dir: &Path) {
180 self.dirs.retain(|d| d != dir);
181 }
182}
183
184#[derive(Debug, Default, Serialize, Deserialize)]
187pub struct ScanRegistry {
188 pub entries: Vec<RegistryEntry>,
189}
190
191impl ScanRegistry {
192 #[must_use]
194 pub fn load(registry_path: &Path) -> Self {
195 std::fs::read_to_string(registry_path)
196 .ok()
197 .and_then(|s| serde_json::from_str(&s).ok())
198 .unwrap_or_default()
199 }
200
201 pub fn save(&self, registry_path: &Path) -> Result<()> {
205 let registry_path = crate::pathsafe::reject_traversal(registry_path)?;
206 if let Some(parent) = registry_path.parent() {
207 std::fs::create_dir_all(parent)?;
208 }
209 let json = serde_json::to_string_pretty(self)?;
210 std::fs::write(®istry_path, json)?;
211 Ok(())
212 }
213
214 pub fn add_entry(&mut self, entry: RegistryEntry) {
215 self.entries.retain(|e| e.run_id != entry.run_id);
216 self.entries.push(entry);
217 self.entries.sort_by_key(|e| Reverse(e.timestamp_utc));
218 }
219
220 #[must_use]
222 pub fn entries_for_roots(&self, roots: &[String]) -> Vec<&RegistryEntry> {
223 self.entries
224 .iter()
225 .filter(|e| e.input_roots == roots)
226 .collect()
227 }
228
229 #[must_use]
230 pub fn find_by_run_id(&self, run_id: &str) -> Option<&RegistryEntry> {
231 self.entries.iter().find(|e| e.run_id == run_id)
232 }
233
234 pub fn prune_stale(&mut self) {
236 self.entries
237 .retain(|e| e.json_path.as_ref().is_none_or(|p| p.exists()));
238 }
239
240 pub fn remove_entries_under(&mut self, dir: &Path) -> usize {
244 let before = self.entries.len();
245 self.entries.retain(|e| {
246 let under = |p: &Option<PathBuf>| p.as_ref().is_some_and(|path| path.starts_with(dir));
247 !(under(&e.json_path) || under(&e.html_path))
248 });
249 before - self.entries.len()
250 }
251
252 pub fn retain_under_roots(&mut self, roots: &[PathBuf]) -> usize {
258 let before = self.entries.len();
259 self.entries.retain(|e| {
260 let under = |p: &Option<PathBuf>| {
261 p.as_ref()
262 .is_some_and(|path| roots.iter().any(|r| path.starts_with(r)))
263 };
264 under(&e.json_path) || under(&e.html_path)
265 });
266 before - self.entries.len()
267 }
268}
269
270const fn default_interval_hours() -> u32 {
271 24
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize)]
276pub struct CleanupPolicy {
277 pub enabled: bool,
278 #[serde(default)]
280 pub max_age_days: Option<u32>,
281 #[serde(default)]
283 pub max_run_count: Option<u32>,
284 #[serde(default)]
289 pub max_total_mb: Option<u64>,
290 #[serde(default = "default_interval_hours")]
292 pub interval_hours: u32,
293}
294
295#[derive(Debug, Default, Clone, Serialize, Deserialize)]
298pub struct CleanupPolicyStore {
299 pub policy: Option<CleanupPolicy>,
300 #[serde(default)]
302 pub last_run_at: Option<DateTime<Utc>>,
303 #[serde(default)]
305 pub last_run_deleted: Option<u32>,
306}
307
308impl CleanupPolicyStore {
309 #[must_use]
310 pub fn load(path: &Path) -> Self {
311 std::fs::read_to_string(path)
312 .ok()
313 .and_then(|s| serde_json::from_str(&s).ok())
314 .unwrap_or_default()
315 }
316
317 pub fn save(&self, path: &Path) -> Result<()> {
321 let path = crate::pathsafe::reject_traversal(path)?;
322 if let Some(parent) = path.parent() {
323 std::fs::create_dir_all(parent)?;
324 }
325 std::fs::write(&path, serde_json::to_string_pretty(self)?)?;
326 Ok(())
327 }
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333
334 fn entry(run_id: &str, json: &str) -> RegistryEntry {
335 RegistryEntry {
336 run_id: run_id.to_string(),
337 timestamp_utc: Utc::now(),
338 project_label: "proj".to_string(),
339 input_roots: vec![],
340 json_path: Some(PathBuf::from(json)),
341 html_path: None,
342 pdf_path: None,
343 csv_path: None,
344 xlsx_path: None,
345 summary: ScanSummarySnapshot::default(),
346 git_branch: None,
347 git_commit: None,
348 git_commit_long: None,
349 git_author: None,
350 git_tags: None,
351 git_nearest_tag: None,
352 git_commit_date: None,
353 scan_os: None,
354 scan_host: None,
355 scan_user: None,
356 scan_ci: None,
357 }
358 }
359
360 #[test]
361 fn remove_entries_under_drops_only_matching_folder() {
362 let mut reg = ScanRegistry::default();
363 reg.entries
364 .push(entry("a", "/watched/scans/run1/result.json"));
365 reg.entries
366 .push(entry("b", "/watched/scans/sub/json/result.json"));
367 reg.entries.push(entry("c", "/other/place/result.json"));
368
369 let removed = reg.remove_entries_under(Path::new("/watched/scans"));
370 assert_eq!(removed, 2);
371 assert_eq!(reg.entries.len(), 1);
372 assert_eq!(reg.entries[0].run_id, "c");
373 }
374
375 #[test]
376 fn remove_entries_under_no_match_is_noop() {
377 let mut reg = ScanRegistry::default();
378 reg.entries.push(entry("a", "/other/result.json"));
379 assert_eq!(reg.remove_entries_under(Path::new("/watched")), 0);
380 assert_eq!(reg.entries.len(), 1);
381 }
382
383 #[test]
384 fn retain_under_roots_keeps_only_watched_and_native() {
385 let mut reg = ScanRegistry::default();
386 reg.entries
387 .push(entry("native", "/app/out/web/run1/json/result.json"));
388 reg.entries
389 .push(entry("watched", "/watched/scans/run2/json/result.json"));
390 reg.entries
391 .push(entry("orphan", "/removed/folder/run3/json/result.json"));
392
393 let roots = vec![
394 PathBuf::from("/app/out/web"),
395 PathBuf::from("/watched/scans"),
396 ];
397 let removed = reg.retain_under_roots(&roots);
398 assert_eq!(removed, 1);
399 assert_eq!(reg.entries.len(), 2);
400 assert!(reg.entries.iter().all(|e| e.run_id != "orphan"));
401 }
402
403 #[test]
404 fn retain_under_roots_empty_roots_clears_all() {
405 let mut reg = ScanRegistry::default();
406 reg.entries.push(entry("a", "/somewhere/result.json"));
407 reg.entries.push(entry("b", "/elsewhere/result.json"));
408 assert_eq!(reg.retain_under_roots(&[]), 2);
409 assert!(reg.entries.is_empty());
410 }
411}