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    /// A plugin is registered for this command (regardless of subcommand scope).
27    pub had_plugin: bool,
28    /// Plugin applies to *this* subcommand — i.e. matches its declared list,
29    /// or plugin declares no subcommand restriction.
30    pub in_scope: bool,
31    /// Filter actually shrank output (filtered_tokens < raw_tokens).
32    pub reduced: bool,
33    pub exit_code: i32,
34}
35
36/// One row of the plugin-candidates ranking.
37#[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    /// AVG(had_plugin): plugin registered for this command.
46    pub registered_ratio: f64,
47    /// AVG(in_scope): filter declared to cover this subcommand.
48    pub in_scope_ratio: f64,
49    /// AVG(reduced): filter actually shrank output on average.
50    pub reduced_ratio: f64,
51    pub score: f64,
52}
53
54/// Exported invocation row (user-driven backup / analysis).
55#[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
68/// Max rows retained in the `invocations` table. Oldest are evicted on insert.
69const INVOCATIONS_CAP: i64 = 10_000;
70
71/// Selective prune strategy for the `invocations` table.
72#[derive(Debug, Clone)]
73pub enum PruneFilter {
74    /// Remove rows whose timestamp is older than N days.
75    OlderThan(u32),
76    /// Remove entire (command, subcommand) groups with fewer than N runs.
77    BelowUsage(u64),
78    /// Remove groups where every row already had a plugin — already filtered,
79    /// so they aren't plugin candidates anymore.
80    KeptByPlugin,
81    /// Wipe the invocations table entirely.
82    All,
83}
84
85/// Summary row from gain report.
86#[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/// Top command row from gain report.
96#[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/// Session summary.
105#[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    /// Open (or create) the tracking database.
117    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        // Finer-grained status columns. Added after initial schema so existing
163        // databases get migrated in place. `in_scope` distinguishes "plugin
164        // declares this subcommand" from bare "plugin registered"; `reduced`
165        // captures whether the filter actually shrank the output. Together
166        // they split the misleading "has_plugin=yes, savings=0%" rows into
167        // out-of-scope / noop / weak / good.
168        let _ = conn.execute("ALTER TABLE invocations ADD COLUMN in_scope INTEGER", []);
169        let _ = conn.execute("ALTER TABLE invocations ADD COLUMN reduced INTEGER", []);
170        // Backfill legacy rows once. `reduced` is perfectly derivable from
171        // stored token counts. `in_scope` can only be approximated — use
172        // `had_plugin` as best-effort proxy (overestimates for subcommands
173        // the plugin never covered, but only for rows predating migration).
174        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    /// Record a command execution.
188    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    /// Record an invocation for the usage-history ranking. Evicts oldest rows
216    /// once the table grows past `INVOCATIONS_CAP`.
217    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        // id is monotonic (AUTOINCREMENT), so this trims only when over the cap.
233        self.conn.execute(
234            "DELETE FROM invocations WHERE id <= (SELECT MAX(id) - ?1 FROM invocations)",
235            [INVOCATIONS_CAP],
236        )?;
237        Ok(())
238    }
239
240    /// Prune invocation rows matching `filter`. With `dry_run=true`, returns the
241    /// count that would be removed without touching the table. Gain stats in the
242    /// `commands` table are lifetime counters and are never pruned here.
243    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                // Tuple-IN subquery: drop every row belonging to groups under the threshold.
277                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                // MIN(had_plugin)=1 → every row in the group already had a plugin.
296                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    /// Export all invocation rows (oldest first) for user-driven backup.
316    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    /// Rank command+subcommand pairs as plugin candidates.
339    ///
340    /// Score = SUM(filtered_tokens) — bytes still flowing through to the LLM
341    /// after filtering. High score = "lowfat hasn't tamed this row yet,"
342    /// regardless of whether that's because there's no plugin or because the
343    /// filter is weak. Note: the previous formula was effectively SUM(saved),
344    /// which inverted the intent and put well-filtered rows at the top.
345    ///
346    /// `show_all=false` hides trivia (avg raw < 50 tokens or fewer than 2 runs)
347    /// where no amount of plugin work could plausibly help.
348    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    /// Lifetime gain summary.
391    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    /// Top commands by tokens saved.
412    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    /// Session summary since a given timestamp.
429    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    /// Record an audit event (plugin load, security check, etc.).
454    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    /// Get recent audit entries.
472    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/// Audit log entry.
493#[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), // 25 tokens
517            filtered: "a".repeat(40), // 10 tokens
518            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        // Insert cap + 5 rows, verify eviction keeps us at the cap.
536        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        // "cargo build": 5 × 2000 × 0.05 savings = score 500 (big but barely filtered)
560        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        // "git status": 10 × 30 × 0.9 savings = score 270 (small and well-filtered)
569        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        // show_all=true: keep tiny git-status group so we can still assert both rows.
579        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        // Default view (show_all=false) filters out avg_raw < 50 → git-status drops.
586        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        // Gain totals (commands table) must be untouched.
637        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        // One old row, one recent row.
646        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        // git status: 3 runs (keep); kubectl get: 1 run (drop at --below 2).
662        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); // dry-run didn't delete
692
693        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        // git status: all runs had a plugin → drop.
706        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        // kubectl get: no plugin coverage → keep.
720        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}