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