1use anyhow::{Context, Result};
22use rusqlite::Connection;
23use std::path::PathBuf;
24use std::sync::Mutex;
25use std::time::Duration;
26
27use crate::display::*;
28
29pub struct Tracker {
43 conn: Mutex<Connection>,
44}
45
46impl Tracker {
47 pub fn new(db_path: &str) -> Result<Self> {
56 let expanded = expand_path(db_path);
57 if let Some(parent) = expanded.parent() {
58 std::fs::create_dir_all(parent)
59 .context("Failed to create tracking database directory")?;
60 }
61 let conn = Connection::open(&expanded).context("Failed to open tracking database")?;
62 conn.busy_timeout(Duration::from_secs(5))?;
63 conn.execute_batch("PRAGMA journal_mode=WAL;")?;
64 conn.execute_batch(
65 "CREATE TABLE IF NOT EXISTS tool_calls (
66 id INTEGER PRIMARY KEY AUTOINCREMENT,
67 timestamp TEXT DEFAULT (datetime('now')),
68 tool_name TEXT NOT NULL,
69 input_bytes INTEGER NOT NULL,
70 output_bytes INTEGER NOT NULL,
71 saved_bytes INTEGER NOT NULL,
72 savings_pct REAL NOT NULL
73 );
74 CREATE INDEX IF NOT EXISTS idx_tool_calls_timestamp ON tool_calls(timestamp);
75 CREATE INDEX IF NOT EXISTS idx_tool_calls_tool ON tool_calls(tool_name);",
76 )
77 .context("Failed to initialize tracking tables")?;
78
79 let has_preset: bool = conn
81 .prepare("SELECT preset FROM tool_calls LIMIT 0")
82 .is_ok();
83 if !has_preset {
84 conn.execute_batch(
85 "ALTER TABLE tool_calls ADD COLUMN preset TEXT NOT NULL DEFAULT 'unknown';",
86 )
87 .context("Failed to add preset column")?;
88 }
89
90 Ok(Self {
91 conn: Mutex::new(conn),
92 })
93 }
94
95 pub fn track(
103 &self,
104 tool_name: &str,
105 raw_output: &str,
106 filtered_output: &str,
107 preset: &str,
108 ) -> Result<()> {
109 let input_bytes = raw_output.len() as i64;
110 let output_bytes = filtered_output.len() as i64;
111 let saved_bytes = (input_bytes - output_bytes).max(0);
114 let savings_pct = if input_bytes > 0 {
115 (saved_bytes as f64 / input_bytes as f64) * 100.0
116 } else {
117 0.0
118 };
119
120 let conn = self
121 .conn
122 .lock()
123 .map_err(|e| anyhow::anyhow!("lock poisoned: {e}"))?;
124 conn.execute(
125 "INSERT INTO tool_calls (tool_name, input_bytes, output_bytes, saved_bytes, savings_pct, preset)
126 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
127 rusqlite::params![tool_name, input_bytes, output_bytes, saved_bytes, savings_pct, preset],
128 )?;
129
130 Ok(())
131 }
132
133 pub fn print_stats(&self) -> Result<()> {
142 let conn = self
143 .conn
144 .lock()
145 .map_err(|e| anyhow::anyhow!("lock poisoned: {e}"))?;
146
147 let mut stmt = conn.prepare(
149 "SELECT
150 preset,
151 tool_name,
152 COUNT(*) as calls,
153 SUM(input_bytes) as total_input,
154 SUM(output_bytes) as total_output,
155 SUM(saved_bytes) as total_saved,
156 AVG(savings_pct) as avg_pct
157 FROM tool_calls
158 GROUP BY preset, tool_name
159 ORDER BY preset, total_saved DESC",
160 )?;
161
162 struct ToolRow {
163 preset: String,
164 name: String,
165 calls: i64,
166 saved: i64,
167 avg_pct: f64,
168 }
169
170 let rows: Vec<ToolRow> = stmt
171 .query_map([], |row| {
172 Ok(ToolRow {
173 preset: row.get(0)?,
174 name: row.get(1)?,
175 calls: row.get(2)?,
176 saved: row.get(5)?,
177 avg_pct: row.get(6)?,
178 })
179 })?
180 .filter_map(|r| r.ok())
181 .collect();
182
183 let grand_calls: i64 = rows.iter().map(|r| r.calls).sum();
184 let grand_input: i64 = conn.query_row(
185 "SELECT COALESCE(SUM(input_bytes), 0) FROM tool_calls",
186 [],
187 |row| row.get(0),
188 )?;
189 let grand_saved: i64 = rows.iter().map(|r| r.saved).sum();
190 let grand_output = grand_input - grand_saved;
191 let grand_pct = if grand_input > 0 {
192 (grand_saved as f64 / grand_input as f64) * 100.0
193 } else {
194 0.0
195 };
196
197 let saved_tokens = grand_saved / 4;
198
199 println!();
201 println!(" {BOLD}{GREEN}MCP-RTK{RESET}{DIM} - Token Savings{RESET}");
202 println!(" {DIM}{}{RESET}", "─".repeat(56));
203 println!();
204
205 println!(
207 " {DIM}Calls{RESET} {BOLD}{WHITE}{:<12}{RESET} {DIM}Input{RESET} {WHITE}{} tokens{RESET}",
208 grand_calls,
209 format_number(grand_input / 4),
210 );
211 println!(
212 " {DIM}Saved{RESET} {BOLD}{GREEN}{:<12}{RESET} {DIM}Output{RESET} {WHITE}{} tokens{RESET}",
213 format!("{} ({:.0}%)", format_number(saved_tokens), grand_pct),
214 format_number(grand_output / 4),
215 );
216 println!();
217
218 let bar_width: usize = 40;
220 let bar = render_block_bar(grand_pct / 100.0, bar_width);
221 let pct_color = pct_to_color(grand_pct);
222 println!(" {bar} {pct_color}{BOLD}{:.1}%{RESET}", grand_pct);
223 println!();
224
225 if rows.is_empty() {
227 println!(" {DIM}No tool calls recorded yet.{RESET}");
228 println!();
229 return Ok(());
230 }
231
232 let mut seen = std::collections::HashSet::new();
234 let mut presets: Vec<String> = Vec::new();
235 for row in &rows {
236 if seen.insert(row.preset.clone()) {
237 presets.push(row.preset.clone());
238 }
239 }
240
241 let max_saved = rows.iter().map(|r| r.saved).max().unwrap_or(1).max(1);
242
243 for preset in &presets {
244 let preset_rows: Vec<&ToolRow> = rows.iter().filter(|r| &r.preset == preset).collect();
245 let preset_saved: i64 = preset_rows.iter().map(|r| r.saved).sum();
246 let preset_calls: i64 = preset_rows.iter().map(|r| r.calls).sum();
247
248 println!(
249 " {DIM}─── {RESET}{BOLD}{}{RESET}{DIM} ({} calls, {} saved) {}─{RESET}",
250 preset,
251 preset_calls,
252 format_tokens(preset_saved),
253 "─".repeat(30usize.saturating_sub(preset.len())),
254 );
255 println!();
256 println!(
257 " {DIM}{:<28} {:>5} {:>8} {:>5}{RESET}",
258 "Tool", "Count", "Saved", "Avg%"
259 );
260 println!();
261
262 for row in &preset_rows {
263 let pct_color = pct_to_color(row.avg_pct);
264 let bar_ratio = row.saved as f64 / max_saved as f64;
265 let inline_bar = render_block_bar(bar_ratio, 16);
266
267 println!(
268 " {BOLD}{WHITE}{:<28}{RESET} {:>5} {:>8} {pct_color}{:>4.0}%{RESET} {inline_bar}",
269 truncate_name(&row.name, 28),
270 row.calls,
271 format_tokens(row.saved),
272 row.avg_pct,
273 );
274 }
275
276 println!();
277 }
278
279 println!();
280 Ok(())
281 }
282
283 pub fn print_history(&self) -> Result<()> {
289 let conn = self
290 .conn
291 .lock()
292 .map_err(|e| anyhow::anyhow!("lock poisoned: {e}"))?;
293 let mut stmt = conn.prepare(
294 "SELECT timestamp, tool_name, input_bytes, output_bytes, savings_pct, preset
295 FROM tool_calls
296 ORDER BY timestamp DESC
297 LIMIT 50",
298 )?;
299
300 let rows: Vec<(String, String, i64, i64, f64, String)> = stmt
301 .query_map([], |row| {
302 Ok((
303 row.get::<_, String>(0)?,
304 row.get::<_, String>(1)?,
305 row.get::<_, i64>(2)?,
306 row.get::<_, i64>(3)?,
307 row.get::<_, f64>(4)?,
308 row.get::<_, String>(5)?,
309 ))
310 })?
311 .filter_map(|r| r.ok())
312 .collect();
313
314 println!();
315 println!(" {BOLD}{GREEN}MCP-RTK{RESET}{DIM} ── Recent Calls{RESET}");
316 println!(" {DIM}{}{RESET}", "─".repeat(76));
317 println!();
318
319 if rows.is_empty() {
320 println!(" {DIM}No tool calls recorded yet.{RESET}");
321 println!();
322 return Ok(());
323 }
324
325 println!(
326 " {DIM}{:<19} {:<8} {:<22} {:>7} {:>7} {:>6}{RESET}",
327 "Timestamp", "Preset", "Tool", "In", "Out", "Saved"
328 );
329 println!();
330
331 for (ts, name, input, output, pct, preset) in &rows {
332 let pct_color = pct_to_color(*pct);
333 let saved_bytes = input - output;
334
335 println!(
336 " {DIM}{:<19}{RESET} {YELLOW}{:<8}{RESET} {WHITE}{:<22}{RESET} {:>7} {:>7} {pct_color}{BOLD}{:>5.0}%{RESET} {DIM}{}{RESET}",
337 ts.get(..19).unwrap_or(ts),
338 truncate_name(preset, 8),
339 truncate_name(name, 22),
340 format_tokens(*input),
341 format_tokens(*output),
342 pct,
343 if saved_bytes > 0 {
344 format!("-{} tk", format_tokens(saved_bytes))
345 } else {
346 String::new()
347 },
348 );
349 }
350
351 println!();
352 Ok(())
353 }
354
355 pub fn stats_as_json(&self) -> Result<serde_json::Value> {
361 let conn = self
362 .conn
363 .lock()
364 .map_err(|e| anyhow::anyhow!("lock poisoned: {e}"))?;
365
366 let (total_calls, total_input, total_output, total_saved): (i64, i64, i64, i64) =
368 conn.query_row(
369 "SELECT COUNT(*), COALESCE(SUM(input_bytes),0), COALESCE(SUM(output_bytes),0), COALESCE(SUM(saved_bytes),0) FROM tool_calls",
370 [],
371 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
372 )?;
373
374 let grand_pct = if total_input > 0 {
375 (total_saved as f64 / total_input as f64) * 100.0
376 } else {
377 0.0
378 };
379
380 let mut stmt = conn.prepare(
382 "SELECT preset, tool_name, COUNT(*) as calls, SUM(input_bytes), SUM(output_bytes), SUM(saved_bytes), AVG(savings_pct)
383 FROM tool_calls GROUP BY preset, tool_name ORDER BY preset, SUM(saved_bytes) DESC",
384 )?;
385
386 let rows: Vec<(String, String, i64, i64, i64, i64, f64)> = stmt
387 .query_map([], |row| {
388 Ok((
389 row.get(0)?,
390 row.get(1)?,
391 row.get(2)?,
392 row.get(3)?,
393 row.get(4)?,
394 row.get(5)?,
395 row.get(6)?,
396 ))
397 })?
398 .filter_map(|r| r.ok())
399 .collect();
400
401 let mut presets_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
403 for (preset, tool, calls, input, output, saved, avg_pct) in &rows {
404 let preset_entry = presets_map
405 .entry(preset.clone())
406 .or_insert_with(|| {
407 serde_json::json!({"calls": 0, "input_bytes": 0, "output_bytes": 0, "saved_bytes": 0, "tools": {}})
408 });
409 let preset_obj = preset_entry.as_object_mut().unwrap();
410 *preset_obj.get_mut("calls").unwrap() =
411 serde_json::json!(preset_obj["calls"].as_i64().unwrap() + calls);
412 *preset_obj.get_mut("input_bytes").unwrap() =
413 serde_json::json!(preset_obj["input_bytes"].as_i64().unwrap() + input);
414 *preset_obj.get_mut("output_bytes").unwrap() =
415 serde_json::json!(preset_obj["output_bytes"].as_i64().unwrap() + output);
416 *preset_obj.get_mut("saved_bytes").unwrap() =
417 serde_json::json!(preset_obj["saved_bytes"].as_i64().unwrap() + saved);
418
419 let tools = preset_obj
420 .get_mut("tools")
421 .unwrap()
422 .as_object_mut()
423 .unwrap();
424 tools.insert(
425 tool.clone(),
426 serde_json::json!({
427 "calls": calls,
428 "input_bytes": input,
429 "output_bytes": output,
430 "saved_bytes": saved,
431 "avg_savings_pct": (avg_pct * 10.0).round() / 10.0,
432 }),
433 );
434 }
435
436 let output = serde_json::json!({
437 "total_calls": total_calls,
438 "total_input_bytes": total_input,
439 "total_output_bytes": total_output,
440 "total_saved_bytes": total_saved,
441 "total_input_tokens": total_input / 4,
442 "total_output_tokens": total_output / 4,
443 "total_saved_tokens": total_saved / 4,
444 "savings_pct": (grand_pct * 10.0).round() / 10.0,
445 "presets": presets_map,
446 });
447
448 Ok(output)
449 }
450
451 pub fn export_json(&self) -> Result<()> {
457 let output = self.stats_as_json()?;
458 println!("{}", serde_json::to_string_pretty(&output).unwrap());
459 Ok(())
460 }
461
462 pub fn tracked_presets(&self) -> Result<std::collections::HashSet<String>> {
466 let conn = self
467 .conn
468 .lock()
469 .map_err(|e| anyhow::anyhow!("lock poisoned: {e}"))?;
470 let mut stmt =
471 conn.prepare("SELECT DISTINCT preset FROM tool_calls WHERE preset != 'unknown'")?;
472 let presets: std::collections::HashSet<String> = stmt
473 .query_map([], |row| row.get::<_, String>(0))?
474 .filter_map(|r| r.ok())
475 .collect();
476 Ok(presets)
477 }
478}
479
480fn expand_path(path: &str) -> PathBuf {
482 if let Some(rest) = path.strip_prefix("~/") {
483 if let Ok(home) = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) {
484 return PathBuf::from(home).join(rest);
485 }
486 }
487 PathBuf::from(path)
488}