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 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 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 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 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 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 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 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 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#[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), filtered: "a".repeat(40), 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}