1use anyhow::Result;
2use rusqlite::Connection;
3use std::path::Path;
4
5pub struct Db {
7 conn: Connection,
8}
9
10pub struct TrackRecord {
12 pub original_cmd: String,
13 pub lowfat_cmd: String,
14 pub raw: String,
15 pub filtered: String,
16 pub exec_time_ms: u64,
17 pub project_path: String,
18}
19
20pub struct InvocationRecord {
22 pub command: String,
23 pub subcommand: String,
24 pub raw_tokens: u64,
25 pub filtered_tokens: u64,
26 pub had_plugin: bool,
27 pub exit_code: i32,
28}
29
30#[derive(Debug)]
32pub struct HistoryRow {
33 pub command: String,
34 pub subcommand: String,
35 pub runs: u64,
36 pub avg_raw_tokens: f64,
37 pub savings_pct: f64,
38 pub plugin_ratio: f64,
39 pub score: f64,
40}
41
42#[derive(Debug)]
44pub struct InvocationExport {
45 pub timestamp: String,
46 pub command: String,
47 pub subcommand: String,
48 pub raw_tokens: u64,
49 pub filtered_tokens: u64,
50 pub had_plugin: bool,
51 pub exit_code: i32,
52}
53
54const INVOCATIONS_CAP: i64 = 10_000;
56
57#[derive(Debug)]
59pub struct GainSummary {
60 pub commands: u64,
61 pub input_tokens: u64,
62 pub output_tokens: u64,
63 pub saved_tokens: u64,
64 pub savings_pct: f64,
65}
66
67#[derive(Debug)]
69pub struct TopCommand {
70 pub command: String,
71 pub runs: u64,
72 pub saved: i64,
73 pub avg_pct: f64,
74}
75
76#[derive(Debug)]
78pub struct SessionSummary {
79 pub commands: u64,
80 pub input_tokens: u64,
81 pub output_tokens: u64,
82 pub saved_tokens: i64,
83 pub savings_pct: f64,
84 pub total_time_ms: u64,
85}
86
87impl Db {
88 pub fn open(data_dir: &Path) -> Result<Self> {
90 std::fs::create_dir_all(data_dir)?;
91 let db_path = data_dir.join("history.db");
92 let conn = Connection::open(&db_path)?;
93 conn.execute_batch(
94 "CREATE TABLE IF NOT EXISTS commands (
95 id INTEGER PRIMARY KEY,
96 timestamp TEXT NOT NULL,
97 original_cmd TEXT NOT NULL,
98 lowfat_cmd TEXT NOT NULL,
99 input_tokens INTEGER NOT NULL,
100 output_tokens INTEGER NOT NULL,
101 saved_tokens INTEGER NOT NULL,
102 savings_pct REAL NOT NULL,
103 exec_time_ms INTEGER DEFAULT 0,
104 project_path TEXT DEFAULT ''
105 );
106 CREATE INDEX IF NOT EXISTS idx_timestamp ON commands(timestamp);
107 CREATE INDEX IF NOT EXISTS idx_project ON commands(project_path, timestamp);
108
109 CREATE TABLE IF NOT EXISTS audit (
110 id INTEGER PRIMARY KEY,
111 timestamp TEXT NOT NULL,
112 plugin_name TEXT NOT NULL,
113 runtime_type TEXT NOT NULL,
114 command TEXT NOT NULL,
115 action TEXT NOT NULL,
116 checksum TEXT DEFAULT '',
117 details TEXT DEFAULT ''
118 );
119 CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit(timestamp);
120
121 CREATE TABLE IF NOT EXISTS invocations (
122 id INTEGER PRIMARY KEY AUTOINCREMENT,
123 timestamp TEXT NOT NULL,
124 command TEXT NOT NULL,
125 subcommand TEXT NOT NULL DEFAULT '',
126 raw_tokens INTEGER NOT NULL,
127 filtered_tokens INTEGER NOT NULL,
128 had_plugin INTEGER NOT NULL,
129 exit_code INTEGER NOT NULL
130 );
131 CREATE INDEX IF NOT EXISTS idx_invocations_cmd ON invocations(command, subcommand);",
132 )?;
133 Ok(Db { conn })
134 }
135
136 pub fn track(&self, record: &TrackRecord) -> Result<()> {
138 let in_tok = crate::tokens::estimate_tokens(&record.raw);
139 let out_tok = crate::tokens::estimate_tokens(&record.filtered);
140 let saved = in_tok as i64 - out_tok as i64;
141 let pct = if in_tok > 0 {
142 (saved as f64 / in_tok as f64) * 100.0
143 } else {
144 0.0
145 };
146
147 self.conn.execute(
148 "INSERT INTO commands(timestamp, original_cmd, lowfat_cmd, input_tokens, output_tokens, saved_tokens, savings_pct, exec_time_ms, project_path)
149 VALUES(datetime('now'), ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
150 rusqlite::params![
151 record.original_cmd,
152 record.lowfat_cmd,
153 in_tok as i64,
154 out_tok as i64,
155 saved,
156 pct,
157 record.exec_time_ms as i64,
158 record.project_path,
159 ],
160 )?;
161 Ok(())
162 }
163
164 pub fn record_invocation(&self, rec: &InvocationRecord) -> Result<()> {
167 self.conn.execute(
168 "INSERT INTO invocations(timestamp, command, subcommand, raw_tokens, filtered_tokens, had_plugin, exit_code)
169 VALUES(datetime('now'), ?1, ?2, ?3, ?4, ?5, ?6)",
170 rusqlite::params![
171 rec.command,
172 rec.subcommand,
173 rec.raw_tokens as i64,
174 rec.filtered_tokens as i64,
175 rec.had_plugin as i64,
176 rec.exit_code,
177 ],
178 )?;
179 self.conn.execute(
181 "DELETE FROM invocations WHERE id <= (SELECT MAX(id) - ?1 FROM invocations)",
182 [INVOCATIONS_CAP],
183 )?;
184 Ok(())
185 }
186
187 pub fn export_invocations(&self) -> Result<Vec<InvocationExport>> {
190 let mut stmt = self.conn.prepare(
191 "SELECT timestamp, command, subcommand, raw_tokens, filtered_tokens, had_plugin, exit_code
192 FROM invocations ORDER BY id ASC",
193 )?;
194 let rows = stmt.query_map([], |row| {
195 Ok(InvocationExport {
196 timestamp: row.get(0)?,
197 command: row.get(1)?,
198 subcommand: row.get(2)?,
199 raw_tokens: row.get::<_, i64>(3)? as u64,
200 filtered_tokens: row.get::<_, i64>(4)? as u64,
201 had_plugin: row.get::<_, i64>(5)? != 0,
202 exit_code: row.get(6)?,
203 })
204 })?;
205 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
206 }
207
208 pub fn history_ranking(&self, limit: usize) -> Result<Vec<HistoryRow>> {
212 let mut stmt = self.conn.prepare(
213 "SELECT command, subcommand,
214 COUNT(*) AS runs,
215 AVG(raw_tokens) AS avg_raw,
216 CASE WHEN SUM(raw_tokens) > 0
217 THEN 100.0 * (1.0 - 1.0 * SUM(filtered_tokens) / SUM(raw_tokens))
218 ELSE 0 END AS savings_pct,
219 AVG(had_plugin) AS plugin_ratio,
220 COUNT(*) * AVG(raw_tokens) *
221 (CASE WHEN SUM(raw_tokens) > 0
222 THEN 1.0 - 1.0 * SUM(filtered_tokens) / SUM(raw_tokens)
223 ELSE 0 END) AS score
224 FROM invocations
225 GROUP BY command, subcommand
226 ORDER BY score DESC
227 LIMIT ?1",
228 )?;
229 let rows = stmt.query_map([limit as i64], |row| {
230 Ok(HistoryRow {
231 command: row.get(0)?,
232 subcommand: row.get(1)?,
233 runs: row.get::<_, i64>(2)? as u64,
234 avg_raw_tokens: row.get(3)?,
235 savings_pct: row.get(4)?,
236 plugin_ratio: row.get(5)?,
237 score: row.get(6)?,
238 })
239 })?;
240 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
241 }
242
243 pub fn gain_summary(&self) -> Result<GainSummary> {
245 let mut stmt = self.conn.prepare(
246 "SELECT COUNT(*), COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0),
247 COALESCE(SUM(saved_tokens),0),
248 CASE WHEN SUM(input_tokens)>0
249 THEN ROUND(100.0*SUM(saved_tokens)/SUM(input_tokens),1) ELSE 0 END
250 FROM commands",
251 )?;
252 let row = stmt.query_row([], |row| {
253 Ok(GainSummary {
254 commands: row.get::<_, i64>(0)? as u64,
255 input_tokens: row.get::<_, i64>(1)? as u64,
256 output_tokens: row.get::<_, i64>(2)? as u64,
257 saved_tokens: row.get::<_, i64>(3)? as u64,
258 savings_pct: row.get(4)?,
259 })
260 })?;
261 Ok(row)
262 }
263
264 pub fn top_commands(&self, limit: usize) -> Result<Vec<TopCommand>> {
266 let mut stmt = self.conn.prepare(
267 "SELECT original_cmd, COUNT(*), SUM(saved_tokens), ROUND(AVG(savings_pct),1)
268 FROM commands GROUP BY original_cmd ORDER BY SUM(saved_tokens) DESC LIMIT ?1",
269 )?;
270 let rows = stmt.query_map([limit as i64], |row| {
271 Ok(TopCommand {
272 command: row.get(0)?,
273 runs: row.get::<_, i64>(1)? as u64,
274 saved: row.get(2)?,
275 avg_pct: row.get(3)?,
276 })
277 })?;
278 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
279 }
280
281 pub fn session_summary(&self, since: &str) -> Result<SessionSummary> {
283 let mut stmt = self.conn.prepare(
284 "SELECT COUNT(*),
285 COALESCE(SUM(input_tokens),0),
286 COALESCE(SUM(output_tokens),0),
287 COALESCE(SUM(saved_tokens),0),
288 CASE WHEN SUM(input_tokens)>0
289 THEN ROUND(100.0*SUM(saved_tokens)/SUM(input_tokens),1) ELSE 0 END,
290 COALESCE(SUM(exec_time_ms),0)
291 FROM commands WHERE timestamp >= ?1",
292 )?;
293 let row = stmt.query_row([since], |row| {
294 Ok(SessionSummary {
295 commands: row.get::<_, i64>(0)? as u64,
296 input_tokens: row.get::<_, i64>(1)? as u64,
297 output_tokens: row.get::<_, i64>(2)? as u64,
298 saved_tokens: row.get(3)?,
299 savings_pct: row.get(4)?,
300 total_time_ms: row.get::<_, i64>(5)? as u64,
301 })
302 })?;
303 Ok(row)
304 }
305
306 pub fn audit(
308 &self,
309 plugin_name: &str,
310 runtime_type: &str,
311 command: &str,
312 action: &str,
313 checksum: &str,
314 details: &str,
315 ) -> Result<()> {
316 self.conn.execute(
317 "INSERT INTO audit(timestamp, plugin_name, runtime_type, command, action, checksum, details)
318 VALUES(datetime('now'), ?1, ?2, ?3, ?4, ?5, ?6)",
319 rusqlite::params![plugin_name, runtime_type, command, action, checksum, details],
320 )?;
321 Ok(())
322 }
323
324 pub fn audit_log(&self, limit: usize) -> Result<Vec<AuditEntry>> {
326 let mut stmt = self.conn.prepare(
327 "SELECT timestamp, plugin_name, runtime_type, command, action, checksum, details
328 FROM audit ORDER BY id DESC LIMIT ?1",
329 )?;
330 let rows = stmt.query_map([limit as i64], |row| {
331 Ok(AuditEntry {
332 timestamp: row.get(0)?,
333 plugin_name: row.get(1)?,
334 runtime_type: row.get(2)?,
335 command: row.get(3)?,
336 action: row.get(4)?,
337 checksum: row.get(5)?,
338 details: row.get(6)?,
339 })
340 })?;
341 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
342 }
343}
344
345#[derive(Debug)]
347pub struct AuditEntry {
348 pub timestamp: String,
349 pub plugin_name: String,
350 pub runtime_type: String,
351 pub command: String,
352 pub action: String,
353 pub checksum: String,
354 pub details: String,
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360
361 #[test]
362 fn db_create_and_track() {
363 let tmp = tempfile::tempdir().unwrap();
364 let db = Db::open(tmp.path()).unwrap();
365
366 let record = TrackRecord {
367 original_cmd: "git status".to_string(),
368 lowfat_cmd: "lowfat git status".to_string(),
369 raw: "a".repeat(100), filtered: "a".repeat(40), exec_time_ms: 50,
372 project_path: "/tmp/test".to_string(),
373 };
374 db.track(&record).unwrap();
375
376 let summary = db.gain_summary().unwrap();
377 assert_eq!(summary.commands, 1);
378 assert_eq!(summary.input_tokens, 25);
379 assert_eq!(summary.output_tokens, 10);
380 assert_eq!(summary.saved_tokens, 15);
381 }
382
383 #[test]
384 fn invocations_evict_past_cap() {
385 let tmp = tempfile::tempdir().unwrap();
386 let db = Db::open(tmp.path()).unwrap();
387
388 for i in 0..(super::INVOCATIONS_CAP + 5) {
390 db.record_invocation(&InvocationRecord {
391 command: "git".into(),
392 subcommand: format!("s{i}"),
393 raw_tokens: 100,
394 filtered_tokens: 20,
395 had_plugin: true,
396 exit_code: 0,
397 }).unwrap();
398 }
399 let count: i64 = db.conn
400 .query_row("SELECT COUNT(*) FROM invocations", [], |r| r.get(0))
401 .unwrap();
402 assert_eq!(count, super::INVOCATIONS_CAP);
403 }
404
405 #[test]
406 fn history_ranking_orders_by_score() {
407 let tmp = tempfile::tempdir().unwrap();
408 let db = Db::open(tmp.path()).unwrap();
409
410 for _ in 0..5 {
412 db.record_invocation(&InvocationRecord {
413 command: "cargo".into(), subcommand: "build".into(),
414 raw_tokens: 2000, filtered_tokens: 1900,
415 had_plugin: false, exit_code: 0,
416 }).unwrap();
417 }
418 for _ in 0..10 {
420 db.record_invocation(&InvocationRecord {
421 command: "git".into(), subcommand: "status".into(),
422 raw_tokens: 30, filtered_tokens: 3,
423 had_plugin: true, exit_code: 0,
424 }).unwrap();
425 }
426
427 let ranking = db.history_ranking(10).unwrap();
428 assert_eq!(ranking.len(), 2);
429 assert_eq!(ranking[0].command, "cargo");
430 assert_eq!(ranking[0].subcommand, "build");
431 assert_eq!(ranking[1].command, "git");
432 }
433
434 #[test]
435 fn top_commands() {
436 let tmp = tempfile::tempdir().unwrap();
437 let db = Db::open(tmp.path()).unwrap();
438
439 for _ in 0..3 {
440 db.track(&TrackRecord {
441 original_cmd: "git diff".to_string(),
442 lowfat_cmd: "lowfat git diff".to_string(),
443 raw: "a".repeat(100),
444 filtered: "a".repeat(20),
445 exec_time_ms: 10,
446 project_path: "/tmp".to_string(),
447 }).unwrap();
448 }
449
450 let top = db.top_commands(10).unwrap();
451 assert_eq!(top.len(), 1);
452 assert_eq!(top[0].command, "git diff");
453 assert_eq!(top[0].runs, 3);
454 }
455}