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
44/// One entry in the scan registry — one per completed analysis run.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct RegistryEntry {
47    pub run_id: String,
48    pub timestamp_utc: DateTime<Utc>,
49    pub project_label: String,
50    pub input_roots: Vec<String>,
51    pub json_path: Option<PathBuf>,
52    pub html_path: Option<PathBuf>,
53    #[serde(default)]
54    pub pdf_path: Option<PathBuf>,
55    #[serde(default)]
56    pub csv_path: Option<PathBuf>,
57    #[serde(default)]
58    pub xlsx_path: Option<PathBuf>,
59    pub summary: ScanSummarySnapshot,
60    /// Git branch active at scan time, if the project is a git repo.
61    #[serde(default)]
62    pub git_branch: Option<String>,
63    /// Short git commit SHA active at scan time.
64    #[serde(default)]
65    pub git_commit: Option<String>,
66    /// Author of the last git commit at scan time.
67    #[serde(default)]
68    pub git_author: Option<String>,
69    /// Comma-separated git tags pointing at HEAD at scan time.
70    #[serde(default)]
71    pub git_tags: Option<String>,
72    /// Nearest ancestor release tag (output of `git describe --tags --abbrev=0`).
73    #[serde(default)]
74    pub git_nearest_tag: Option<String>,
75    /// ISO 8601 author-date of the last git commit at scan time.
76    #[serde(default)]
77    pub git_commit_date: Option<String>,
78}
79
80/// Persistent list of directories the user has chosen to watch for new reports.
81/// Stored as `watched_dirs.json` adjacent to `registry.json`.
82#[derive(Debug, Default, Serialize, Deserialize)]
83pub struct WatchedDirsStore {
84    pub dirs: Vec<PathBuf>,
85}
86
87impl WatchedDirsStore {
88    #[must_use]
89    pub fn load(path: &Path) -> Self {
90        std::fs::read_to_string(path)
91            .ok()
92            .and_then(|s| serde_json::from_str(&s).ok())
93            .unwrap_or_default()
94    }
95
96    /// # Errors
97    ///
98    /// Returns an error if the file cannot be written.
99    pub fn save(&self, path: &Path) -> Result<()> {
100        if let Some(parent) = path.parent() {
101            std::fs::create_dir_all(parent)?;
102        }
103        std::fs::write(path, serde_json::to_string_pretty(self)?)?;
104        Ok(())
105    }
106
107    pub fn add(&mut self, dir: PathBuf) {
108        if !self.dirs.contains(&dir) {
109            self.dirs.push(dir);
110        }
111    }
112
113    pub fn remove(&mut self, dir: &Path) {
114        self.dirs.retain(|d| d != dir);
115    }
116}
117
118/// Persistent on-disk index of all past scans for this workspace.
119/// Stored as `registry.json` adjacent to the scan output directories.
120#[derive(Debug, Default, Serialize, Deserialize)]
121pub struct ScanRegistry {
122    pub entries: Vec<RegistryEntry>,
123}
124
125impl ScanRegistry {
126    /// Load from disk; returns an empty registry on missing file or parse error.
127    #[must_use]
128    pub fn load(registry_path: &Path) -> Self {
129        std::fs::read_to_string(registry_path)
130            .ok()
131            .and_then(|s| serde_json::from_str(&s).ok())
132            .unwrap_or_default()
133    }
134
135    /// # Errors
136    ///
137    /// Returns an error if the parent directory cannot be created or the file cannot be written.
138    pub fn save(&self, registry_path: &Path) -> Result<()> {
139        if let Some(parent) = registry_path.parent() {
140            std::fs::create_dir_all(parent)?;
141        }
142        let json = serde_json::to_string_pretty(self)?;
143        std::fs::write(registry_path, json)?;
144        Ok(())
145    }
146
147    pub fn add_entry(&mut self, entry: RegistryEntry) {
148        self.entries.retain(|e| e.run_id != entry.run_id);
149        self.entries.push(entry);
150        self.entries.sort_by_key(|e| Reverse(e.timestamp_utc));
151    }
152
153    /// All entries whose `input_roots` exactly match, newest first.
154    #[must_use]
155    pub fn entries_for_roots(&self, roots: &[String]) -> Vec<&RegistryEntry> {
156        self.entries
157            .iter()
158            .filter(|e| e.input_roots == roots)
159            .collect()
160    }
161
162    #[must_use]
163    pub fn find_by_run_id(&self, run_id: &str) -> Option<&RegistryEntry> {
164        self.entries.iter().find(|e| e.run_id == run_id)
165    }
166
167    /// Remove entries whose `json_path` no longer exists on disk.
168    pub fn prune_stale(&mut self) {
169        self.entries
170            .retain(|e| e.json_path.as_ref().is_none_or(|p| p.exists()));
171    }
172}
173
174fn default_interval_hours() -> u32 {
175    24
176}
177
178/// Rules for automatic periodic cleanup of old scan runs.
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct CleanupPolicy {
181    pub enabled: bool,
182    /// Delete runs older than this many days. `None` disables age-based cleanup.
183    #[serde(default)]
184    pub max_age_days: Option<u32>,
185    /// Keep only the N most recent runs; delete older ones. `None` disables count-based cleanup.
186    #[serde(default)]
187    pub max_run_count: Option<u32>,
188    /// Hours between automatic cleanup passes (minimum 1, default 24).
189    #[serde(default = "default_interval_hours")]
190    pub interval_hours: u32,
191}
192
193/// Persisted store for the auto-cleanup policy and last-run metadata.
194/// Stored as `cleanup_policy.json` adjacent to `registry.json`.
195#[derive(Debug, Default, Clone, Serialize, Deserialize)]
196pub struct CleanupPolicyStore {
197    pub policy: Option<CleanupPolicy>,
198    /// When the background task last ran a cleanup pass.
199    #[serde(default)]
200    pub last_run_at: Option<DateTime<Utc>>,
201    /// Number of runs deleted in the last cleanup pass.
202    #[serde(default)]
203    pub last_run_deleted: Option<u32>,
204}
205
206impl CleanupPolicyStore {
207    #[must_use]
208    pub fn load(path: &Path) -> Self {
209        std::fs::read_to_string(path)
210            .ok()
211            .and_then(|s| serde_json::from_str(&s).ok())
212            .unwrap_or_default()
213    }
214
215    /// # Errors
216    ///
217    /// Returns an error if the file cannot be written.
218    pub fn save(&self, path: &Path) -> Result<()> {
219        if let Some(parent) = path.parent() {
220            std::fs::create_dir_all(parent)?;
221        }
222        std::fs::write(path, serde_json::to_string_pretty(self)?)?;
223        Ok(())
224    }
225}