Skip to main content

cli_denoiser/tracker/
db.rs

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
10/// `SQLite`-backed token savings tracker.
11pub struct TrackerDb {
12    conn: Connection,
13}
14
15impl TrackerDb {
16    /// Open or create the tracker database in the user's data directory.
17    ///
18    /// # Errors
19    /// Returns `TrackerError` if the data directory is unavailable or `SQLite` fails.
20    pub fn open() -> Result<Self, TrackerError> {
21        let path = db_path()?;
22
23        // Ensure parent directory exists
24        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    /// Record a filter event.
57    ///
58    /// # Errors
59    /// Returns `TrackerError` if the insert fails.
60    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    /// Get savings summary for the last N days.
81    ///
82    /// # Errors
83    /// Returns `TrackerError` if the query fails.
84    #[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        // Top commands by savings
122        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    /// Get daily breakdown for the last N days.
161    ///
162    /// # Errors
163    /// Returns `TrackerError` if the query fails.
164    #[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    /// Get recent event log (last N events).
217    ///
218    /// # Errors
219    /// Returns `TrackerError` if the query fails.
220    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    /// Prune events older than retention period.
253    ///
254    /// # Errors
255    /// Returns `TrackerError` if the delete fails.
256    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}