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}
109
110/// Persistent list of directories the user has chosen to watch for new reports.
111/// Stored as `watched_dirs.json` adjacent to `registry.json`.
112#[derive(Debug, Default, Serialize, Deserialize)]
113pub struct WatchedDirsStore {
114    pub dirs: Vec<PathBuf>,
115}
116
117impl WatchedDirsStore {
118    #[must_use]
119    pub fn load(path: &Path) -> Self {
120        std::fs::read_to_string(path)
121            .ok()
122            .and_then(|s| serde_json::from_str(&s).ok())
123            .unwrap_or_default()
124    }
125
126    /// # Errors
127    ///
128    /// Returns an error if the file cannot be written.
129    pub fn save(&self, path: &Path) -> Result<()> {
130        if let Some(parent) = path.parent() {
131            std::fs::create_dir_all(parent)?;
132        }
133        std::fs::write(path, serde_json::to_string_pretty(self)?)?;
134        Ok(())
135    }
136
137    pub fn add(&mut self, dir: PathBuf) {
138        if !self.dirs.contains(&dir) {
139            self.dirs.push(dir);
140        }
141    }
142
143    pub fn remove(&mut self, dir: &Path) {
144        self.dirs.retain(|d| d != dir);
145    }
146}
147
148/// Persistent on-disk index of all past scans for this workspace.
149/// Stored as `registry.json` adjacent to the scan output directories.
150#[derive(Debug, Default, Serialize, Deserialize)]
151pub struct ScanRegistry {
152    pub entries: Vec<RegistryEntry>,
153}
154
155impl ScanRegistry {
156    /// Load from disk; returns an empty registry on missing file or parse error.
157    #[must_use]
158    pub fn load(registry_path: &Path) -> Self {
159        std::fs::read_to_string(registry_path)
160            .ok()
161            .and_then(|s| serde_json::from_str(&s).ok())
162            .unwrap_or_default()
163    }
164
165    /// # Errors
166    ///
167    /// Returns an error if the parent directory cannot be created or the file cannot be written.
168    pub fn save(&self, registry_path: &Path) -> Result<()> {
169        if let Some(parent) = registry_path.parent() {
170            std::fs::create_dir_all(parent)?;
171        }
172        let json = serde_json::to_string_pretty(self)?;
173        std::fs::write(registry_path, json)?;
174        Ok(())
175    }
176
177    pub fn add_entry(&mut self, entry: RegistryEntry) {
178        self.entries.retain(|e| e.run_id != entry.run_id);
179        self.entries.push(entry);
180        self.entries.sort_by_key(|e| Reverse(e.timestamp_utc));
181    }
182
183    /// All entries whose `input_roots` exactly match, newest first.
184    #[must_use]
185    pub fn entries_for_roots(&self, roots: &[String]) -> Vec<&RegistryEntry> {
186        self.entries
187            .iter()
188            .filter(|e| e.input_roots == roots)
189            .collect()
190    }
191
192    #[must_use]
193    pub fn find_by_run_id(&self, run_id: &str) -> Option<&RegistryEntry> {
194        self.entries.iter().find(|e| e.run_id == run_id)
195    }
196
197    /// Remove entries whose `json_path` no longer exists on disk.
198    pub fn prune_stale(&mut self) {
199        self.entries
200            .retain(|e| e.json_path.as_ref().is_none_or(|p| p.exists()));
201    }
202}
203
204const fn default_interval_hours() -> u32 {
205    24
206}
207
208/// Rules for automatic periodic cleanup of old scan runs.
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct CleanupPolicy {
211    pub enabled: bool,
212    /// Delete runs older than this many days. `None` disables age-based cleanup.
213    #[serde(default)]
214    pub max_age_days: Option<u32>,
215    /// Keep only the N most recent runs; delete older ones. `None` disables count-based cleanup.
216    #[serde(default)]
217    pub max_run_count: Option<u32>,
218    /// Hours between automatic cleanup passes (minimum 1, default 24).
219    #[serde(default = "default_interval_hours")]
220    pub interval_hours: u32,
221}
222
223/// Persisted store for the auto-cleanup policy and last-run metadata.
224/// Stored as `cleanup_policy.json` adjacent to `registry.json`.
225#[derive(Debug, Default, Clone, Serialize, Deserialize)]
226pub struct CleanupPolicyStore {
227    pub policy: Option<CleanupPolicy>,
228    /// When the background task last ran a cleanup pass.
229    #[serde(default)]
230    pub last_run_at: Option<DateTime<Utc>>,
231    /// Number of runs deleted in the last cleanup pass.
232    #[serde(default)]
233    pub last_run_deleted: Option<u32>,
234}
235
236impl CleanupPolicyStore {
237    #[must_use]
238    pub fn load(path: &Path) -> Self {
239        std::fs::read_to_string(path)
240            .ok()
241            .and_then(|s| serde_json::from_str(&s).ok())
242            .unwrap_or_default()
243    }
244
245    /// # Errors
246    ///
247    /// Returns an error if the file cannot be written.
248    pub fn save(&self, path: &Path) -> Result<()> {
249        if let Some(parent) = path.parent() {
250            std::fs::create_dir_all(parent)?;
251        }
252        std::fs::write(path, serde_json::to_string_pretty(self)?)?;
253        Ok(())
254    }
255}