Skip to main content

sloc_core/
history.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Copyright (C) 2026 Nima Shafie <nimzshafie@gmail.com>
3
4use std::cmp::Reverse;
5use std::path::{Path, PathBuf};
6
7use anyhow::Result;
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10
11/// Lightweight summary snapshot stored in the registry — avoids loading full JSON per entry.
12#[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    /// Project the full per-run totals down to the lightweight registry/baseline snapshot.
46    /// Centralises the field-by-field copy that callers (CLI baseline, web registry) would
47    /// otherwise duplicate.
48    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/// One entry in the scan registry — one per completed analysis run.
72#[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    /// Git branch active at scan time, if the project is a git repo.
88    #[serde(default)]
89    pub git_branch: Option<String>,
90    /// Short git commit SHA active at scan time.
91    #[serde(default)]
92    pub git_commit: Option<String>,
93    /// Full-length git commit SHA active at scan time (shown on hover).
94    #[serde(default)]
95    pub git_commit_long: Option<String>,
96    /// Author of the last git commit at scan time.
97    #[serde(default)]
98    pub git_author: Option<String>,
99    /// Comma-separated git tags pointing at HEAD at scan time.
100    #[serde(default)]
101    pub git_tags: Option<String>,
102    /// Nearest ancestor release tag (output of `git describe --tags --abbrev=0`).
103    #[serde(default)]
104    pub git_nearest_tag: Option<String>,
105    /// ISO 8601 author-date of the last git commit at scan time.
106    #[serde(default)]
107    pub git_commit_date: Option<String>,
108    /// Operating system the scan ran on (e.g. "windows", "linux"). Lets pooled reports
109    /// from different environments be told apart in the list/compare/trend views.
110    #[serde(default)]
111    pub scan_os: Option<String>,
112    /// Hostname (or CI node name) the scan ran on.
113    #[serde(default)]
114    pub scan_host: Option<String>,
115    /// User who initiated the scan.
116    #[serde(default)]
117    pub scan_user: Option<String>,
118    /// Detected CI system name (e.g. "Jenkins", "GitHub Actions"), if the scan ran in CI.
119    #[serde(default)]
120    pub scan_ci: Option<String>,
121}
122
123impl RegistryEntry {
124    /// Human-readable label for who/what produced this report. Mirrors the single-report
125    /// page's "Scan by" chip so pooled reports from different environments/users are
126    /// distinguishable in the list, compare, and trend views: the CI system name when the
127    /// scan ran in CI, otherwise `user / host`, otherwise "unknown".
128    #[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/// Persistent list of directories the user has chosen to watch for new reports.
146/// Stored as `watched_dirs.json` adjacent to `registry.json`.
147#[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    /// # Errors
162    ///
163    /// Returns an error if the file cannot be written.
164    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/// Persistent on-disk index of all past scans for this workspace.
185/// Stored as `registry.json` adjacent to the scan output directories.
186#[derive(Debug, Default, Serialize, Deserialize)]
187pub struct ScanRegistry {
188    pub entries: Vec<RegistryEntry>,
189}
190
191impl ScanRegistry {
192    /// Load from disk; returns an empty registry on missing file or parse error.
193    #[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    /// # Errors
202    ///
203    /// Returns an error if the parent directory cannot be created or the file cannot be written.
204    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(&registry_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    /// All entries whose `input_roots` exactly match, newest first.
221    #[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    /// Remove entries whose `json_path` no longer exists on disk.
235    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    /// Remove every entry whose artifacts live under `dir` (the directory itself or any
241    /// descendant). Used when a watched folder is un-watched so its linked reports leave the
242    /// list too. Returns the number of entries removed.
243    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    /// Keep only entries whose artifacts live under one of `roots` — the currently-watched
253    /// folders plus the app's own output directory. Everything else (leftovers from folders
254    /// that are no longer watched, or other external strays) is dropped. This enforces the
255    /// invariant that the report list reflects exactly the watched folders and native scans.
256    /// Returns the number of entries removed.
257    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/// Rules for automatic periodic cleanup of old scan runs.
275#[derive(Debug, Clone, Serialize, Deserialize)]
276pub struct CleanupPolicy {
277    pub enabled: bool,
278    /// Delete runs older than this many days. `None` disables age-based cleanup.
279    #[serde(default)]
280    pub max_age_days: Option<u32>,
281    /// Keep only the N most recent runs; delete older ones. `None` disables count-based cleanup.
282    #[serde(default)]
283    pub max_run_count: Option<u32>,
284    /// Cap the total on-disk size of retained scan artifacts, in megabytes. When the
285    /// artifact tree exceeds this, the oldest runs are deleted until it fits again.
286    /// `None` disables size-based cleanup. The `SLOC_MAX_DISK_MB` operator environment
287    /// variable is a separate hard ceiling; when both are set the smaller wins.
288    #[serde(default)]
289    pub max_total_mb: Option<u64>,
290    /// Hours between automatic cleanup passes (minimum 1, default 24).
291    #[serde(default = "default_interval_hours")]
292    pub interval_hours: u32,
293}
294
295/// Persisted store for the auto-cleanup policy and last-run metadata.
296/// Stored as `cleanup_policy.json` adjacent to `registry.json`.
297#[derive(Debug, Default, Clone, Serialize, Deserialize)]
298pub struct CleanupPolicyStore {
299    pub policy: Option<CleanupPolicy>,
300    /// When the background task last ran a cleanup pass.
301    #[serde(default)]
302    pub last_run_at: Option<DateTime<Utc>>,
303    /// Number of runs deleted in the last cleanup pass.
304    #[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    /// # Errors
318    ///
319    /// Returns an error if the file cannot be written.
320    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}