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,
29 pub in_scope: bool,
32 pub reduced: bool,
34 pub is_external_plugin: bool,
38 pub exit_code: i32,
39}
40
41#[derive(Debug)]
43pub struct HistoryRow {
44 pub command: String,
45 pub subcommand: String,
46 pub runs: u64,
47 pub avg_raw_tokens: f64,
48 pub total_raw_tokens: u64,
49 pub savings_pct: f64,
50 pub registered_ratio: f64,
52 pub in_scope_ratio: f64,
54 pub reduced_ratio: f64,
56 pub external_ratio: f64,
59 pub score: f64,
60}
61
62#[derive(Debug)]
64pub struct InvocationExport {
65 pub timestamp: String,
66 pub command: String,
67 pub subcommand: String,
68 pub raw_tokens: u64,
69 pub filtered_tokens: u64,
70 pub had_plugin: bool,
71 pub in_scope: bool,
72 pub reduced: bool,
73 pub is_external_plugin: bool,
74 pub exit_code: i32,
75}
76
77const INVOCATIONS_CAP: i64 = 10_000;
79
80#[derive(Debug, Clone)]
82pub enum PruneFilter {
83 OlderThan(u32),
85 BelowUsage(u64),
87 KeptByPlugin,
90 All,
92}
93
94#[derive(Debug)]
96pub struct GainSummary {
97 pub commands: u64,
98 pub input_tokens: u64,
99 pub output_tokens: u64,
100 pub saved_tokens: u64,
101 pub savings_pct: f64,
102}
103
104#[derive(Debug)]
106pub struct TopCommand {
107 pub command: String,
108 pub runs: u64,
109 pub saved: i64,
110 pub avg_pct: f64,
111}
112
113#[derive(Debug)]
115pub struct SessionSummary {
116 pub commands: u64,
117 pub input_tokens: u64,
118 pub output_tokens: u64,
119 pub saved_tokens: i64,
120 pub savings_pct: f64,
121 pub total_time_ms: u64,
122}
123
124impl Db {
125 pub fn open(data_dir: &Path) -> Result<Self> {
127 std::fs::create_dir_all(data_dir)?;
128 let db_path = data_dir.join("history.db");
129 let conn = Connection::open(&db_path)?;
130 conn.execute_batch(
131 "CREATE TABLE IF NOT EXISTS commands (
132 id INTEGER PRIMARY KEY,
133 timestamp TEXT NOT NULL,
134 original_cmd TEXT NOT NULL,
135 lowfat_cmd TEXT NOT NULL,
136 input_tokens INTEGER NOT NULL,
137 output_tokens INTEGER NOT NULL,
138 saved_tokens INTEGER NOT NULL,
139 savings_pct REAL NOT NULL,
140 exec_time_ms INTEGER DEFAULT 0,
141 project_path TEXT DEFAULT ''
142 );
143 CREATE INDEX IF NOT EXISTS idx_timestamp ON commands(timestamp);
144 CREATE INDEX IF NOT EXISTS idx_project ON commands(project_path, timestamp);
145
146 CREATE TABLE IF NOT EXISTS audit (
147 id INTEGER PRIMARY KEY,
148 timestamp TEXT NOT NULL,
149 plugin_name TEXT NOT NULL,
150 runtime_type TEXT NOT NULL,
151 command TEXT NOT NULL,
152 action TEXT NOT NULL,
153 checksum TEXT DEFAULT '',
154 details TEXT DEFAULT ''
155 );
156 CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit(timestamp);
157
158 CREATE TABLE IF NOT EXISTS invocations (
159 id INTEGER PRIMARY KEY AUTOINCREMENT,
160 timestamp TEXT NOT NULL,
161 command TEXT NOT NULL,
162 subcommand TEXT NOT NULL DEFAULT '',
163 raw_tokens INTEGER NOT NULL,
164 filtered_tokens INTEGER NOT NULL,
165 had_plugin INTEGER NOT NULL,
166 exit_code INTEGER NOT NULL
167 );
168 CREATE INDEX IF NOT EXISTS idx_invocations_cmd ON invocations(command, subcommand);",
169 )?;
170
171 let _ = conn.execute("ALTER TABLE invocations ADD COLUMN in_scope INTEGER", []);
178 let _ = conn.execute("ALTER TABLE invocations ADD COLUMN reduced INTEGER", []);
179 conn.execute(
184 "UPDATE invocations SET reduced = CASE \
185 WHEN filtered_tokens < raw_tokens THEN 1 ELSE 0 END \
186 WHERE reduced IS NULL",
187 [],
188 )?;
189 conn.execute(
190 "UPDATE invocations SET in_scope = had_plugin WHERE in_scope IS NULL",
191 [],
192 )?;
193
194 let _ = conn.execute(
199 "ALTER TABLE invocations ADD COLUMN is_external_plugin INTEGER",
200 [],
201 );
202 conn.execute(
208 "UPDATE invocations SET is_external_plugin = CASE \
209 WHEN had_plugin = 1 AND command NOT IN ('git','docker','ls') THEN 1 \
210 ELSE 0 END \
211 WHERE is_external_plugin IS NULL",
212 [],
213 )?;
214 Ok(Db { conn })
215 }
216
217 pub fn track(&self, record: &TrackRecord) -> Result<()> {
219 let in_tok = crate::tokens::estimate_tokens(&record.raw);
220 let out_tok = crate::tokens::estimate_tokens(&record.filtered);
221 let saved = in_tok as i64 - out_tok as i64;
222 let pct = if in_tok > 0 {
223 (saved as f64 / in_tok as f64) * 100.0
224 } else {
225 0.0
226 };
227
228 self.conn.execute(
229 "INSERT INTO commands(timestamp, original_cmd, lowfat_cmd, input_tokens, output_tokens, saved_tokens, savings_pct, exec_time_ms, project_path)
230 VALUES(datetime('now'), ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
231 rusqlite::params![
232 record.original_cmd,
233 record.lowfat_cmd,
234 in_tok as i64,
235 out_tok as i64,
236 saved,
237 pct,
238 record.exec_time_ms as i64,
239 record.project_path,
240 ],
241 )?;
242 Ok(())
243 }
244
245 pub fn record_invocation(&self, rec: &InvocationRecord) -> Result<()> {
248 self.conn.execute(
249 "INSERT INTO invocations(timestamp, command, subcommand, raw_tokens, filtered_tokens, had_plugin, in_scope, reduced, is_external_plugin, exit_code)
250 VALUES(datetime('now'), ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
251 rusqlite::params![
252 rec.command,
253 rec.subcommand,
254 rec.raw_tokens as i64,
255 rec.filtered_tokens as i64,
256 rec.had_plugin as i64,
257 rec.in_scope as i64,
258 rec.reduced as i64,
259 rec.is_external_plugin as i64,
260 rec.exit_code,
261 ],
262 )?;
263 self.conn.execute(
265 "DELETE FROM invocations WHERE id <= (SELECT MAX(id) - ?1 FROM invocations)",
266 [INVOCATIONS_CAP],
267 )?;
268 Ok(())
269 }
270
271 pub fn prune_invocations(&self, filter: &PruneFilter, dry_run: bool) -> Result<u64> {
275 match filter {
276 PruneFilter::All => {
277 if dry_run {
278 let n: i64 = self.conn.query_row(
279 "SELECT COUNT(*) FROM invocations",
280 [],
281 |r| r.get(0),
282 )?;
283 Ok(n as u64)
284 } else {
285 let n = self.conn.execute("DELETE FROM invocations", [])?;
286 Ok(n as u64)
287 }
288 }
289 PruneFilter::OlderThan(days) => {
290 let modifier = format!("-{days} days");
291 if dry_run {
292 let n: i64 = self.conn.query_row(
293 "SELECT COUNT(*) FROM invocations WHERE timestamp < datetime('now', ?1)",
294 [&modifier],
295 |r| r.get(0),
296 )?;
297 Ok(n as u64)
298 } else {
299 let n = self.conn.execute(
300 "DELETE FROM invocations WHERE timestamp < datetime('now', ?1)",
301 [&modifier],
302 )?;
303 Ok(n as u64)
304 }
305 }
306 PruneFilter::BelowUsage(min) => {
307 let count_sql = "SELECT COUNT(*) FROM invocations \
309 WHERE (command, subcommand) IN ( \
310 SELECT command, subcommand FROM invocations \
311 GROUP BY command, subcommand HAVING COUNT(*) < ?1)";
312 let del_sql = "DELETE FROM invocations \
313 WHERE (command, subcommand) IN ( \
314 SELECT command, subcommand FROM invocations \
315 GROUP BY command, subcommand HAVING COUNT(*) < ?1)";
316 let threshold = *min as i64;
317 if dry_run {
318 let n: i64 = self.conn.query_row(count_sql, [threshold], |r| r.get(0))?;
319 Ok(n as u64)
320 } else {
321 let n = self.conn.execute(del_sql, [threshold])?;
322 Ok(n as u64)
323 }
324 }
325 PruneFilter::KeptByPlugin => {
326 let count_sql = "SELECT COUNT(*) FROM invocations \
328 WHERE (command, subcommand) IN ( \
329 SELECT command, subcommand FROM invocations \
330 GROUP BY command, subcommand HAVING MIN(had_plugin) = 1)";
331 let del_sql = "DELETE FROM invocations \
332 WHERE (command, subcommand) IN ( \
333 SELECT command, subcommand FROM invocations \
334 GROUP BY command, subcommand HAVING MIN(had_plugin) = 1)";
335 if dry_run {
336 let n: i64 = self.conn.query_row(count_sql, [], |r| r.get(0))?;
337 Ok(n as u64)
338 } else {
339 let n = self.conn.execute(del_sql, [])?;
340 Ok(n as u64)
341 }
342 }
343 }
344 }
345
346 pub fn export_invocations(&self) -> Result<Vec<InvocationExport>> {
348 let mut stmt = self.conn.prepare(
349 "SELECT timestamp, command, subcommand, raw_tokens, filtered_tokens,
350 had_plugin, COALESCE(in_scope,0), COALESCE(reduced,0),
351 COALESCE(is_external_plugin,0), exit_code
352 FROM invocations ORDER BY id ASC",
353 )?;
354 let rows = stmt.query_map([], |row| {
355 Ok(InvocationExport {
356 timestamp: row.get(0)?,
357 command: row.get(1)?,
358 subcommand: row.get(2)?,
359 raw_tokens: row.get::<_, i64>(3)? as u64,
360 filtered_tokens: row.get::<_, i64>(4)? as u64,
361 had_plugin: row.get::<_, i64>(5)? != 0,
362 in_scope: row.get::<_, i64>(6)? != 0,
363 reduced: row.get::<_, i64>(7)? != 0,
364 is_external_plugin: row.get::<_, i64>(8)? != 0,
365 exit_code: row.get(9)?,
366 })
367 })?;
368 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
369 }
370
371 pub fn history_ranking(&self, limit: usize, show_all: bool) -> Result<Vec<HistoryRow>> {
382 let extra_having = if show_all {
383 ""
384 } else {
385 " AND AVG(raw_tokens) >= 50 AND COUNT(*) >= 2"
386 };
387 let sql = format!(
388 "SELECT command, subcommand,
389 COUNT(*) AS runs,
390 AVG(raw_tokens) AS avg_raw,
391 SUM(raw_tokens) AS total_raw,
392 CASE WHEN SUM(raw_tokens) > 0
393 THEN 100.0 * (1.0 - 1.0 * SUM(filtered_tokens) / SUM(raw_tokens))
394 ELSE 0 END AS savings_pct,
395 AVG(had_plugin) AS registered_ratio,
396 AVG(COALESCE(in_scope,0)) AS in_scope_ratio,
397 AVG(COALESCE(reduced,0)) AS reduced_ratio,
398 AVG(COALESCE(is_external_plugin,0)) AS external_ratio,
399 SUM(filtered_tokens) AS score
400 FROM invocations
401 GROUP BY command, subcommand
402 HAVING SUM(raw_tokens) > 0{extra_having}
403 ORDER BY score DESC
404 LIMIT ?1"
405 );
406 let mut stmt = self.conn.prepare(&sql)?;
407 let rows = stmt.query_map([limit as i64], |row| {
408 Ok(HistoryRow {
409 command: row.get(0)?,
410 subcommand: row.get(1)?,
411 runs: row.get::<_, i64>(2)? as u64,
412 avg_raw_tokens: row.get(3)?,
413 total_raw_tokens: row.get::<_, i64>(4)? as u64,
414 savings_pct: row.get(5)?,
415 registered_ratio: row.get(6)?,
416 in_scope_ratio: row.get(7)?,
417 reduced_ratio: row.get(8)?,
418 external_ratio: row.get(9)?,
419 score: row.get(10)?,
420 })
421 })?;
422 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
423 }
424
425 pub fn gain_summary(&self) -> Result<GainSummary> {
427 let mut stmt = self.conn.prepare(
428 "SELECT COUNT(*), COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0),
429 COALESCE(SUM(saved_tokens),0),
430 CASE WHEN SUM(input_tokens)>0
431 THEN ROUND(100.0*SUM(saved_tokens)/SUM(input_tokens),1) ELSE 0 END
432 FROM commands",
433 )?;
434 let row = stmt.query_row([], |row| {
435 Ok(GainSummary {
436 commands: row.get::<_, i64>(0)? as u64,
437 input_tokens: row.get::<_, i64>(1)? as u64,
438 output_tokens: row.get::<_, i64>(2)? as u64,
439 saved_tokens: row.get::<_, i64>(3)? as u64,
440 savings_pct: row.get(4)?,
441 })
442 })?;
443 Ok(row)
444 }
445
446 pub fn top_commands(&self, limit: usize) -> Result<Vec<TopCommand>> {
448 let mut stmt = self.conn.prepare(
449 "SELECT original_cmd, COUNT(*), SUM(saved_tokens), ROUND(AVG(savings_pct),1)
450 FROM commands GROUP BY original_cmd ORDER BY SUM(saved_tokens) DESC LIMIT ?1",
451 )?;
452 let rows = stmt.query_map([limit as i64], |row| {
453 Ok(TopCommand {
454 command: row.get(0)?,
455 runs: row.get::<_, i64>(1)? as u64,
456 saved: row.get(2)?,
457 avg_pct: row.get(3)?,
458 })
459 })?;
460 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
461 }
462
463 pub fn session_summary(&self, since: &str) -> Result<SessionSummary> {
465 let mut stmt = self.conn.prepare(
466 "SELECT COUNT(*),
467 COALESCE(SUM(input_tokens),0),
468 COALESCE(SUM(output_tokens),0),
469 COALESCE(SUM(saved_tokens),0),
470 CASE WHEN SUM(input_tokens)>0
471 THEN ROUND(100.0*SUM(saved_tokens)/SUM(input_tokens),1) ELSE 0 END,
472 COALESCE(SUM(exec_time_ms),0)
473 FROM commands WHERE timestamp >= ?1",
474 )?;
475 let row = stmt.query_row([since], |row| {
476 Ok(SessionSummary {
477 commands: row.get::<_, i64>(0)? as u64,
478 input_tokens: row.get::<_, i64>(1)? as u64,
479 output_tokens: row.get::<_, i64>(2)? as u64,
480 saved_tokens: row.get(3)?,
481 savings_pct: row.get(4)?,
482 total_time_ms: row.get::<_, i64>(5)? as u64,
483 })
484 })?;
485 Ok(row)
486 }
487
488 pub fn audit(
490 &self,
491 plugin_name: &str,
492 runtime_type: &str,
493 command: &str,
494 action: &str,
495 checksum: &str,
496 details: &str,
497 ) -> Result<()> {
498 self.conn.execute(
499 "INSERT INTO audit(timestamp, plugin_name, runtime_type, command, action, checksum, details)
500 VALUES(datetime('now'), ?1, ?2, ?3, ?4, ?5, ?6)",
501 rusqlite::params![plugin_name, runtime_type, command, action, checksum, details],
502 )?;
503 Ok(())
504 }
505
506 pub fn audit_log(&self, limit: usize) -> Result<Vec<AuditEntry>> {
508 let mut stmt = self.conn.prepare(
509 "SELECT timestamp, plugin_name, runtime_type, command, action, checksum, details
510 FROM audit ORDER BY id DESC LIMIT ?1",
511 )?;
512 let rows = stmt.query_map([limit as i64], |row| {
513 Ok(AuditEntry {
514 timestamp: row.get(0)?,
515 plugin_name: row.get(1)?,
516 runtime_type: row.get(2)?,
517 command: row.get(3)?,
518 action: row.get(4)?,
519 checksum: row.get(5)?,
520 details: row.get(6)?,
521 })
522 })?;
523 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
524 }
525}
526
527#[derive(Debug)]
529pub struct AuditEntry {
530 pub timestamp: String,
531 pub plugin_name: String,
532 pub runtime_type: String,
533 pub command: String,
534 pub action: String,
535 pub checksum: String,
536 pub details: String,
537}
538
539#[cfg(test)]
540mod tests {
541 use super::*;
542
543 #[test]
544 fn db_create_and_track() {
545 let tmp = tempfile::tempdir().unwrap();
546 let db = Db::open(tmp.path()).unwrap();
547
548 let record = TrackRecord {
549 original_cmd: "git status".to_string(),
550 lowfat_cmd: "lowfat git status".to_string(),
551 raw: "a".repeat(100), filtered: "a".repeat(40), exec_time_ms: 50,
554 project_path: "/tmp/test".to_string(),
555 };
556 db.track(&record).unwrap();
557
558 let summary = db.gain_summary().unwrap();
559 assert_eq!(summary.commands, 1);
560 assert_eq!(summary.input_tokens, 25);
561 assert_eq!(summary.output_tokens, 10);
562 assert_eq!(summary.saved_tokens, 15);
563 }
564
565 #[test]
566 fn invocations_evict_past_cap() {
567 let tmp = tempfile::tempdir().unwrap();
568 let db = Db::open(tmp.path()).unwrap();
569
570 for i in 0..(super::INVOCATIONS_CAP + 5) {
572 db.record_invocation(&InvocationRecord {
573 command: "git".into(),
574 subcommand: format!("s{i}"),
575 raw_tokens: 100,
576 filtered_tokens: 20,
577 had_plugin: true,
578 in_scope: true,
579 reduced: true,
580 is_external_plugin: false,
581 exit_code: 0,
582 }).unwrap();
583 }
584 let count: i64 = db.conn
585 .query_row("SELECT COUNT(*) FROM invocations", [], |r| r.get(0))
586 .unwrap();
587 assert_eq!(count, super::INVOCATIONS_CAP);
588 }
589
590 #[test]
591 fn history_ranking_orders_by_score() {
592 let tmp = tempfile::tempdir().unwrap();
593 let db = Db::open(tmp.path()).unwrap();
594
595 for _ in 0..5 {
597 db.record_invocation(&InvocationRecord {
598 command: "cargo".into(), subcommand: "build".into(),
599 raw_tokens: 2000, filtered_tokens: 1900,
600 had_plugin: false, in_scope: false, reduced: true,
601 is_external_plugin: false,
602 exit_code: 0,
603 }).unwrap();
604 }
605 for _ in 0..10 {
607 db.record_invocation(&InvocationRecord {
608 command: "git".into(), subcommand: "status".into(),
609 raw_tokens: 30, filtered_tokens: 3,
610 had_plugin: true, in_scope: true, reduced: true,
611 is_external_plugin: false,
612 exit_code: 0,
613 }).unwrap();
614 }
615
616 let ranking = db.history_ranking(10, true).unwrap();
618 assert_eq!(ranking.len(), 2);
619 assert_eq!(ranking[0].command, "cargo");
620 assert_eq!(ranking[0].subcommand, "build");
621 assert_eq!(ranking[1].command, "git");
622
623 let trimmed = db.history_ranking(10, false).unwrap();
625 assert_eq!(trimmed.len(), 1);
626 assert_eq!(trimmed[0].command, "cargo");
627 }
628
629 fn insert_dated_invocation(db: &Db, cmd: &str, sub: &str, had_plugin: bool, ts: &str) {
630 db.conn.execute(
631 "INSERT INTO invocations(timestamp, command, subcommand, raw_tokens, filtered_tokens, had_plugin, in_scope, reduced, exit_code)
632 VALUES(?1, ?2, ?3, 100, 50, ?4, ?4, 1, 0)",
633 rusqlite::params![ts, cmd, sub, had_plugin as i64],
634 ).unwrap();
635 }
636
637 fn count_invocations(db: &Db) -> i64 {
638 db.conn
639 .query_row("SELECT COUNT(*) FROM invocations", [], |r| r.get(0))
640 .unwrap()
641 }
642
643 #[test]
644 fn prune_all_wipes_invocations_but_leaves_gain() {
645 let tmp = tempfile::tempdir().unwrap();
646 let db = Db::open(tmp.path()).unwrap();
647
648 for i in 0..3 {
649 db.record_invocation(&InvocationRecord {
650 command: "git".into(),
651 subcommand: format!("s{i}"),
652 raw_tokens: 100,
653 filtered_tokens: 20,
654 had_plugin: true,
655 in_scope: true,
656 reduced: true,
657 is_external_plugin: false,
658 exit_code: 0,
659 })
660 .unwrap();
661 }
662 db.track(&TrackRecord {
663 original_cmd: "git status".into(),
664 lowfat_cmd: "lowfat git status".into(),
665 raw: "a".repeat(100),
666 filtered: "a".repeat(20),
667 exec_time_ms: 5,
668 project_path: "/tmp".into(),
669 })
670 .unwrap();
671
672 let removed = db.prune_invocations(&PruneFilter::All, false).unwrap();
673 assert_eq!(removed, 3);
674 assert_eq!(count_invocations(&db), 0);
675 assert_eq!(db.gain_summary().unwrap().commands, 1);
677 }
678
679 #[test]
680 fn prune_older_than_keeps_recent_drops_stale() {
681 let tmp = tempfile::tempdir().unwrap();
682 let db = Db::open(tmp.path()).unwrap();
683
684 insert_dated_invocation(&db, "git", "log", false, "2020-01-01 00:00:00");
686 insert_dated_invocation(&db, "git", "status", false, "2099-01-01 00:00:00");
687
688 let removed = db
689 .prune_invocations(&PruneFilter::OlderThan(30), false)
690 .unwrap();
691 assert_eq!(removed, 1);
692 assert_eq!(count_invocations(&db), 1);
693 }
694
695 #[test]
696 fn prune_below_usage_drops_rare_groups() {
697 let tmp = tempfile::tempdir().unwrap();
698 let db = Db::open(tmp.path()).unwrap();
699
700 for _ in 0..3 {
702 db.record_invocation(&InvocationRecord {
703 command: "git".into(),
704 subcommand: "status".into(),
705 raw_tokens: 50,
706 filtered_tokens: 10,
707 had_plugin: false,
708 in_scope: false,
709 reduced: true,
710 is_external_plugin: false,
711 exit_code: 0,
712 })
713 .unwrap();
714 }
715 db.record_invocation(&InvocationRecord {
716 command: "kubectl".into(),
717 subcommand: "get".into(),
718 raw_tokens: 4000,
719 filtered_tokens: 4000,
720 had_plugin: false,
721 in_scope: false,
722 reduced: false,
723 is_external_plugin: false,
724 exit_code: 0,
725 })
726 .unwrap();
727
728 let preview = db
729 .prune_invocations(&PruneFilter::BelowUsage(2), true)
730 .unwrap();
731 assert_eq!(preview, 1);
732 assert_eq!(count_invocations(&db), 4); let removed = db
735 .prune_invocations(&PruneFilter::BelowUsage(2), false)
736 .unwrap();
737 assert_eq!(removed, 1);
738 assert_eq!(count_invocations(&db), 3);
739 }
740
741 #[test]
742 fn prune_kept_by_plugin_drops_fully_covered_groups() {
743 let tmp = tempfile::tempdir().unwrap();
744 let db = Db::open(tmp.path()).unwrap();
745
746 for _ in 0..3 {
748 db.record_invocation(&InvocationRecord {
749 command: "git".into(),
750 subcommand: "status".into(),
751 raw_tokens: 50,
752 filtered_tokens: 10,
753 had_plugin: true,
754 in_scope: true,
755 reduced: true,
756 is_external_plugin: false,
757 exit_code: 0,
758 })
759 .unwrap();
760 }
761 for _ in 0..2 {
763 db.record_invocation(&InvocationRecord {
764 command: "kubectl".into(),
765 subcommand: "get".into(),
766 raw_tokens: 4000,
767 filtered_tokens: 4000,
768 had_plugin: false,
769 in_scope: false,
770 reduced: false,
771 is_external_plugin: false,
772 exit_code: 0,
773 })
774 .unwrap();
775 }
776
777 let removed = db
778 .prune_invocations(&PruneFilter::KeptByPlugin, false)
779 .unwrap();
780 assert_eq!(removed, 3);
781 assert_eq!(count_invocations(&db), 2);
782 }
783
784 #[test]
785 fn prune_dry_run_never_mutates() {
786 let tmp = tempfile::tempdir().unwrap();
787 let db = Db::open(tmp.path()).unwrap();
788 for _ in 0..5 {
789 db.record_invocation(&InvocationRecord {
790 command: "git".into(),
791 subcommand: "log".into(),
792 raw_tokens: 100,
793 filtered_tokens: 50,
794 had_plugin: false,
795 in_scope: false,
796 reduced: true,
797 is_external_plugin: false,
798 exit_code: 0,
799 })
800 .unwrap();
801 }
802 for filter in [
803 PruneFilter::All,
804 PruneFilter::BelowUsage(100),
805 PruneFilter::KeptByPlugin,
806 PruneFilter::OlderThan(0),
807 ] {
808 db.prune_invocations(&filter, true).unwrap();
809 assert_eq!(count_invocations(&db), 5, "dry-run mutated with {filter:?}");
810 }
811 }
812
813 #[test]
814 fn top_commands() {
815 let tmp = tempfile::tempdir().unwrap();
816 let db = Db::open(tmp.path()).unwrap();
817
818 for _ in 0..3 {
819 db.track(&TrackRecord {
820 original_cmd: "git diff".to_string(),
821 lowfat_cmd: "lowfat git diff".to_string(),
822 raw: "a".repeat(100),
823 filtered: "a".repeat(20),
824 exec_time_ms: 10,
825 project_path: "/tmp".to_string(),
826 }).unwrap();
827 }
828
829 let top = db.top_commands(10).unwrap();
830 assert_eq!(top.len(), 1);
831 assert_eq!(top[0].command, "git diff");
832 assert_eq!(top[0].runs, 3);
833 }
834}