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 avg_saved_tokens: f64,
39 pub plugin_ratio: f64,
40 pub score: f64,
41}
42
43#[derive(Debug)]
45pub struct InvocationExport {
46 pub timestamp: String,
47 pub command: String,
48 pub subcommand: String,
49 pub raw_tokens: u64,
50 pub filtered_tokens: u64,
51 pub had_plugin: bool,
52 pub exit_code: i32,
53}
54
55const INVOCATIONS_CAP: i64 = 10_000;
57
58#[derive(Debug, Clone)]
60pub enum PruneFilter {
61 OlderThan(u32),
63 BelowUsage(u64),
65 KeptByPlugin,
68 All,
70}
71
72#[derive(Debug)]
74pub struct GainSummary {
75 pub commands: u64,
76 pub input_tokens: u64,
77 pub output_tokens: u64,
78 pub saved_tokens: u64,
79 pub savings_pct: f64,
80}
81
82#[derive(Debug)]
84pub struct TopCommand {
85 pub command: String,
86 pub runs: u64,
87 pub saved: i64,
88 pub avg_pct: f64,
89}
90
91#[derive(Debug)]
93pub struct SessionSummary {
94 pub commands: u64,
95 pub input_tokens: u64,
96 pub output_tokens: u64,
97 pub saved_tokens: i64,
98 pub savings_pct: f64,
99 pub total_time_ms: u64,
100}
101
102impl Db {
103 pub fn open(data_dir: &Path) -> Result<Self> {
105 std::fs::create_dir_all(data_dir)?;
106 let db_path = data_dir.join("history.db");
107 let conn = Connection::open(&db_path)?;
108 conn.execute_batch(
109 "CREATE TABLE IF NOT EXISTS commands (
110 id INTEGER PRIMARY KEY,
111 timestamp TEXT NOT NULL,
112 original_cmd TEXT NOT NULL,
113 lowfat_cmd TEXT NOT NULL,
114 input_tokens INTEGER NOT NULL,
115 output_tokens INTEGER NOT NULL,
116 saved_tokens INTEGER NOT NULL,
117 savings_pct REAL NOT NULL,
118 exec_time_ms INTEGER DEFAULT 0,
119 project_path TEXT DEFAULT ''
120 );
121 CREATE INDEX IF NOT EXISTS idx_timestamp ON commands(timestamp);
122 CREATE INDEX IF NOT EXISTS idx_project ON commands(project_path, timestamp);
123
124 CREATE TABLE IF NOT EXISTS audit (
125 id INTEGER PRIMARY KEY,
126 timestamp TEXT NOT NULL,
127 plugin_name TEXT NOT NULL,
128 runtime_type TEXT NOT NULL,
129 command TEXT NOT NULL,
130 action TEXT NOT NULL,
131 checksum TEXT DEFAULT '',
132 details TEXT DEFAULT ''
133 );
134 CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit(timestamp);
135
136 CREATE TABLE IF NOT EXISTS invocations (
137 id INTEGER PRIMARY KEY AUTOINCREMENT,
138 timestamp TEXT NOT NULL,
139 command TEXT NOT NULL,
140 subcommand TEXT NOT NULL DEFAULT '',
141 raw_tokens INTEGER NOT NULL,
142 filtered_tokens INTEGER NOT NULL,
143 had_plugin INTEGER NOT NULL,
144 exit_code INTEGER NOT NULL
145 );
146 CREATE INDEX IF NOT EXISTS idx_invocations_cmd ON invocations(command, subcommand);",
147 )?;
148 Ok(Db { conn })
149 }
150
151 pub fn track(&self, record: &TrackRecord) -> Result<()> {
153 let in_tok = crate::tokens::estimate_tokens(&record.raw);
154 let out_tok = crate::tokens::estimate_tokens(&record.filtered);
155 let saved = in_tok as i64 - out_tok as i64;
156 let pct = if in_tok > 0 {
157 (saved as f64 / in_tok as f64) * 100.0
158 } else {
159 0.0
160 };
161
162 self.conn.execute(
163 "INSERT INTO commands(timestamp, original_cmd, lowfat_cmd, input_tokens, output_tokens, saved_tokens, savings_pct, exec_time_ms, project_path)
164 VALUES(datetime('now'), ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
165 rusqlite::params![
166 record.original_cmd,
167 record.lowfat_cmd,
168 in_tok as i64,
169 out_tok as i64,
170 saved,
171 pct,
172 record.exec_time_ms as i64,
173 record.project_path,
174 ],
175 )?;
176 Ok(())
177 }
178
179 pub fn record_invocation(&self, rec: &InvocationRecord) -> Result<()> {
182 self.conn.execute(
183 "INSERT INTO invocations(timestamp, command, subcommand, raw_tokens, filtered_tokens, had_plugin, exit_code)
184 VALUES(datetime('now'), ?1, ?2, ?3, ?4, ?5, ?6)",
185 rusqlite::params![
186 rec.command,
187 rec.subcommand,
188 rec.raw_tokens as i64,
189 rec.filtered_tokens as i64,
190 rec.had_plugin as i64,
191 rec.exit_code,
192 ],
193 )?;
194 self.conn.execute(
196 "DELETE FROM invocations WHERE id <= (SELECT MAX(id) - ?1 FROM invocations)",
197 [INVOCATIONS_CAP],
198 )?;
199 Ok(())
200 }
201
202 pub fn prune_invocations(&self, filter: &PruneFilter, dry_run: bool) -> Result<u64> {
206 match filter {
207 PruneFilter::All => {
208 if dry_run {
209 let n: i64 = self.conn.query_row(
210 "SELECT COUNT(*) FROM invocations",
211 [],
212 |r| r.get(0),
213 )?;
214 Ok(n as u64)
215 } else {
216 let n = self.conn.execute("DELETE FROM invocations", [])?;
217 Ok(n as u64)
218 }
219 }
220 PruneFilter::OlderThan(days) => {
221 let modifier = format!("-{days} days");
222 if dry_run {
223 let n: i64 = self.conn.query_row(
224 "SELECT COUNT(*) FROM invocations WHERE timestamp < datetime('now', ?1)",
225 [&modifier],
226 |r| r.get(0),
227 )?;
228 Ok(n as u64)
229 } else {
230 let n = self.conn.execute(
231 "DELETE FROM invocations WHERE timestamp < datetime('now', ?1)",
232 [&modifier],
233 )?;
234 Ok(n as u64)
235 }
236 }
237 PruneFilter::BelowUsage(min) => {
238 let count_sql = "SELECT COUNT(*) FROM invocations \
240 WHERE (command, subcommand) IN ( \
241 SELECT command, subcommand FROM invocations \
242 GROUP BY command, subcommand HAVING COUNT(*) < ?1)";
243 let del_sql = "DELETE FROM invocations \
244 WHERE (command, subcommand) IN ( \
245 SELECT command, subcommand FROM invocations \
246 GROUP BY command, subcommand HAVING COUNT(*) < ?1)";
247 let threshold = *min as i64;
248 if dry_run {
249 let n: i64 = self.conn.query_row(count_sql, [threshold], |r| r.get(0))?;
250 Ok(n as u64)
251 } else {
252 let n = self.conn.execute(del_sql, [threshold])?;
253 Ok(n as u64)
254 }
255 }
256 PruneFilter::KeptByPlugin => {
257 let count_sql = "SELECT COUNT(*) FROM invocations \
259 WHERE (command, subcommand) IN ( \
260 SELECT command, subcommand FROM invocations \
261 GROUP BY command, subcommand HAVING MIN(had_plugin) = 1)";
262 let del_sql = "DELETE FROM invocations \
263 WHERE (command, subcommand) IN ( \
264 SELECT command, subcommand FROM invocations \
265 GROUP BY command, subcommand HAVING MIN(had_plugin) = 1)";
266 if dry_run {
267 let n: i64 = self.conn.query_row(count_sql, [], |r| r.get(0))?;
268 Ok(n as u64)
269 } else {
270 let n = self.conn.execute(del_sql, [])?;
271 Ok(n as u64)
272 }
273 }
274 }
275 }
276
277 pub fn export_invocations(&self) -> Result<Vec<InvocationExport>> {
280 let mut stmt = self.conn.prepare(
281 "SELECT timestamp, command, subcommand, raw_tokens, filtered_tokens, had_plugin, exit_code
282 FROM invocations ORDER BY id ASC",
283 )?;
284 let rows = stmt.query_map([], |row| {
285 Ok(InvocationExport {
286 timestamp: row.get(0)?,
287 command: row.get(1)?,
288 subcommand: row.get(2)?,
289 raw_tokens: row.get::<_, i64>(3)? as u64,
290 filtered_tokens: row.get::<_, i64>(4)? as u64,
291 had_plugin: row.get::<_, i64>(5)? != 0,
292 exit_code: row.get(6)?,
293 })
294 })?;
295 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
296 }
297
298 pub fn history_ranking(&self, limit: usize) -> Result<Vec<HistoryRow>> {
302 let mut stmt = self.conn.prepare(
303 "SELECT command, subcommand,
304 COUNT(*) AS runs,
305 AVG(raw_tokens) AS avg_raw,
306 CASE WHEN SUM(raw_tokens) > 0
307 THEN 100.0 * (1.0 - 1.0 * SUM(filtered_tokens) / SUM(raw_tokens))
308 ELSE 0 END AS savings_pct,
309 AVG(raw_tokens - filtered_tokens) AS avg_saved,
310 AVG(had_plugin) AS plugin_ratio,
311 COUNT(*) * AVG(raw_tokens) *
312 (CASE WHEN SUM(raw_tokens) > 0
313 THEN 1.0 - 1.0 * SUM(filtered_tokens) / SUM(raw_tokens)
314 ELSE 0 END) AS score
315 FROM invocations
316 GROUP BY command, subcommand
317 HAVING SUM(raw_tokens) > 0
318 ORDER BY score DESC
319 LIMIT ?1",
320 )?;
321 let rows = stmt.query_map([limit as i64], |row| {
322 Ok(HistoryRow {
323 command: row.get(0)?,
324 subcommand: row.get(1)?,
325 runs: row.get::<_, i64>(2)? as u64,
326 avg_raw_tokens: row.get(3)?,
327 savings_pct: row.get(4)?,
328 avg_saved_tokens: row.get::<_, f64>(5)?.max(0.0),
329 plugin_ratio: row.get(6)?,
330 score: row.get(7)?,
331 })
332 })?;
333 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
334 }
335
336 pub fn gain_summary(&self) -> Result<GainSummary> {
338 let mut stmt = self.conn.prepare(
339 "SELECT COUNT(*), COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0),
340 COALESCE(SUM(saved_tokens),0),
341 CASE WHEN SUM(input_tokens)>0
342 THEN ROUND(100.0*SUM(saved_tokens)/SUM(input_tokens),1) ELSE 0 END
343 FROM commands",
344 )?;
345 let row = stmt.query_row([], |row| {
346 Ok(GainSummary {
347 commands: row.get::<_, i64>(0)? as u64,
348 input_tokens: row.get::<_, i64>(1)? as u64,
349 output_tokens: row.get::<_, i64>(2)? as u64,
350 saved_tokens: row.get::<_, i64>(3)? as u64,
351 savings_pct: row.get(4)?,
352 })
353 })?;
354 Ok(row)
355 }
356
357 pub fn top_commands(&self, limit: usize) -> Result<Vec<TopCommand>> {
359 let mut stmt = self.conn.prepare(
360 "SELECT original_cmd, COUNT(*), SUM(saved_tokens), ROUND(AVG(savings_pct),1)
361 FROM commands GROUP BY original_cmd ORDER BY SUM(saved_tokens) DESC LIMIT ?1",
362 )?;
363 let rows = stmt.query_map([limit as i64], |row| {
364 Ok(TopCommand {
365 command: row.get(0)?,
366 runs: row.get::<_, i64>(1)? as u64,
367 saved: row.get(2)?,
368 avg_pct: row.get(3)?,
369 })
370 })?;
371 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
372 }
373
374 pub fn session_summary(&self, since: &str) -> Result<SessionSummary> {
376 let mut stmt = self.conn.prepare(
377 "SELECT COUNT(*),
378 COALESCE(SUM(input_tokens),0),
379 COALESCE(SUM(output_tokens),0),
380 COALESCE(SUM(saved_tokens),0),
381 CASE WHEN SUM(input_tokens)>0
382 THEN ROUND(100.0*SUM(saved_tokens)/SUM(input_tokens),1) ELSE 0 END,
383 COALESCE(SUM(exec_time_ms),0)
384 FROM commands WHERE timestamp >= ?1",
385 )?;
386 let row = stmt.query_row([since], |row| {
387 Ok(SessionSummary {
388 commands: row.get::<_, i64>(0)? as u64,
389 input_tokens: row.get::<_, i64>(1)? as u64,
390 output_tokens: row.get::<_, i64>(2)? as u64,
391 saved_tokens: row.get(3)?,
392 savings_pct: row.get(4)?,
393 total_time_ms: row.get::<_, i64>(5)? as u64,
394 })
395 })?;
396 Ok(row)
397 }
398
399 pub fn audit(
401 &self,
402 plugin_name: &str,
403 runtime_type: &str,
404 command: &str,
405 action: &str,
406 checksum: &str,
407 details: &str,
408 ) -> Result<()> {
409 self.conn.execute(
410 "INSERT INTO audit(timestamp, plugin_name, runtime_type, command, action, checksum, details)
411 VALUES(datetime('now'), ?1, ?2, ?3, ?4, ?5, ?6)",
412 rusqlite::params![plugin_name, runtime_type, command, action, checksum, details],
413 )?;
414 Ok(())
415 }
416
417 pub fn audit_log(&self, limit: usize) -> Result<Vec<AuditEntry>> {
419 let mut stmt = self.conn.prepare(
420 "SELECT timestamp, plugin_name, runtime_type, command, action, checksum, details
421 FROM audit ORDER BY id DESC LIMIT ?1",
422 )?;
423 let rows = stmt.query_map([limit as i64], |row| {
424 Ok(AuditEntry {
425 timestamp: row.get(0)?,
426 plugin_name: row.get(1)?,
427 runtime_type: row.get(2)?,
428 command: row.get(3)?,
429 action: row.get(4)?,
430 checksum: row.get(5)?,
431 details: row.get(6)?,
432 })
433 })?;
434 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
435 }
436}
437
438#[derive(Debug)]
440pub struct AuditEntry {
441 pub timestamp: String,
442 pub plugin_name: String,
443 pub runtime_type: String,
444 pub command: String,
445 pub action: String,
446 pub checksum: String,
447 pub details: String,
448}
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453
454 #[test]
455 fn db_create_and_track() {
456 let tmp = tempfile::tempdir().unwrap();
457 let db = Db::open(tmp.path()).unwrap();
458
459 let record = TrackRecord {
460 original_cmd: "git status".to_string(),
461 lowfat_cmd: "lowfat git status".to_string(),
462 raw: "a".repeat(100), filtered: "a".repeat(40), exec_time_ms: 50,
465 project_path: "/tmp/test".to_string(),
466 };
467 db.track(&record).unwrap();
468
469 let summary = db.gain_summary().unwrap();
470 assert_eq!(summary.commands, 1);
471 assert_eq!(summary.input_tokens, 25);
472 assert_eq!(summary.output_tokens, 10);
473 assert_eq!(summary.saved_tokens, 15);
474 }
475
476 #[test]
477 fn invocations_evict_past_cap() {
478 let tmp = tempfile::tempdir().unwrap();
479 let db = Db::open(tmp.path()).unwrap();
480
481 for i in 0..(super::INVOCATIONS_CAP + 5) {
483 db.record_invocation(&InvocationRecord {
484 command: "git".into(),
485 subcommand: format!("s{i}"),
486 raw_tokens: 100,
487 filtered_tokens: 20,
488 had_plugin: true,
489 exit_code: 0,
490 }).unwrap();
491 }
492 let count: i64 = db.conn
493 .query_row("SELECT COUNT(*) FROM invocations", [], |r| r.get(0))
494 .unwrap();
495 assert_eq!(count, super::INVOCATIONS_CAP);
496 }
497
498 #[test]
499 fn history_ranking_orders_by_score() {
500 let tmp = tempfile::tempdir().unwrap();
501 let db = Db::open(tmp.path()).unwrap();
502
503 for _ in 0..5 {
505 db.record_invocation(&InvocationRecord {
506 command: "cargo".into(), subcommand: "build".into(),
507 raw_tokens: 2000, filtered_tokens: 1900,
508 had_plugin: false, exit_code: 0,
509 }).unwrap();
510 }
511 for _ in 0..10 {
513 db.record_invocation(&InvocationRecord {
514 command: "git".into(), subcommand: "status".into(),
515 raw_tokens: 30, filtered_tokens: 3,
516 had_plugin: true, exit_code: 0,
517 }).unwrap();
518 }
519
520 let ranking = db.history_ranking(10).unwrap();
521 assert_eq!(ranking.len(), 2);
522 assert_eq!(ranking[0].command, "cargo");
523 assert_eq!(ranking[0].subcommand, "build");
524 assert_eq!(ranking[1].command, "git");
525 }
526
527 fn insert_dated_invocation(db: &Db, cmd: &str, sub: &str, had_plugin: bool, ts: &str) {
528 db.conn.execute(
529 "INSERT INTO invocations(timestamp, command, subcommand, raw_tokens, filtered_tokens, had_plugin, exit_code)
530 VALUES(?1, ?2, ?3, 100, 50, ?4, 0)",
531 rusqlite::params![ts, cmd, sub, had_plugin as i64],
532 ).unwrap();
533 }
534
535 fn count_invocations(db: &Db) -> i64 {
536 db.conn
537 .query_row("SELECT COUNT(*) FROM invocations", [], |r| r.get(0))
538 .unwrap()
539 }
540
541 #[test]
542 fn prune_all_wipes_invocations_but_leaves_gain() {
543 let tmp = tempfile::tempdir().unwrap();
544 let db = Db::open(tmp.path()).unwrap();
545
546 for i in 0..3 {
547 db.record_invocation(&InvocationRecord {
548 command: "git".into(),
549 subcommand: format!("s{i}"),
550 raw_tokens: 100,
551 filtered_tokens: 20,
552 had_plugin: true,
553 exit_code: 0,
554 })
555 .unwrap();
556 }
557 db.track(&TrackRecord {
558 original_cmd: "git status".into(),
559 lowfat_cmd: "lowfat git status".into(),
560 raw: "a".repeat(100),
561 filtered: "a".repeat(20),
562 exec_time_ms: 5,
563 project_path: "/tmp".into(),
564 })
565 .unwrap();
566
567 let removed = db.prune_invocations(&PruneFilter::All, false).unwrap();
568 assert_eq!(removed, 3);
569 assert_eq!(count_invocations(&db), 0);
570 assert_eq!(db.gain_summary().unwrap().commands, 1);
572 }
573
574 #[test]
575 fn prune_older_than_keeps_recent_drops_stale() {
576 let tmp = tempfile::tempdir().unwrap();
577 let db = Db::open(tmp.path()).unwrap();
578
579 insert_dated_invocation(&db, "git", "log", false, "2020-01-01 00:00:00");
581 insert_dated_invocation(&db, "git", "status", false, "2099-01-01 00:00:00");
582
583 let removed = db
584 .prune_invocations(&PruneFilter::OlderThan(30), false)
585 .unwrap();
586 assert_eq!(removed, 1);
587 assert_eq!(count_invocations(&db), 1);
588 }
589
590 #[test]
591 fn prune_below_usage_drops_rare_groups() {
592 let tmp = tempfile::tempdir().unwrap();
593 let db = Db::open(tmp.path()).unwrap();
594
595 for _ in 0..3 {
597 db.record_invocation(&InvocationRecord {
598 command: "git".into(),
599 subcommand: "status".into(),
600 raw_tokens: 50,
601 filtered_tokens: 10,
602 had_plugin: false,
603 exit_code: 0,
604 })
605 .unwrap();
606 }
607 db.record_invocation(&InvocationRecord {
608 command: "kubectl".into(),
609 subcommand: "get".into(),
610 raw_tokens: 4000,
611 filtered_tokens: 4000,
612 had_plugin: false,
613 exit_code: 0,
614 })
615 .unwrap();
616
617 let preview = db
618 .prune_invocations(&PruneFilter::BelowUsage(2), true)
619 .unwrap();
620 assert_eq!(preview, 1);
621 assert_eq!(count_invocations(&db), 4); let removed = db
624 .prune_invocations(&PruneFilter::BelowUsage(2), false)
625 .unwrap();
626 assert_eq!(removed, 1);
627 assert_eq!(count_invocations(&db), 3);
628 }
629
630 #[test]
631 fn prune_kept_by_plugin_drops_fully_covered_groups() {
632 let tmp = tempfile::tempdir().unwrap();
633 let db = Db::open(tmp.path()).unwrap();
634
635 for _ in 0..3 {
637 db.record_invocation(&InvocationRecord {
638 command: "git".into(),
639 subcommand: "status".into(),
640 raw_tokens: 50,
641 filtered_tokens: 10,
642 had_plugin: true,
643 exit_code: 0,
644 })
645 .unwrap();
646 }
647 for _ in 0..2 {
649 db.record_invocation(&InvocationRecord {
650 command: "kubectl".into(),
651 subcommand: "get".into(),
652 raw_tokens: 4000,
653 filtered_tokens: 4000,
654 had_plugin: false,
655 exit_code: 0,
656 })
657 .unwrap();
658 }
659
660 let removed = db
661 .prune_invocations(&PruneFilter::KeptByPlugin, false)
662 .unwrap();
663 assert_eq!(removed, 3);
664 assert_eq!(count_invocations(&db), 2);
665 }
666
667 #[test]
668 fn prune_dry_run_never_mutates() {
669 let tmp = tempfile::tempdir().unwrap();
670 let db = Db::open(tmp.path()).unwrap();
671 for _ in 0..5 {
672 db.record_invocation(&InvocationRecord {
673 command: "git".into(),
674 subcommand: "log".into(),
675 raw_tokens: 100,
676 filtered_tokens: 50,
677 had_plugin: false,
678 exit_code: 0,
679 })
680 .unwrap();
681 }
682 for filter in [
683 PruneFilter::All,
684 PruneFilter::BelowUsage(100),
685 PruneFilter::KeptByPlugin,
686 PruneFilter::OlderThan(0),
687 ] {
688 db.prune_invocations(&filter, true).unwrap();
689 assert_eq!(count_invocations(&db), 5, "dry-run mutated with {filter:?}");
690 }
691 }
692
693 #[test]
694 fn top_commands() {
695 let tmp = tempfile::tempdir().unwrap();
696 let db = Db::open(tmp.path()).unwrap();
697
698 for _ in 0..3 {
699 db.track(&TrackRecord {
700 original_cmd: "git diff".to_string(),
701 lowfat_cmd: "lowfat git diff".to_string(),
702 raw: "a".repeat(100),
703 filtered: "a".repeat(20),
704 exec_time_ms: 10,
705 project_path: "/tmp".to_string(),
706 }).unwrap();
707 }
708
709 let top = db.top_commands(10).unwrap();
710 assert_eq!(top.len(), 1);
711 assert_eq!(top[0].command, "git diff");
712 assert_eq!(top[0].runs, 3);
713 }
714}