Skip to main content

devclean/
history.rs

1//! Local `SQLite` history for scan trends and cleanup outcomes.
2
3use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5use std::time::{SystemTime, UNIX_EPOCH};
6
7use anyhow::{Context, Result};
8use directories::BaseDirs;
9use rusqlite::{Connection, OptionalExtension, params};
10use serde::Serialize;
11
12use crate::cleaner::CleanReport;
13use crate::model::{Category, ScanReport};
14use crate::scanner::totals_by_category;
15
16#[derive(Debug, Clone, Serialize)]
17pub struct HistorySummary {
18    pub days: u64,
19    pub scan_count: u64,
20    pub cleanup_count: u64,
21    pub latest_reclaimable_bytes: u64,
22    pub reclaimable_change_bytes: i64,
23    pub removed_bytes: u64,
24    pub quarantined_bytes: u64,
25    pub failures: u64,
26    pub category_change_bytes: BTreeMap<Category, i64>,
27}
28
29/// Returns the platform-local history database path.
30///
31/// # Errors
32///
33/// Returns an error when the platform data directory is unavailable.
34pub fn default_database_path() -> Result<PathBuf> {
35    let base = BaseDirs::new().context("platform data directory is unavailable")?;
36    Ok(base.data_local_dir().join("devclean/history.sqlite3"))
37}
38
39/// Records a privacy-local aggregate scan event.
40///
41/// # Errors
42///
43/// Returns an error when the database cannot be opened, migrated, or written.
44pub fn record_scan(report: &ScanReport, path: Option<&Path>) -> Result<()> {
45    let connection = open(path)?;
46    let categories = serde_json::to_string(&totals_by_category(report))?;
47    connection.execute(
48        "INSERT INTO scan_events(at_unix,total_bytes,candidate_count,categories_json) VALUES (?1,?2,?3,?4)",
49        params![to_i64(now_unix()), to_i64(report.total_bytes), to_i64(report.candidates.len() as u64), categories],
50    )?;
51    Ok(())
52}
53
54/// Records one cleanup outcome without storing candidate paths.
55///
56/// # Errors
57///
58/// Returns an error when the database cannot be opened, migrated, or written.
59pub fn record_cleanup(report: &CleanReport, path: Option<&Path>) -> Result<()> {
60    let connection = open(path)?;
61    connection.execute(
62        "INSERT INTO cleanup_events(at_unix,removed_bytes,quarantined_bytes,removed_count,held_count,failures) VALUES (?1,?2,?3,?4,?5,?6)",
63        params![
64            to_i64(now_unix()),
65            to_i64(report.removed_bytes),
66            to_i64(report.quarantined_bytes),
67            to_i64(report.removed.len() as u64),
68            to_i64(report.quarantined.len() as u64),
69            to_i64(report.failures.len() as u64),
70        ],
71    )?;
72    Ok(())
73}
74
75/// Summarizes scan growth and cleanup outcomes over a rolling window.
76///
77/// # Errors
78///
79/// Returns an error when the database cannot be opened or queried.
80pub fn summarize(days: u64, path: Option<&Path>) -> Result<HistorySummary> {
81    let connection = open(path)?;
82    let since = now_unix().saturating_sub(days.saturating_mul(86_400));
83    let scan_count = query_u64(
84        &connection,
85        "SELECT COUNT(*) FROM scan_events WHERE at_unix >= ?1",
86        since,
87    )?;
88    let cleanup_count = query_u64(
89        &connection,
90        "SELECT COUNT(*) FROM cleanup_events WHERE at_unix >= ?1",
91        since,
92    )?;
93    let cleanup_totals = connection.query_row(
94        "SELECT COALESCE(SUM(removed_bytes),0),COALESCE(SUM(quarantined_bytes),0),COALESCE(SUM(failures),0) FROM cleanup_events WHERE at_unix >= ?1",
95        [to_i64(since)],
96        |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?, row.get::<_, i64>(2)?)),
97    )?;
98    let first = scan_snapshot(&connection, since, "ASC")?;
99    let latest = scan_snapshot(&connection, since, "DESC")?;
100    let latest_bytes = latest.as_ref().map_or(0, |snapshot| snapshot.0);
101    let first_bytes = first.as_ref().map_or(latest_bytes, |snapshot| snapshot.0);
102    let category_change_bytes = category_delta(
103        first.as_ref().map(|snapshot| &snapshot.1),
104        latest.as_ref().map(|snapshot| &snapshot.1),
105    );
106
107    Ok(HistorySummary {
108        days,
109        scan_count,
110        cleanup_count,
111        latest_reclaimable_bytes: latest_bytes,
112        reclaimable_change_bytes: signed_delta(latest_bytes, first_bytes),
113        removed_bytes: from_i64(cleanup_totals.0),
114        quarantined_bytes: from_i64(cleanup_totals.1),
115        failures: from_i64(cleanup_totals.2),
116        category_change_bytes,
117    })
118}
119
120fn open(path: Option<&Path>) -> Result<Connection> {
121    let path = path.map_or_else(default_database_path, |value| Ok(value.to_path_buf()))?;
122    if let Some(parent) = path.parent() {
123        std::fs::create_dir_all(parent)?;
124    }
125    let connection = Connection::open(&path)
126        .with_context(|| format!("failed to open history database {}", path.display()))?;
127    connection.execute_batch(
128        "PRAGMA journal_mode=WAL;
129         CREATE TABLE IF NOT EXISTS scan_events(id INTEGER PRIMARY KEY,at_unix INTEGER NOT NULL,total_bytes INTEGER NOT NULL,candidate_count INTEGER NOT NULL,categories_json TEXT NOT NULL);
130         CREATE INDEX IF NOT EXISTS scan_events_at ON scan_events(at_unix);
131         CREATE TABLE IF NOT EXISTS cleanup_events(id INTEGER PRIMARY KEY,at_unix INTEGER NOT NULL,removed_bytes INTEGER NOT NULL,quarantined_bytes INTEGER NOT NULL,removed_count INTEGER NOT NULL,held_count INTEGER NOT NULL,failures INTEGER NOT NULL);
132         CREATE INDEX IF NOT EXISTS cleanup_events_at ON cleanup_events(at_unix);",
133    )?;
134    Ok(connection)
135}
136
137fn scan_snapshot(
138    connection: &Connection,
139    since: u64,
140    order: &str,
141) -> Result<Option<(u64, BTreeMap<Category, u64>)>> {
142    let sql = format!(
143        "SELECT total_bytes,categories_json FROM scan_events WHERE at_unix >= ?1 ORDER BY at_unix {order},id {order} LIMIT 1"
144    );
145    let raw = connection
146        .query_row(&sql, [to_i64(since)], |row| {
147            Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
148        })
149        .optional()?;
150    raw.map(|(bytes, categories)| {
151        Ok((
152            from_i64(bytes),
153            serde_json::from_str::<BTreeMap<Category, u64>>(&categories)?,
154        ))
155    })
156    .transpose()
157}
158
159fn category_delta(
160    first: Option<&BTreeMap<Category, u64>>,
161    latest: Option<&BTreeMap<Category, u64>>,
162) -> BTreeMap<Category, i64> {
163    Category::all()
164        .into_iter()
165        .filter_map(|category| {
166            let old = first
167                .and_then(|values| values.get(&category))
168                .copied()
169                .unwrap_or(0);
170            let new = latest
171                .and_then(|values| values.get(&category))
172                .copied()
173                .unwrap_or(0);
174            let delta = signed_delta(new, old);
175            (delta != 0).then_some((category, delta))
176        })
177        .collect()
178}
179
180fn query_u64(connection: &Connection, sql: &str, since: u64) -> Result<u64> {
181    Ok(from_i64(connection.query_row(
182        sql,
183        [to_i64(since)],
184        |row| row.get(0),
185    )?))
186}
187
188fn now_unix() -> u64 {
189    SystemTime::now()
190        .duration_since(UNIX_EPOCH)
191        .map_or(0, |duration| duration.as_secs())
192}
193
194fn signed_delta(new: u64, old: u64) -> i64 {
195    i64::try_from(
196        i128::from(new)
197            .saturating_sub(i128::from(old))
198            .clamp(i128::from(i64::MIN), i128::from(i64::MAX)),
199    )
200    .expect("clamped delta always fits in i64")
201}
202
203fn to_i64(value: u64) -> i64 {
204    i64::try_from(value).unwrap_or(i64::MAX)
205}
206
207fn from_i64(value: i64) -> u64 {
208    u64::try_from(value).unwrap_or(0)
209}
210
211#[cfg(test)]
212mod tests {
213    use tempfile::tempdir;
214
215    use super::*;
216    use crate::model::{Candidate, Confidence};
217
218    #[test]
219    fn history_should_record_aggregate_scan_without_paths() -> Result<()> {
220        let temporary = tempdir()?;
221        let database = temporary.path().join("history.sqlite3");
222        let report = ScanReport {
223            roots: vec![temporary.path().to_path_buf()],
224            candidates: vec![Candidate {
225                category: Category::NodeModules,
226                path: temporary.path().join("private/node_modules"),
227                bytes: 2048,
228                reason: "test".to_owned(),
229                modified_at_unix: None,
230                confidence: Confidence::Safe,
231                approved_rule: None,
232                custom_rule: None,
233            }],
234            review_candidates: Vec::new(),
235            learning_observations: Vec::new(),
236            warnings: Vec::new(),
237            total_bytes: 2048,
238            review_total_bytes: 0,
239            observed_total_bytes: 0,
240            protect_git_tracked: true,
241        };
242
243        record_scan(&report, Some(&database))?;
244        let summary = summarize(30, Some(&database))?;
245        let raw = std::fs::read(&database)?;
246
247        assert_eq!(summary.scan_count, 1);
248        assert_eq!(summary.latest_reclaimable_bytes, 2048);
249        assert!(
250            !raw.windows(b"private/node_modules".len())
251                .any(|window| window == b"private/node_modules")
252        );
253        Ok(())
254    }
255}