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 plugin_ratio: f64,
39    pub score: f64,
40}
41
42/// Max rows retained in the `invocations` table. Oldest are evicted on insert.
43const INVOCATIONS_CAP: i64 = 10_000;
44
45/// Summary row from gain report.
46#[derive(Debug)]
47pub struct GainSummary {
48    pub commands: u64,
49    pub input_tokens: u64,
50    pub output_tokens: u64,
51    pub saved_tokens: u64,
52    pub savings_pct: f64,
53}
54
55/// Top command row from gain report.
56#[derive(Debug)]
57pub struct TopCommand {
58    pub command: String,
59    pub runs: u64,
60    pub saved: i64,
61    pub avg_pct: f64,
62}
63
64/// Session summary.
65#[derive(Debug)]
66pub struct SessionSummary {
67    pub commands: u64,
68    pub input_tokens: u64,
69    pub output_tokens: u64,
70    pub saved_tokens: i64,
71    pub savings_pct: f64,
72    pub total_time_ms: u64,
73}
74
75impl Db {
76    /// Open (or create) the tracking database.
77    pub fn open(data_dir: &Path) -> Result<Self> {
78        std::fs::create_dir_all(data_dir)?;
79        let db_path = data_dir.join("history.db");
80        let conn = Connection::open(&db_path)?;
81        conn.execute_batch(
82            "CREATE TABLE IF NOT EXISTS commands (
83                id INTEGER PRIMARY KEY,
84                timestamp TEXT NOT NULL,
85                original_cmd TEXT NOT NULL,
86                lowfat_cmd TEXT NOT NULL,
87                input_tokens INTEGER NOT NULL,
88                output_tokens INTEGER NOT NULL,
89                saved_tokens INTEGER NOT NULL,
90                savings_pct REAL NOT NULL,
91                exec_time_ms INTEGER DEFAULT 0,
92                project_path TEXT DEFAULT ''
93            );
94            CREATE INDEX IF NOT EXISTS idx_timestamp ON commands(timestamp);
95            CREATE INDEX IF NOT EXISTS idx_project ON commands(project_path, timestamp);
96
97            CREATE TABLE IF NOT EXISTS audit (
98                id INTEGER PRIMARY KEY,
99                timestamp TEXT NOT NULL,
100                plugin_name TEXT NOT NULL,
101                runtime_type TEXT NOT NULL,
102                command TEXT NOT NULL,
103                action TEXT NOT NULL,
104                checksum TEXT DEFAULT '',
105                details TEXT DEFAULT ''
106            );
107            CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit(timestamp);
108
109            CREATE TABLE IF NOT EXISTS invocations (
110                id INTEGER PRIMARY KEY AUTOINCREMENT,
111                timestamp TEXT NOT NULL,
112                command TEXT NOT NULL,
113                subcommand TEXT NOT NULL DEFAULT '',
114                raw_tokens INTEGER NOT NULL,
115                filtered_tokens INTEGER NOT NULL,
116                had_plugin INTEGER NOT NULL,
117                exit_code INTEGER NOT NULL
118            );
119            CREATE INDEX IF NOT EXISTS idx_invocations_cmd ON invocations(command, subcommand);",
120        )?;
121        Ok(Db { conn })
122    }
123
124    /// Record a command execution.
125    pub fn track(&self, record: &TrackRecord) -> Result<()> {
126        let in_tok = crate::tokens::estimate_tokens(&record.raw);
127        let out_tok = crate::tokens::estimate_tokens(&record.filtered);
128        let saved = in_tok as i64 - out_tok as i64;
129        let pct = if in_tok > 0 {
130            (saved as f64 / in_tok as f64) * 100.0
131        } else {
132            0.0
133        };
134
135        self.conn.execute(
136            "INSERT INTO commands(timestamp, original_cmd, lowfat_cmd, input_tokens, output_tokens, saved_tokens, savings_pct, exec_time_ms, project_path)
137             VALUES(datetime('now'), ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
138            rusqlite::params![
139                record.original_cmd,
140                record.lowfat_cmd,
141                in_tok as i64,
142                out_tok as i64,
143                saved,
144                pct,
145                record.exec_time_ms as i64,
146                record.project_path,
147            ],
148        )?;
149        Ok(())
150    }
151
152    /// Record an invocation for the usage-history ranking. Evicts oldest rows
153    /// once the table grows past `INVOCATIONS_CAP`.
154    pub fn record_invocation(&self, rec: &InvocationRecord) -> Result<()> {
155        self.conn.execute(
156            "INSERT INTO invocations(timestamp, command, subcommand, raw_tokens, filtered_tokens, had_plugin, exit_code)
157             VALUES(datetime('now'), ?1, ?2, ?3, ?4, ?5, ?6)",
158            rusqlite::params![
159                rec.command,
160                rec.subcommand,
161                rec.raw_tokens as i64,
162                rec.filtered_tokens as i64,
163                rec.had_plugin as i64,
164                rec.exit_code,
165            ],
166        )?;
167        // id is monotonic (AUTOINCREMENT), so this trims only when over the cap.
168        self.conn.execute(
169            "DELETE FROM invocations WHERE id <= (SELECT MAX(id) - ?1 FROM invocations)",
170            [INVOCATIONS_CAP],
171        )?;
172        Ok(())
173    }
174
175    /// Rank command+subcommand pairs as plugin candidates.
176    /// Score = runs × avg_raw_tokens × (1 − savings_ratio) — i.e. called often,
177    /// produces a lot, and lowfat is not yet shrinking it much.
178    pub fn history_ranking(&self, limit: usize) -> Result<Vec<HistoryRow>> {
179        let mut stmt = self.conn.prepare(
180            "SELECT command, subcommand,
181                    COUNT(*) AS runs,
182                    AVG(raw_tokens) AS avg_raw,
183                    CASE WHEN SUM(raw_tokens) > 0
184                         THEN 100.0 * (1.0 - 1.0 * SUM(filtered_tokens) / SUM(raw_tokens))
185                         ELSE 0 END AS savings_pct,
186                    AVG(had_plugin) AS plugin_ratio,
187                    COUNT(*) * AVG(raw_tokens) *
188                        (CASE WHEN SUM(raw_tokens) > 0
189                              THEN 1.0 - 1.0 * SUM(filtered_tokens) / SUM(raw_tokens)
190                              ELSE 0 END) AS score
191             FROM invocations
192             GROUP BY command, subcommand
193             ORDER BY score DESC
194             LIMIT ?1",
195        )?;
196        let rows = stmt.query_map([limit as i64], |row| {
197            Ok(HistoryRow {
198                command: row.get(0)?,
199                subcommand: row.get(1)?,
200                runs: row.get::<_, i64>(2)? as u64,
201                avg_raw_tokens: row.get(3)?,
202                savings_pct: row.get(4)?,
203                plugin_ratio: row.get(5)?,
204                score: row.get(6)?,
205            })
206        })?;
207        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
208    }
209
210    /// Lifetime gain summary.
211    pub fn gain_summary(&self) -> Result<GainSummary> {
212        let mut stmt = self.conn.prepare(
213            "SELECT COUNT(*), COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0),
214                    COALESCE(SUM(saved_tokens),0),
215                    CASE WHEN SUM(input_tokens)>0
216                      THEN ROUND(100.0*SUM(saved_tokens)/SUM(input_tokens),1) ELSE 0 END
217             FROM commands",
218        )?;
219        let row = stmt.query_row([], |row| {
220            Ok(GainSummary {
221                commands: row.get::<_, i64>(0)? as u64,
222                input_tokens: row.get::<_, i64>(1)? as u64,
223                output_tokens: row.get::<_, i64>(2)? as u64,
224                saved_tokens: row.get::<_, i64>(3)? as u64,
225                savings_pct: row.get(4)?,
226            })
227        })?;
228        Ok(row)
229    }
230
231    /// Top commands by tokens saved.
232    pub fn top_commands(&self, limit: usize) -> Result<Vec<TopCommand>> {
233        let mut stmt = self.conn.prepare(
234            "SELECT original_cmd, COUNT(*), SUM(saved_tokens), ROUND(AVG(savings_pct),1)
235             FROM commands GROUP BY original_cmd ORDER BY SUM(saved_tokens) DESC LIMIT ?1",
236        )?;
237        let rows = stmt.query_map([limit as i64], |row| {
238            Ok(TopCommand {
239                command: row.get(0)?,
240                runs: row.get::<_, i64>(1)? as u64,
241                saved: row.get(2)?,
242                avg_pct: row.get(3)?,
243            })
244        })?;
245        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
246    }
247
248    /// Session summary since a given timestamp.
249    pub fn session_summary(&self, since: &str) -> Result<SessionSummary> {
250        let mut stmt = self.conn.prepare(
251            "SELECT COUNT(*),
252                    COALESCE(SUM(input_tokens),0),
253                    COALESCE(SUM(output_tokens),0),
254                    COALESCE(SUM(saved_tokens),0),
255                    CASE WHEN SUM(input_tokens)>0
256                      THEN ROUND(100.0*SUM(saved_tokens)/SUM(input_tokens),1) ELSE 0 END,
257                    COALESCE(SUM(exec_time_ms),0)
258             FROM commands WHERE timestamp >= ?1",
259        )?;
260        let row = stmt.query_row([since], |row| {
261            Ok(SessionSummary {
262                commands: row.get::<_, i64>(0)? as u64,
263                input_tokens: row.get::<_, i64>(1)? as u64,
264                output_tokens: row.get::<_, i64>(2)? as u64,
265                saved_tokens: row.get(3)?,
266                savings_pct: row.get(4)?,
267                total_time_ms: row.get::<_, i64>(5)? as u64,
268            })
269        })?;
270        Ok(row)
271    }
272
273    /// Record an audit event (plugin load, security check, etc.).
274    pub fn audit(
275        &self,
276        plugin_name: &str,
277        runtime_type: &str,
278        command: &str,
279        action: &str,
280        checksum: &str,
281        details: &str,
282    ) -> Result<()> {
283        self.conn.execute(
284            "INSERT INTO audit(timestamp, plugin_name, runtime_type, command, action, checksum, details)
285             VALUES(datetime('now'), ?1, ?2, ?3, ?4, ?5, ?6)",
286            rusqlite::params![plugin_name, runtime_type, command, action, checksum, details],
287        )?;
288        Ok(())
289    }
290
291    /// Get recent audit entries.
292    pub fn audit_log(&self, limit: usize) -> Result<Vec<AuditEntry>> {
293        let mut stmt = self.conn.prepare(
294            "SELECT timestamp, plugin_name, runtime_type, command, action, checksum, details
295             FROM audit ORDER BY id DESC LIMIT ?1",
296        )?;
297        let rows = stmt.query_map([limit as i64], |row| {
298            Ok(AuditEntry {
299                timestamp: row.get(0)?,
300                plugin_name: row.get(1)?,
301                runtime_type: row.get(2)?,
302                command: row.get(3)?,
303                action: row.get(4)?,
304                checksum: row.get(5)?,
305                details: row.get(6)?,
306            })
307        })?;
308        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
309    }
310}
311
312/// Audit log entry.
313#[derive(Debug)]
314pub struct AuditEntry {
315    pub timestamp: String,
316    pub plugin_name: String,
317    pub runtime_type: String,
318    pub command: String,
319    pub action: String,
320    pub checksum: String,
321    pub details: String,
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    #[test]
329    fn db_create_and_track() {
330        let tmp = tempfile::tempdir().unwrap();
331        let db = Db::open(tmp.path()).unwrap();
332
333        let record = TrackRecord {
334            original_cmd: "git status".to_string(),
335            lowfat_cmd: "lowfat git status".to_string(),
336            raw: "a".repeat(100), // 25 tokens
337            filtered: "a".repeat(40), // 10 tokens
338            exec_time_ms: 50,
339            project_path: "/tmp/test".to_string(),
340        };
341        db.track(&record).unwrap();
342
343        let summary = db.gain_summary().unwrap();
344        assert_eq!(summary.commands, 1);
345        assert_eq!(summary.input_tokens, 25);
346        assert_eq!(summary.output_tokens, 10);
347        assert_eq!(summary.saved_tokens, 15);
348    }
349
350    #[test]
351    fn invocations_evict_past_cap() {
352        let tmp = tempfile::tempdir().unwrap();
353        let db = Db::open(tmp.path()).unwrap();
354
355        // Insert cap + 5 rows, verify eviction keeps us at the cap.
356        for i in 0..(super::INVOCATIONS_CAP + 5) {
357            db.record_invocation(&InvocationRecord {
358                command: "git".into(),
359                subcommand: format!("s{i}"),
360                raw_tokens: 100,
361                filtered_tokens: 20,
362                had_plugin: true,
363                exit_code: 0,
364            }).unwrap();
365        }
366        let count: i64 = db.conn
367            .query_row("SELECT COUNT(*) FROM invocations", [], |r| r.get(0))
368            .unwrap();
369        assert_eq!(count, super::INVOCATIONS_CAP);
370    }
371
372    #[test]
373    fn history_ranking_orders_by_score() {
374        let tmp = tempfile::tempdir().unwrap();
375        let db = Db::open(tmp.path()).unwrap();
376
377        // "cargo build": 5 × 2000 × 0.05 savings = score 500 (big but barely filtered)
378        for _ in 0..5 {
379            db.record_invocation(&InvocationRecord {
380                command: "cargo".into(), subcommand: "build".into(),
381                raw_tokens: 2000, filtered_tokens: 1900,
382                had_plugin: false, exit_code: 0,
383            }).unwrap();
384        }
385        // "git status": 10 × 30 × 0.9 savings = score 270 (small and well-filtered)
386        for _ in 0..10 {
387            db.record_invocation(&InvocationRecord {
388                command: "git".into(), subcommand: "status".into(),
389                raw_tokens: 30, filtered_tokens: 3,
390                had_plugin: true, exit_code: 0,
391            }).unwrap();
392        }
393
394        let ranking = db.history_ranking(10).unwrap();
395        assert_eq!(ranking.len(), 2);
396        assert_eq!(ranking[0].command, "cargo");
397        assert_eq!(ranking[0].subcommand, "build");
398        assert_eq!(ranking[1].command, "git");
399    }
400
401    #[test]
402    fn top_commands() {
403        let tmp = tempfile::tempdir().unwrap();
404        let db = Db::open(tmp.path()).unwrap();
405
406        for _ in 0..3 {
407            db.track(&TrackRecord {
408                original_cmd: "git diff".to_string(),
409                lowfat_cmd: "lowfat git diff".to_string(),
410                raw: "a".repeat(100),
411                filtered: "a".repeat(20),
412                exec_time_ms: 10,
413                project_path: "/tmp".to_string(),
414            }).unwrap();
415        }
416
417        let top = db.top_commands(10).unwrap();
418        assert_eq!(top.len(), 1);
419        assert_eq!(top[0].command, "git diff");
420        assert_eq!(top[0].runs, 3);
421    }
422}