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/// Summary row from gain report.
21#[derive(Debug)]
22pub struct GainSummary {
23    pub commands: u64,
24    pub input_tokens: u64,
25    pub output_tokens: u64,
26    pub saved_tokens: u64,
27    pub savings_pct: f64,
28}
29
30/// Top command row from gain report.
31#[derive(Debug)]
32pub struct TopCommand {
33    pub command: String,
34    pub runs: u64,
35    pub saved: i64,
36    pub avg_pct: f64,
37}
38
39/// Session summary.
40#[derive(Debug)]
41pub struct SessionSummary {
42    pub commands: u64,
43    pub saved_tokens: i64,
44    pub savings_pct: f64,
45}
46
47impl Db {
48    /// Open (or create) the tracking database.
49    pub fn open(data_dir: &Path) -> Result<Self> {
50        std::fs::create_dir_all(data_dir)?;
51        let db_path = data_dir.join("history.db");
52        let conn = Connection::open(&db_path)?;
53        conn.execute_batch(
54            "CREATE TABLE IF NOT EXISTS commands (
55                id INTEGER PRIMARY KEY,
56                timestamp TEXT NOT NULL,
57                original_cmd TEXT NOT NULL,
58                lowfat_cmd TEXT NOT NULL,
59                input_tokens INTEGER NOT NULL,
60                output_tokens INTEGER NOT NULL,
61                saved_tokens INTEGER NOT NULL,
62                savings_pct REAL NOT NULL,
63                exec_time_ms INTEGER DEFAULT 0,
64                project_path TEXT DEFAULT ''
65            );
66            CREATE INDEX IF NOT EXISTS idx_timestamp ON commands(timestamp);
67            CREATE INDEX IF NOT EXISTS idx_project ON commands(project_path, timestamp);
68
69            CREATE TABLE IF NOT EXISTS audit (
70                id INTEGER PRIMARY KEY,
71                timestamp TEXT NOT NULL,
72                plugin_name TEXT NOT NULL,
73                runtime_type TEXT NOT NULL,
74                command TEXT NOT NULL,
75                action TEXT NOT NULL,
76                checksum TEXT DEFAULT '',
77                details TEXT DEFAULT ''
78            );
79            CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit(timestamp);",
80        )?;
81        Ok(Db { conn })
82    }
83
84    /// Record a command execution.
85    pub fn track(&self, record: &TrackRecord) -> Result<()> {
86        let in_tok = crate::tokens::estimate_tokens(&record.raw);
87        let out_tok = crate::tokens::estimate_tokens(&record.filtered);
88        let saved = in_tok as i64 - out_tok as i64;
89        let pct = if in_tok > 0 {
90            (saved as f64 / in_tok as f64) * 100.0
91        } else {
92            0.0
93        };
94
95        self.conn.execute(
96            "INSERT INTO commands(timestamp, original_cmd, lowfat_cmd, input_tokens, output_tokens, saved_tokens, savings_pct, exec_time_ms, project_path)
97             VALUES(datetime('now'), ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
98            rusqlite::params![
99                record.original_cmd,
100                record.lowfat_cmd,
101                in_tok as i64,
102                out_tok as i64,
103                saved,
104                pct,
105                record.exec_time_ms as i64,
106                record.project_path,
107            ],
108        )?;
109        Ok(())
110    }
111
112    /// Lifetime gain summary.
113    pub fn gain_summary(&self) -> Result<GainSummary> {
114        let mut stmt = self.conn.prepare(
115            "SELECT COUNT(*), COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0),
116                    COALESCE(SUM(saved_tokens),0),
117                    CASE WHEN SUM(input_tokens)>0
118                      THEN ROUND(100.0*SUM(saved_tokens)/SUM(input_tokens),1) ELSE 0 END
119             FROM commands",
120        )?;
121        let row = stmt.query_row([], |row| {
122            Ok(GainSummary {
123                commands: row.get::<_, i64>(0)? as u64,
124                input_tokens: row.get::<_, i64>(1)? as u64,
125                output_tokens: row.get::<_, i64>(2)? as u64,
126                saved_tokens: row.get::<_, i64>(3)? as u64,
127                savings_pct: row.get(4)?,
128            })
129        })?;
130        Ok(row)
131    }
132
133    /// Top commands by tokens saved.
134    pub fn top_commands(&self, limit: usize) -> Result<Vec<TopCommand>> {
135        let mut stmt = self.conn.prepare(
136            "SELECT original_cmd, COUNT(*), SUM(saved_tokens), ROUND(AVG(savings_pct),1)
137             FROM commands GROUP BY original_cmd ORDER BY SUM(saved_tokens) DESC LIMIT ?1",
138        )?;
139        let rows = stmt.query_map([limit as i64], |row| {
140            Ok(TopCommand {
141                command: row.get(0)?,
142                runs: row.get::<_, i64>(1)? as u64,
143                saved: row.get(2)?,
144                avg_pct: row.get(3)?,
145            })
146        })?;
147        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
148    }
149
150    /// Session summary since a given timestamp.
151    pub fn session_summary(&self, since: &str) -> Result<SessionSummary> {
152        let mut stmt = self.conn.prepare(
153            "SELECT COUNT(*), COALESCE(SUM(saved_tokens),0),
154                    CASE WHEN SUM(input_tokens)>0
155                      THEN ROUND(100.0*SUM(saved_tokens)/SUM(input_tokens),1) ELSE 0 END
156             FROM commands WHERE timestamp >= ?1",
157        )?;
158        let row = stmt.query_row([since], |row| {
159            Ok(SessionSummary {
160                commands: row.get::<_, i64>(0)? as u64,
161                saved_tokens: row.get(1)?,
162                savings_pct: row.get(2)?,
163            })
164        })?;
165        Ok(row)
166    }
167
168    /// Record an audit event (plugin load, security check, etc.).
169    pub fn audit(
170        &self,
171        plugin_name: &str,
172        runtime_type: &str,
173        command: &str,
174        action: &str,
175        checksum: &str,
176        details: &str,
177    ) -> Result<()> {
178        self.conn.execute(
179            "INSERT INTO audit(timestamp, plugin_name, runtime_type, command, action, checksum, details)
180             VALUES(datetime('now'), ?1, ?2, ?3, ?4, ?5, ?6)",
181            rusqlite::params![plugin_name, runtime_type, command, action, checksum, details],
182        )?;
183        Ok(())
184    }
185
186    /// Get recent audit entries.
187    pub fn audit_log(&self, limit: usize) -> Result<Vec<AuditEntry>> {
188        let mut stmt = self.conn.prepare(
189            "SELECT timestamp, plugin_name, runtime_type, command, action, checksum, details
190             FROM audit ORDER BY id DESC LIMIT ?1",
191        )?;
192        let rows = stmt.query_map([limit as i64], |row| {
193            Ok(AuditEntry {
194                timestamp: row.get(0)?,
195                plugin_name: row.get(1)?,
196                runtime_type: row.get(2)?,
197                command: row.get(3)?,
198                action: row.get(4)?,
199                checksum: row.get(5)?,
200                details: row.get(6)?,
201            })
202        })?;
203        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
204    }
205}
206
207/// Audit log entry.
208#[derive(Debug)]
209pub struct AuditEntry {
210    pub timestamp: String,
211    pub plugin_name: String,
212    pub runtime_type: String,
213    pub command: String,
214    pub action: String,
215    pub checksum: String,
216    pub details: String,
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn db_create_and_track() {
225        let tmp = tempfile::tempdir().unwrap();
226        let db = Db::open(tmp.path()).unwrap();
227
228        let record = TrackRecord {
229            original_cmd: "git status".to_string(),
230            lowfat_cmd: "lowfat git status".to_string(),
231            raw: "a".repeat(100), // 25 tokens
232            filtered: "a".repeat(40), // 10 tokens
233            exec_time_ms: 50,
234            project_path: "/tmp/test".to_string(),
235        };
236        db.track(&record).unwrap();
237
238        let summary = db.gain_summary().unwrap();
239        assert_eq!(summary.commands, 1);
240        assert_eq!(summary.input_tokens, 25);
241        assert_eq!(summary.output_tokens, 10);
242        assert_eq!(summary.saved_tokens, 15);
243    }
244
245    #[test]
246    fn top_commands() {
247        let tmp = tempfile::tempdir().unwrap();
248        let db = Db::open(tmp.path()).unwrap();
249
250        for _ in 0..3 {
251            db.track(&TrackRecord {
252                original_cmd: "git diff".to_string(),
253                lowfat_cmd: "lowfat git diff".to_string(),
254                raw: "a".repeat(100),
255                filtered: "a".repeat(20),
256                exec_time_ms: 10,
257                project_path: "/tmp".to_string(),
258            }).unwrap();
259        }
260
261        let top = db.top_commands(10).unwrap();
262        assert_eq!(top.len(), 1);
263        assert_eq!(top[0].command, "git diff");
264        assert_eq!(top[0].runs, 3);
265    }
266}