use std::path::PathBuf;
use std::time::Instant;
use anyhow::Result;
use rusqlite::{params, Connection};
use crate::cli::compact::{estimate_tokens, format_tokens};
fn db_path() -> Result<PathBuf> {
let base =
dirs::data_dir().ok_or_else(|| anyhow::anyhow!("could not determine data directory"))?;
Ok(base.join("securegit").join("tracking.db"))
}
fn open_db() -> Result<Connection> {
let path = db_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let conn = Connection::open(&path)?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS command_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
command TEXT NOT NULL,
input_tokens INTEGER NOT NULL,
output_tokens INTEGER NOT NULL,
saved_tokens INTEGER NOT NULL,
savings_pct REAL NOT NULL,
duration_ms INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_ch_timestamp ON command_history(timestamp);
CREATE INDEX IF NOT EXISTS idx_ch_command ON command_history(command);",
)?;
Ok(conn)
}
pub fn record(command: &str, normal_output: &str, compact_output: &str, duration_ms: u64) {
let _ = record_inner(command, normal_output, compact_output, duration_ms);
}
fn record_inner(
command: &str,
normal_output: &str,
compact_output: &str,
duration_ms: u64,
) -> Result<()> {
let input_tokens = estimate_tokens(normal_output);
let output_tokens = estimate_tokens(compact_output);
let saved_tokens = input_tokens.saturating_sub(output_tokens);
let savings_pct = if input_tokens > 0 {
(saved_tokens as f64 / input_tokens as f64) * 100.0
} else {
0.0
};
let conn = open_db()?;
conn.execute(
"INSERT INTO command_history (command, input_tokens, output_tokens, saved_tokens, savings_pct, duration_ms)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![command, input_tokens, output_tokens, saved_tokens, savings_pct, duration_ms as i64],
)?;
conn.execute(
"DELETE FROM command_history WHERE timestamp < datetime('now', '-90 days')",
[],
)?;
Ok(())
}
pub struct Timer {
start: Instant,
command: String,
}
impl Timer {
pub fn start(command: impl Into<String>) -> Self {
Self {
start: Instant::now(),
command: command.into(),
}
}
pub fn elapsed_ms(&self) -> u64 {
self.start.elapsed().as_millis() as u64
}
pub fn record(&self, normal_output: &str, compact_output: &str) {
record(
&self.command,
normal_output,
compact_output,
self.elapsed_ms(),
);
}
}
pub struct GainSummary {
pub total_commands: usize,
pub total_input_tokens: usize,
pub total_output_tokens: usize,
pub total_saved_tokens: usize,
pub efficiency_pct: f64,
pub by_command: Vec<CommandStats>,
}
pub struct CommandStats {
pub command: String,
pub count: usize,
pub saved_tokens: usize,
pub avg_savings_pct: f64,
}
pub struct HistoryEntry {
pub timestamp: String,
pub command: String,
pub input_tokens: usize,
pub output_tokens: usize,
pub saved_tokens: usize,
pub savings_pct: f64,
pub duration_ms: u64,
}
pub fn get_summary() -> Result<GainSummary> {
let conn = open_db()?;
let (total_commands, total_input, total_output, total_saved): (usize, usize, usize, usize) =
conn.query_row(
"SELECT COUNT(*),
COALESCE(SUM(input_tokens), 0),
COALESCE(SUM(output_tokens), 0),
COALESCE(SUM(saved_tokens), 0)
FROM command_history",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
)?;
let efficiency_pct = if total_input > 0 {
(total_saved as f64 / total_input as f64) * 100.0
} else {
0.0
};
let mut stmt = conn.prepare(
"SELECT command,
COUNT(*) AS cnt,
SUM(saved_tokens) AS saved,
AVG(savings_pct) AS avg_pct
FROM command_history
GROUP BY command
ORDER BY saved DESC
LIMIT 10",
)?;
let by_command = stmt
.query_map([], |row| {
Ok(CommandStats {
command: row.get(0)?,
count: row.get(1)?,
saved_tokens: row.get(2)?,
avg_savings_pct: row.get(3)?,
})
})?
.filter_map(|r| r.ok())
.collect();
Ok(GainSummary {
total_commands,
total_input_tokens: total_input,
total_output_tokens: total_output,
total_saved_tokens: total_saved,
efficiency_pct,
by_command,
})
}
pub fn get_history(limit: usize) -> Result<Vec<HistoryEntry>> {
let conn = open_db()?;
let mut stmt = conn.prepare(
"SELECT timestamp, command, input_tokens, output_tokens,
saved_tokens, savings_pct, duration_ms
FROM command_history
ORDER BY id DESC
LIMIT ?1",
)?;
let entries = stmt
.query_map(params![limit], |row| {
Ok(HistoryEntry {
timestamp: row.get(0)?,
command: row.get(1)?,
input_tokens: row.get(2)?,
output_tokens: row.get(3)?,
saved_tokens: row.get(4)?,
savings_pct: row.get(5)?,
duration_ms: row.get::<_, i64>(6)? as u64,
})
})?
.filter_map(|r| r.ok())
.collect();
Ok(entries)
}
pub fn display_summary(summary: &GainSummary) {
println!("Token Savings Summary");
println!("=====================");
println!();
println!(" Total commands: {}", summary.total_commands);
println!(
" Input tokens: {}",
format_tokens(summary.total_input_tokens)
);
println!(
" Output tokens: {}",
format_tokens(summary.total_output_tokens)
);
println!(
" Tokens saved: {}",
format_tokens(summary.total_saved_tokens)
);
println!(" Efficiency: {:.1}%", summary.efficiency_pct);
if !summary.by_command.is_empty() {
println!();
println!(" Top Commands by Savings");
println!(
" {:<20} {:>6} {:>10} {:>8}",
"Command", "Count", "Saved", "Avg %"
);
println!(
" {:<20} {:>6} {:>10} {:>8}",
"-------", "-----", "-----", "-----"
);
for cs in &summary.by_command {
println!(
" {:<20} {:>6} {:>10} {:>7.1}%",
cs.command,
cs.count,
format_tokens(cs.saved_tokens),
cs.avg_savings_pct,
);
}
}
}
pub fn display_history(entries: &[HistoryEntry]) {
if entries.is_empty() {
println!("No tracking history found.");
return;
}
println!(
"{:<20} {:<16} {:>8} {:>8} {:>8} {:>7} {:>7}",
"Timestamp", "Command", "Input", "Output", "Saved", "Pct", "ms"
);
println!(
"{:<20} {:<16} {:>8} {:>8} {:>8} {:>7} {:>7}",
"---------", "-------", "-----", "------", "-----", "---", "--"
);
for e in entries {
println!(
"{:<20} {:<16} {:>8} {:>8} {:>8} {:>6.1}% {:>7}",
e.timestamp,
e.command,
format_tokens(e.input_tokens),
format_tokens(e.output_tokens),
format_tokens(e.saved_tokens),
e.savings_pct,
e.duration_ms,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_timer_start_and_elapsed() {
let timer = Timer::start("test-cmd");
std::thread::sleep(std::time::Duration::from_millis(10));
let elapsed = timer.elapsed_ms();
assert!(
elapsed > 0,
"elapsed_ms should be > 0 after sleeping, got {elapsed}"
);
}
#[test]
fn test_estimate_savings() {
let normal = "a]".repeat(100); let compact = "a".repeat(40);
let input_tokens = estimate_tokens(&normal);
let output_tokens = estimate_tokens(&compact);
let saved = input_tokens.saturating_sub(output_tokens);
let pct = if input_tokens > 0 {
(saved as f64 / input_tokens as f64) * 100.0
} else {
0.0
};
assert_eq!(input_tokens, 50);
assert_eq!(output_tokens, 10);
assert_eq!(saved, 40);
assert!(
(pct - 80.0).abs() < 0.01,
"Expected ~80% savings, got {pct}"
);
let same = "hello";
let inp = estimate_tokens(same);
let out = estimate_tokens(same);
assert_eq!(inp.saturating_sub(out), 0);
let empty_inp = estimate_tokens("");
let empty_pct = if empty_inp > 0 { 100.0 } else { 0.0 };
assert_eq!(empty_pct, 0.0);
}
#[test]
fn test_record_and_retrieve() {
let unique = format!(
"test-record-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
);
record(&unique, "normal output text here", "compact", 42);
let history = get_history(100).expect("get_history should succeed");
let found = history.iter().find(|e| e.command == unique);
assert!(found.is_some(), "recorded command should appear in history");
let entry = found.unwrap();
assert_eq!(entry.duration_ms, 42);
assert!(entry.input_tokens > 0);
assert!(entry.output_tokens > 0);
assert!(entry.saved_tokens > 0);
}
#[test]
fn test_get_summary() {
let prefix = format!(
"test-summary-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
);
record(&prefix, "aaaa bbbb cccc", "ab", 10);
record(&prefix, "dddd eeee ffff", "de", 20);
record(&prefix, "gggg hhhh iiii", "gh", 30);
let summary = get_summary().expect("get_summary should succeed");
assert!(
summary.total_commands >= 3,
"should have at least 3 commands, got {}",
summary.total_commands
);
assert!(summary.total_input_tokens > 0);
assert!(summary.total_saved_tokens > 0);
assert!(summary.efficiency_pct > 0.0);
assert!(!summary.by_command.is_empty());
}
#[test]
fn test_get_history_limit() {
for i in 0..5 {
record(
&format!("test-limit-{i}"),
"some long normal output text",
"short",
i as u64,
);
}
let history = get_history(2).expect("get_history should succeed");
assert!(
history.len() <= 2,
"limit=2 should return at most 2 entries, got {}",
history.len()
);
}
#[test]
fn test_display_summary_no_panic() {
let empty = GainSummary {
total_commands: 0,
total_input_tokens: 0,
total_output_tokens: 0,
total_saved_tokens: 0,
efficiency_pct: 0.0,
by_command: vec![],
};
display_summary(&empty);
let with_data = GainSummary {
total_commands: 100,
total_input_tokens: 50_000,
total_output_tokens: 10_000,
total_saved_tokens: 40_000,
efficiency_pct: 80.0,
by_command: vec![
CommandStats {
command: "status".into(),
count: 50,
saved_tokens: 20_000,
avg_savings_pct: 75.0,
},
CommandStats {
command: "diff".into(),
count: 30,
saved_tokens: 15_000,
avg_savings_pct: 85.0,
},
],
};
display_summary(&with_data); }
#[test]
fn test_display_history_no_panic() {
display_history(&[]);
let entries = vec![
HistoryEntry {
timestamp: "2025-01-01 12:00:00".into(),
command: "status".into(),
input_tokens: 500,
output_tokens: 100,
saved_tokens: 400,
savings_pct: 80.0,
duration_ms: 15,
},
HistoryEntry {
timestamp: "2025-01-01 12:01:00".into(),
command: "diff".into(),
input_tokens: 2000,
output_tokens: 400,
saved_tokens: 1600,
savings_pct: 80.0,
duration_ms: 42,
},
];
display_history(&entries); }
}