1use std::path::PathBuf;
2
3use rusqlite::Connection;
4
5use super::{CommandSavings, DailyStats, FilterEvent, GainSummary};
6
7const DB_FILENAME: &str = "cli-denoiser.db";
8const RETENTION_DAYS: u32 = 90;
9
10pub struct TrackerDb {
12 conn: Connection,
13}
14
15impl TrackerDb {
16 pub fn open() -> Result<Self, TrackerError> {
21 let path = db_path()?;
22
23 if let Some(parent) = path.parent() {
25 std::fs::create_dir_all(parent).map_err(|e| TrackerError::Io {
26 path: parent.to_path_buf(),
27 source: e,
28 })?;
29 }
30
31 let conn = Connection::open(&path).map_err(|e| TrackerError::Sqlite {
32 context: "open database".to_string(),
33 source: e,
34 })?;
35
36 conn.execute_batch(
37 "CREATE TABLE IF NOT EXISTS events (
38 id INTEGER PRIMARY KEY AUTOINCREMENT,
39 command TEXT NOT NULL,
40 original_tokens INTEGER NOT NULL,
41 filtered_tokens INTEGER NOT NULL,
42 savings INTEGER NOT NULL,
43 timestamp TEXT NOT NULL
44 );
45 CREATE INDEX IF NOT EXISTS idx_events_timestamp ON events(timestamp);
46 CREATE INDEX IF NOT EXISTS idx_events_command ON events(command);",
47 )
48 .map_err(|e| TrackerError::Sqlite {
49 context: "create tables".to_string(),
50 source: e,
51 })?;
52
53 Ok(Self { conn })
54 }
55
56 pub fn record(&self, event: &FilterEvent) -> Result<(), TrackerError> {
61 self.conn
62 .execute(
63 "INSERT INTO events (command, original_tokens, filtered_tokens, savings, timestamp)
64 VALUES (?1, ?2, ?3, ?4, ?5)",
65 rusqlite::params![
66 event.command,
67 event.original_tokens,
68 event.filtered_tokens,
69 event.savings,
70 event.timestamp,
71 ],
72 )
73 .map_err(|e| TrackerError::Sqlite {
74 context: "insert event".to_string(),
75 source: e,
76 })?;
77 Ok(())
78 }
79
80 #[allow(clippy::cast_precision_loss)]
85 pub fn gain_summary(&self, days: u32) -> Result<GainSummary, TrackerError> {
86 let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(days));
87 let cutoff_str = cutoff.to_rfc3339();
88
89 let mut stmt = self
90 .conn
91 .prepare(
92 "SELECT COUNT(*), COALESCE(SUM(original_tokens), 0),
93 COALESCE(SUM(filtered_tokens), 0), COALESCE(SUM(savings), 0)
94 FROM events WHERE timestamp >= ?1",
95 )
96 .map_err(|e| TrackerError::Sqlite {
97 context: "prepare summary".to_string(),
98 source: e,
99 })?;
100
101 let (total_events, total_original, total_filtered, total_savings): (
102 usize,
103 usize,
104 usize,
105 usize,
106 ) = stmt
107 .query_row(rusqlite::params![cutoff_str], |row| {
108 Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
109 })
110 .map_err(|e| TrackerError::Sqlite {
111 context: "query summary".to_string(),
112 source: e,
113 })?;
114
115 let savings_percent = if total_original == 0 {
116 0.0
117 } else {
118 (total_savings as f64 / total_original as f64) * 100.0
119 };
120
121 let mut cmd_stmt = self
123 .conn
124 .prepare(
125 "SELECT command, COUNT(*), SUM(savings)
126 FROM events WHERE timestamp >= ?1
127 GROUP BY command ORDER BY SUM(savings) DESC LIMIT 10",
128 )
129 .map_err(|e| TrackerError::Sqlite {
130 context: "prepare top commands".to_string(),
131 source: e,
132 })?;
133
134 let top_commands: Vec<CommandSavings> = cmd_stmt
135 .query_map(rusqlite::params![cutoff_str], |row| {
136 Ok(CommandSavings {
137 command: row.get(0)?,
138 events: row.get(1)?,
139 savings: row.get(2)?,
140 })
141 })
142 .map_err(|e| TrackerError::Sqlite {
143 context: "query top commands".to_string(),
144 source: e,
145 })?
146 .filter_map(Result::ok)
147 .collect();
148
149 Ok(GainSummary {
150 total_events,
151 total_original_tokens: total_original,
152 total_filtered_tokens: total_filtered,
153 total_savings,
154 savings_percent,
155 top_commands,
156 period_days: days,
157 })
158 }
159
160 #[allow(clippy::cast_precision_loss)]
165 pub fn daily_report(&self, days: u32) -> Result<Vec<DailyStats>, TrackerError> {
166 let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(days));
167 let cutoff_str = cutoff.to_rfc3339();
168
169 let mut stmt = self
170 .conn
171 .prepare(
172 "SELECT DATE(timestamp) as day,
173 COUNT(*),
174 SUM(original_tokens),
175 SUM(filtered_tokens),
176 SUM(savings)
177 FROM events
178 WHERE timestamp >= ?1
179 GROUP BY day
180 ORDER BY day DESC",
181 )
182 .map_err(|e| TrackerError::Sqlite {
183 context: "prepare daily report".to_string(),
184 source: e,
185 })?;
186
187 let rows: Vec<DailyStats> = stmt
188 .query_map(rusqlite::params![cutoff_str], |row| {
189 let original: usize = row.get(2)?;
190 let filtered: usize = row.get(3)?;
191 let savings: usize = row.get(4)?;
192 let pct = if original == 0 {
193 0.0
194 } else {
195 (savings as f64 / original as f64) * 100.0
196 };
197 Ok(DailyStats {
198 date: row.get(0)?,
199 events: row.get(1)?,
200 original_tokens: original,
201 filtered_tokens: filtered,
202 savings,
203 savings_percent: pct,
204 })
205 })
206 .map_err(|e| TrackerError::Sqlite {
207 context: "query daily report".to_string(),
208 source: e,
209 })?
210 .filter_map(Result::ok)
211 .collect();
212
213 Ok(rows)
214 }
215
216 pub fn recent_events(&self, limit: u32) -> Result<Vec<FilterEvent>, TrackerError> {
221 let mut stmt = self
222 .conn
223 .prepare(
224 "SELECT command, original_tokens, filtered_tokens, savings, timestamp
225 FROM events ORDER BY id DESC LIMIT ?1",
226 )
227 .map_err(|e| TrackerError::Sqlite {
228 context: "prepare recent events".to_string(),
229 source: e,
230 })?;
231
232 let rows: Vec<FilterEvent> = stmt
233 .query_map(rusqlite::params![limit], |row| {
234 Ok(FilterEvent {
235 command: row.get(0)?,
236 original_tokens: row.get(1)?,
237 filtered_tokens: row.get(2)?,
238 savings: row.get(3)?,
239 timestamp: row.get(4)?,
240 })
241 })
242 .map_err(|e| TrackerError::Sqlite {
243 context: "query recent events".to_string(),
244 source: e,
245 })?
246 .filter_map(Result::ok)
247 .collect();
248
249 Ok(rows)
250 }
251
252 pub fn prune(&self) -> Result<usize, TrackerError> {
257 let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(RETENTION_DAYS));
258 let cutoff_str = cutoff.to_rfc3339();
259
260 let deleted = self
261 .conn
262 .execute(
263 "DELETE FROM events WHERE timestamp < ?1",
264 rusqlite::params![cutoff_str],
265 )
266 .map_err(|e| TrackerError::Sqlite {
267 context: "prune old events".to_string(),
268 source: e,
269 })?;
270
271 Ok(deleted)
272 }
273}
274
275fn db_path() -> Result<PathBuf, TrackerError> {
276 let data_dir = dirs::data_local_dir().ok_or(TrackerError::NoDataDir)?;
277 Ok(data_dir.join("cli-denoiser").join(DB_FILENAME))
278}
279
280#[derive(Debug, thiserror::Error)]
281pub enum TrackerError {
282 #[error("no local data directory found")]
283 NoDataDir,
284 #[error("IO error at {path}: {source}")]
285 Io {
286 path: PathBuf,
287 source: std::io::Error,
288 },
289 #[error("SQLite error ({context}): {source}")]
290 Sqlite {
291 context: String,
292 source: rusqlite::Error,
293 },
294}