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