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