1use anyhow::Result;
2use rusqlite::Connection;
3use std::path::Path;
4
5pub struct Db {
7 conn: Connection,
8}
9
10pub 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#[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#[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#[derive(Debug)]
41pub struct SessionSummary {
42 pub commands: u64,
43 pub saved_tokens: i64,
44 pub savings_pct: f64,
45}
46
47impl Db {
48 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 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 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 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 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 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 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#[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), filtered: "a".repeat(40), 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}