1use 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
29pub 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
39pub 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
54pub 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
75pub 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}