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 filter is registered for this command (regardless of subcommand scope).
27    /// This is true for both native built-ins and external plugins.
28    pub had_plugin: bool,
29    /// Plugin applies to *this* subcommand — i.e. matches its declared list,
30    /// or plugin declares no subcommand restriction.
31    pub in_scope: bool,
32    /// Filter actually shrank output (filtered_tokens < raw_tokens).
33    pub reduced: bool,
34    /// The handler is an external plugin from `~/.lowfat/plugins/` rather than
35    /// a native built-in compiled into the binary. Used to label the source
36    /// column in `lowfat history` so users can tell what they own vs what ships.
37    pub is_external_plugin: bool,
38    pub exit_code: i32,
39}
40
41/// One row of the plugin-candidates ranking.
42#[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    /// AVG(had_plugin): plugin registered for this command.
51    pub registered_ratio: f64,
52    /// AVG(in_scope): filter declared to cover this subcommand.
53    pub in_scope_ratio: f64,
54    /// AVG(reduced): filter actually shrank output on average.
55    pub reduced_ratio: f64,
56    /// AVG(is_external_plugin): handler came from `~/.lowfat/plugins/` rather
57    /// than the binary's compiled-in filters. Drives the source column.
58    pub external_ratio: f64,
59    pub score: f64,
60}
61
62/// Exported invocation row (user-driven backup / analysis).
63#[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
77/// Max rows retained in the `invocations` table. Oldest are evicted on insert.
78const INVOCATIONS_CAP: i64 = 10_000;
79
80/// Selective prune strategy for the `invocations` table.
81#[derive(Debug, Clone)]
82pub enum PruneFilter {
83    /// Remove rows whose timestamp is older than N days.
84    OlderThan(u32),
85    /// Remove entire (command, subcommand) groups with fewer than N runs.
86    BelowUsage(u64),
87    /// Remove groups where every row already had a plugin — already filtered,
88    /// so they aren't plugin candidates anymore.
89    KeptByPlugin,
90    /// Wipe the invocations table entirely.
91    All,
92}
93
94/// Summary row from gain report.
95#[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/// Top command row from gain report.
105#[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/// Session summary.
114#[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    /// Open (or create) the tracking database.
126    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        // Finer-grained status columns. Added after initial schema so existing
172        // databases get migrated in place. `in_scope` distinguishes "plugin
173        // declares this subcommand" from bare "plugin registered"; `reduced`
174        // captures whether the filter actually shrank the output. Together
175        // they split the misleading "has_plugin=yes, savings=0%" rows into
176        // out-of-scope / noop / weak / good.
177        let _ = conn.execute("ALTER TABLE invocations ADD COLUMN in_scope INTEGER", []);
178        let _ = conn.execute("ALTER TABLE invocations ADD COLUMN reduced INTEGER", []);
179        // Backfill legacy rows once. `reduced` is perfectly derivable from
180        // stored token counts. `in_scope` can only be approximated — use
181        // `had_plugin` as best-effort proxy (overestimates for subcommands
182        // the plugin never covered, but only for rows predating migration).
183        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        // `is_external_plugin` distinguishes external plugins (from
195        // `~/.lowfat/plugins/`) from native built-ins compiled into the
196        // binary. Without it, the source column conflates "lowfat ships a
197        // filter for this" with "the user installed a plugin for this".
198        let _ = conn.execute(
199            "ALTER TABLE invocations ADD COLUMN is_external_plugin INTEGER",
200            [],
201        );
202        // Legacy backfill: had_plugin=1 with command in the known native
203        // builtin set is treated as built-in. Anything else with had_plugin=1
204        // is treated as external. The native set is hardcoded to the names
205        // shipped at this migration's authorship — accurate for historical
206        // rows, since shipping new builtins requires a new binary anyway.
207        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    /// Record a command execution.
218    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    /// Record an invocation for the usage-history ranking. Evicts oldest rows
246    /// once the table grows past `INVOCATIONS_CAP`.
247    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        // id is monotonic (AUTOINCREMENT), so this trims only when over the cap.
264        self.conn.execute(
265            "DELETE FROM invocations WHERE id <= (SELECT MAX(id) - ?1 FROM invocations)",
266            [INVOCATIONS_CAP],
267        )?;
268        Ok(())
269    }
270
271    /// Prune invocation rows matching `filter`. With `dry_run=true`, returns the
272    /// count that would be removed without touching the table. Gain stats in the
273    /// `commands` table are lifetime counters and are never pruned here.
274    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                // Tuple-IN subquery: drop every row belonging to groups under the threshold.
308                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                // MIN(had_plugin)=1 → every row in the group already had a plugin.
327                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    /// Export all invocation rows (oldest first) for user-driven backup.
347    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    /// Rank command+subcommand pairs as plugin candidates.
372    ///
373    /// Score = SUM(filtered_tokens) — bytes still flowing through to the LLM
374    /// after filtering. High score = "lowfat hasn't tamed this row yet,"
375    /// regardless of whether that's because there's no plugin or because the
376    /// filter is weak. Note: the previous formula was effectively SUM(saved),
377    /// which inverted the intent and put well-filtered rows at the top.
378    ///
379    /// `show_all=false` hides trivia (avg raw < 50 tokens or fewer than 2 runs)
380    /// where no amount of plugin work could plausibly help.
381    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    /// Lifetime gain summary.
426    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    /// Top commands by tokens saved.
447    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    /// Session summary since a given timestamp.
464    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    /// Record an audit event (plugin load, security check, etc.).
489    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    /// Get recent audit entries.
507    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/// Audit log entry.
528#[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), // 25 tokens
552            filtered: "a".repeat(40), // 10 tokens
553            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        // Insert cap + 5 rows, verify eviction keeps us at the cap.
571        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        // "cargo build": 5 × 2000 × 0.05 savings = score 500 (big but barely filtered)
596        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        // "git status": 10 × 30 × 0.9 savings = score 270 (small and well-filtered)
606        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        // show_all=true: keep tiny git-status group so we can still assert both rows.
617        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        // Default view (show_all=false) filters out avg_raw < 50 → git-status drops.
624        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        // Gain totals (commands table) must be untouched.
676        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        // One old row, one recent row.
685        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        // git status: 3 runs (keep); kubectl get: 1 run (drop at --below 2).
701        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); // dry-run didn't delete
733
734        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        // git status: all runs had a plugin → drop.
747        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        // kubectl get: no plugin coverage → keep.
762        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}