Skip to main content

lowfat_core/
db.rs

1use anyhow::Result;
2use rusqlite::Connection;
3use std::path::Path;
4
5/// SQLite tracking database. Same schema as the bash version.
6pub struct Db {
7    conn: Connection,
8}
9
10/// A single tracked command execution.
11pub 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
20/// A single invocation record for the usage-history table.
21pub 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/// One row of the plugin-candidates ranking.
31#[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/// Exported invocation row (user-driven backup / analysis).
44#[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
55/// Max rows retained in the `invocations` table. Oldest are evicted on insert.
56const INVOCATIONS_CAP: i64 = 10_000;
57
58/// Selective prune strategy for the `invocations` table.
59#[derive(Debug, Clone)]
60pub enum PruneFilter {
61    /// Remove rows whose timestamp is older than N days.
62    OlderThan(u32),
63    /// Remove entire (command, subcommand) groups with fewer than N runs.
64    BelowUsage(u64),
65    /// Remove groups where every row already had a plugin — already filtered,
66    /// so they aren't plugin candidates anymore.
67    KeptByPlugin,
68    /// Wipe the invocations table entirely.
69    All,
70}
71
72/// Summary row from gain report.
73#[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/// Top command row from gain report.
83#[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/// Session summary.
92#[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    /// Open (or create) the tracking database.
104    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    /// Record a command execution.
152    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    /// Record an invocation for the usage-history ranking. Evicts oldest rows
180    /// once the table grows past `INVOCATIONS_CAP`.
181    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        // id is monotonic (AUTOINCREMENT), so this trims only when over the cap.
195        self.conn.execute(
196            "DELETE FROM invocations WHERE id <= (SELECT MAX(id) - ?1 FROM invocations)",
197            [INVOCATIONS_CAP],
198        )?;
199        Ok(())
200    }
201
202    /// Prune invocation rows matching `filter`. With `dry_run=true`, returns the
203    /// count that would be removed without touching the table. Gain stats in the
204    /// `commands` table are lifetime counters and are never pruned here.
205    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                // Tuple-IN subquery: drop every row belonging to groups under the threshold.
239                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                // MIN(had_plugin)=1 → every row in the group already had a plugin.
258                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    /// Export all invocation rows (oldest first) for user-driven backup.
278    /// Returns `(timestamp, command, subcommand, raw_tokens, filtered_tokens, had_plugin, exit_code)`.
279    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    /// Rank command+subcommand pairs as plugin candidates.
299    /// Score = runs × avg_raw_tokens × (1 − savings_ratio) — i.e. called often,
300    /// produces a lot, and lowfat is not yet shrinking it much.
301    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    /// Lifetime gain summary.
337    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    /// Top commands by tokens saved.
358    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    /// Session summary since a given timestamp.
375    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    /// Record an audit event (plugin load, security check, etc.).
400    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    /// Get recent audit entries.
418    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/// Audit log entry.
439#[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), // 25 tokens
463            filtered: "a".repeat(40), // 10 tokens
464            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        // Insert cap + 5 rows, verify eviction keeps us at the cap.
482        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        // "cargo build": 5 × 2000 × 0.05 savings = score 500 (big but barely filtered)
504        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        // "git status": 10 × 30 × 0.9 savings = score 270 (small and well-filtered)
512        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        // Gain totals (commands table) must be untouched.
571        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        // One old row, one recent row.
580        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        // git status: 3 runs (keep); kubectl get: 1 run (drop at --below 2).
596        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); // dry-run didn't delete
622
623        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        // git status: all runs had a plugin → drop.
636        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        // kubectl get: no plugin coverage → keep.
648        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}