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
20pub 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#[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
42const INVOCATIONS_CAP: i64 = 10_000;
44
45#[derive(Debug)]
47pub struct GainSummary {
48 pub commands: u64,
49 pub input_tokens: u64,
50 pub output_tokens: u64,
51 pub saved_tokens: u64,
52 pub savings_pct: f64,
53}
54
55#[derive(Debug)]
57pub struct TopCommand {
58 pub command: String,
59 pub runs: u64,
60 pub saved: i64,
61 pub avg_pct: f64,
62}
63
64#[derive(Debug)]
66pub struct SessionSummary {
67 pub commands: u64,
68 pub input_tokens: u64,
69 pub output_tokens: u64,
70 pub saved_tokens: i64,
71 pub savings_pct: f64,
72 pub total_time_ms: u64,
73}
74
75impl Db {
76 pub fn open(data_dir: &Path) -> Result<Self> {
78 std::fs::create_dir_all(data_dir)?;
79 let db_path = data_dir.join("history.db");
80 let conn = Connection::open(&db_path)?;
81 conn.execute_batch(
82 "CREATE TABLE IF NOT EXISTS commands (
83 id INTEGER PRIMARY KEY,
84 timestamp TEXT NOT NULL,
85 original_cmd TEXT NOT NULL,
86 lowfat_cmd TEXT NOT NULL,
87 input_tokens INTEGER NOT NULL,
88 output_tokens INTEGER NOT NULL,
89 saved_tokens INTEGER NOT NULL,
90 savings_pct REAL NOT NULL,
91 exec_time_ms INTEGER DEFAULT 0,
92 project_path TEXT DEFAULT ''
93 );
94 CREATE INDEX IF NOT EXISTS idx_timestamp ON commands(timestamp);
95 CREATE INDEX IF NOT EXISTS idx_project ON commands(project_path, timestamp);
96
97 CREATE TABLE IF NOT EXISTS audit (
98 id INTEGER PRIMARY KEY,
99 timestamp TEXT NOT NULL,
100 plugin_name TEXT NOT NULL,
101 runtime_type TEXT NOT NULL,
102 command TEXT NOT NULL,
103 action TEXT NOT NULL,
104 checksum TEXT DEFAULT '',
105 details TEXT DEFAULT ''
106 );
107 CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit(timestamp);
108
109 CREATE TABLE IF NOT EXISTS invocations (
110 id INTEGER PRIMARY KEY AUTOINCREMENT,
111 timestamp TEXT NOT NULL,
112 command TEXT NOT NULL,
113 subcommand TEXT NOT NULL DEFAULT '',
114 raw_tokens INTEGER NOT NULL,
115 filtered_tokens INTEGER NOT NULL,
116 had_plugin INTEGER NOT NULL,
117 exit_code INTEGER NOT NULL
118 );
119 CREATE INDEX IF NOT EXISTS idx_invocations_cmd ON invocations(command, subcommand);",
120 )?;
121 Ok(Db { conn })
122 }
123
124 pub fn track(&self, record: &TrackRecord) -> Result<()> {
126 let in_tok = crate::tokens::estimate_tokens(&record.raw);
127 let out_tok = crate::tokens::estimate_tokens(&record.filtered);
128 let saved = in_tok as i64 - out_tok as i64;
129 let pct = if in_tok > 0 {
130 (saved as f64 / in_tok as f64) * 100.0
131 } else {
132 0.0
133 };
134
135 self.conn.execute(
136 "INSERT INTO commands(timestamp, original_cmd, lowfat_cmd, input_tokens, output_tokens, saved_tokens, savings_pct, exec_time_ms, project_path)
137 VALUES(datetime('now'), ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
138 rusqlite::params![
139 record.original_cmd,
140 record.lowfat_cmd,
141 in_tok as i64,
142 out_tok as i64,
143 saved,
144 pct,
145 record.exec_time_ms as i64,
146 record.project_path,
147 ],
148 )?;
149 Ok(())
150 }
151
152 pub fn record_invocation(&self, rec: &InvocationRecord) -> Result<()> {
155 self.conn.execute(
156 "INSERT INTO invocations(timestamp, command, subcommand, raw_tokens, filtered_tokens, had_plugin, exit_code)
157 VALUES(datetime('now'), ?1, ?2, ?3, ?4, ?5, ?6)",
158 rusqlite::params![
159 rec.command,
160 rec.subcommand,
161 rec.raw_tokens as i64,
162 rec.filtered_tokens as i64,
163 rec.had_plugin as i64,
164 rec.exit_code,
165 ],
166 )?;
167 self.conn.execute(
169 "DELETE FROM invocations WHERE id <= (SELECT MAX(id) - ?1 FROM invocations)",
170 [INVOCATIONS_CAP],
171 )?;
172 Ok(())
173 }
174
175 pub fn history_ranking(&self, limit: usize) -> Result<Vec<HistoryRow>> {
179 let mut stmt = self.conn.prepare(
180 "SELECT command, subcommand,
181 COUNT(*) AS runs,
182 AVG(raw_tokens) AS avg_raw,
183 CASE WHEN SUM(raw_tokens) > 0
184 THEN 100.0 * (1.0 - 1.0 * SUM(filtered_tokens) / SUM(raw_tokens))
185 ELSE 0 END AS savings_pct,
186 AVG(had_plugin) AS plugin_ratio,
187 COUNT(*) * AVG(raw_tokens) *
188 (CASE WHEN SUM(raw_tokens) > 0
189 THEN 1.0 - 1.0 * SUM(filtered_tokens) / SUM(raw_tokens)
190 ELSE 0 END) AS score
191 FROM invocations
192 GROUP BY command, subcommand
193 ORDER BY score DESC
194 LIMIT ?1",
195 )?;
196 let rows = stmt.query_map([limit as i64], |row| {
197 Ok(HistoryRow {
198 command: row.get(0)?,
199 subcommand: row.get(1)?,
200 runs: row.get::<_, i64>(2)? as u64,
201 avg_raw_tokens: row.get(3)?,
202 savings_pct: row.get(4)?,
203 plugin_ratio: row.get(5)?,
204 score: row.get(6)?,
205 })
206 })?;
207 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
208 }
209
210 pub fn gain_summary(&self) -> Result<GainSummary> {
212 let mut stmt = self.conn.prepare(
213 "SELECT COUNT(*), COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0),
214 COALESCE(SUM(saved_tokens),0),
215 CASE WHEN SUM(input_tokens)>0
216 THEN ROUND(100.0*SUM(saved_tokens)/SUM(input_tokens),1) ELSE 0 END
217 FROM commands",
218 )?;
219 let row = stmt.query_row([], |row| {
220 Ok(GainSummary {
221 commands: row.get::<_, i64>(0)? as u64,
222 input_tokens: row.get::<_, i64>(1)? as u64,
223 output_tokens: row.get::<_, i64>(2)? as u64,
224 saved_tokens: row.get::<_, i64>(3)? as u64,
225 savings_pct: row.get(4)?,
226 })
227 })?;
228 Ok(row)
229 }
230
231 pub fn top_commands(&self, limit: usize) -> Result<Vec<TopCommand>> {
233 let mut stmt = self.conn.prepare(
234 "SELECT original_cmd, COUNT(*), SUM(saved_tokens), ROUND(AVG(savings_pct),1)
235 FROM commands GROUP BY original_cmd ORDER BY SUM(saved_tokens) DESC LIMIT ?1",
236 )?;
237 let rows = stmt.query_map([limit as i64], |row| {
238 Ok(TopCommand {
239 command: row.get(0)?,
240 runs: row.get::<_, i64>(1)? as u64,
241 saved: row.get(2)?,
242 avg_pct: row.get(3)?,
243 })
244 })?;
245 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
246 }
247
248 pub fn session_summary(&self, since: &str) -> Result<SessionSummary> {
250 let mut stmt = self.conn.prepare(
251 "SELECT COUNT(*),
252 COALESCE(SUM(input_tokens),0),
253 COALESCE(SUM(output_tokens),0),
254 COALESCE(SUM(saved_tokens),0),
255 CASE WHEN SUM(input_tokens)>0
256 THEN ROUND(100.0*SUM(saved_tokens)/SUM(input_tokens),1) ELSE 0 END,
257 COALESCE(SUM(exec_time_ms),0)
258 FROM commands WHERE timestamp >= ?1",
259 )?;
260 let row = stmt.query_row([since], |row| {
261 Ok(SessionSummary {
262 commands: row.get::<_, i64>(0)? as u64,
263 input_tokens: row.get::<_, i64>(1)? as u64,
264 output_tokens: row.get::<_, i64>(2)? as u64,
265 saved_tokens: row.get(3)?,
266 savings_pct: row.get(4)?,
267 total_time_ms: row.get::<_, i64>(5)? as u64,
268 })
269 })?;
270 Ok(row)
271 }
272
273 pub fn audit(
275 &self,
276 plugin_name: &str,
277 runtime_type: &str,
278 command: &str,
279 action: &str,
280 checksum: &str,
281 details: &str,
282 ) -> Result<()> {
283 self.conn.execute(
284 "INSERT INTO audit(timestamp, plugin_name, runtime_type, command, action, checksum, details)
285 VALUES(datetime('now'), ?1, ?2, ?3, ?4, ?5, ?6)",
286 rusqlite::params![plugin_name, runtime_type, command, action, checksum, details],
287 )?;
288 Ok(())
289 }
290
291 pub fn audit_log(&self, limit: usize) -> Result<Vec<AuditEntry>> {
293 let mut stmt = self.conn.prepare(
294 "SELECT timestamp, plugin_name, runtime_type, command, action, checksum, details
295 FROM audit ORDER BY id DESC LIMIT ?1",
296 )?;
297 let rows = stmt.query_map([limit as i64], |row| {
298 Ok(AuditEntry {
299 timestamp: row.get(0)?,
300 plugin_name: row.get(1)?,
301 runtime_type: row.get(2)?,
302 command: row.get(3)?,
303 action: row.get(4)?,
304 checksum: row.get(5)?,
305 details: row.get(6)?,
306 })
307 })?;
308 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
309 }
310}
311
312#[derive(Debug)]
314pub struct AuditEntry {
315 pub timestamp: String,
316 pub plugin_name: String,
317 pub runtime_type: String,
318 pub command: String,
319 pub action: String,
320 pub checksum: String,
321 pub details: String,
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 #[test]
329 fn db_create_and_track() {
330 let tmp = tempfile::tempdir().unwrap();
331 let db = Db::open(tmp.path()).unwrap();
332
333 let record = TrackRecord {
334 original_cmd: "git status".to_string(),
335 lowfat_cmd: "lowfat git status".to_string(),
336 raw: "a".repeat(100), filtered: "a".repeat(40), exec_time_ms: 50,
339 project_path: "/tmp/test".to_string(),
340 };
341 db.track(&record).unwrap();
342
343 let summary = db.gain_summary().unwrap();
344 assert_eq!(summary.commands, 1);
345 assert_eq!(summary.input_tokens, 25);
346 assert_eq!(summary.output_tokens, 10);
347 assert_eq!(summary.saved_tokens, 15);
348 }
349
350 #[test]
351 fn invocations_evict_past_cap() {
352 let tmp = tempfile::tempdir().unwrap();
353 let db = Db::open(tmp.path()).unwrap();
354
355 for i in 0..(super::INVOCATIONS_CAP + 5) {
357 db.record_invocation(&InvocationRecord {
358 command: "git".into(),
359 subcommand: format!("s{i}"),
360 raw_tokens: 100,
361 filtered_tokens: 20,
362 had_plugin: true,
363 exit_code: 0,
364 }).unwrap();
365 }
366 let count: i64 = db.conn
367 .query_row("SELECT COUNT(*) FROM invocations", [], |r| r.get(0))
368 .unwrap();
369 assert_eq!(count, super::INVOCATIONS_CAP);
370 }
371
372 #[test]
373 fn history_ranking_orders_by_score() {
374 let tmp = tempfile::tempdir().unwrap();
375 let db = Db::open(tmp.path()).unwrap();
376
377 for _ in 0..5 {
379 db.record_invocation(&InvocationRecord {
380 command: "cargo".into(), subcommand: "build".into(),
381 raw_tokens: 2000, filtered_tokens: 1900,
382 had_plugin: false, exit_code: 0,
383 }).unwrap();
384 }
385 for _ in 0..10 {
387 db.record_invocation(&InvocationRecord {
388 command: "git".into(), subcommand: "status".into(),
389 raw_tokens: 30, filtered_tokens: 3,
390 had_plugin: true, exit_code: 0,
391 }).unwrap();
392 }
393
394 let ranking = db.history_ranking(10).unwrap();
395 assert_eq!(ranking.len(), 2);
396 assert_eq!(ranking[0].command, "cargo");
397 assert_eq!(ranking[0].subcommand, "build");
398 assert_eq!(ranking[1].command, "git");
399 }
400
401 #[test]
402 fn top_commands() {
403 let tmp = tempfile::tempdir().unwrap();
404 let db = Db::open(tmp.path()).unwrap();
405
406 for _ in 0..3 {
407 db.track(&TrackRecord {
408 original_cmd: "git diff".to_string(),
409 lowfat_cmd: "lowfat git diff".to_string(),
410 raw: "a".repeat(100),
411 filtered: "a".repeat(20),
412 exec_time_ms: 10,
413 project_path: "/tmp".to_string(),
414 }).unwrap();
415 }
416
417 let top = db.top_commands(10).unwrap();
418 assert_eq!(top.len(), 1);
419 assert_eq!(top[0].command, "git diff");
420 assert_eq!(top[0].runs, 3);
421 }
422}