use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use rusqlite::{params, Connection};
use serde::Serialize;
use std::ffi::OsString;
use std::path::PathBuf;
use std::time::Instant;
fn current_project_path_string() -> String {
std::env::current_dir()
.ok()
.and_then(|p| p.canonicalize().ok())
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default()
}
fn project_filter_params(project_path: Option<&str>) -> (Option<String>, Option<String>) {
match project_path {
Some(p) => (
Some(p.to_string()),
Some(format!("{}{}*", p, std::path::MAIN_SEPARATOR)),
),
None => (None, None),
}
}
use super::constants::{DEFAULT_HISTORY_DAYS, HISTORY_DB, RTK_DATA_DIR};
pub struct Tracker {
conn: Connection,
}
#[derive(Debug)]
pub struct CommandRecord {
pub timestamp: DateTime<Utc>,
pub rtk_cmd: String,
pub saved_tokens: usize,
pub savings_pct: f64,
}
#[derive(Debug)]
pub struct GainSummary {
pub total_commands: usize,
pub total_input: usize,
pub total_output: usize,
pub total_saved: usize,
pub avg_savings_pct: f64,
pub total_time_ms: u64,
pub avg_time_ms: u64,
pub by_command: Vec<(String, usize, usize, f64, u64)>,
pub by_day: Vec<(String, usize)>,
}
#[derive(Debug, Serialize)]
pub struct DayStats {
pub date: String,
pub commands: usize,
pub input_tokens: usize,
pub output_tokens: usize,
pub saved_tokens: usize,
pub savings_pct: f64,
pub total_time_ms: u64,
pub avg_time_ms: u64,
}
#[derive(Debug, Serialize)]
pub struct WeekStats {
pub week_start: String,
pub week_end: String,
pub commands: usize,
pub input_tokens: usize,
pub output_tokens: usize,
pub saved_tokens: usize,
pub savings_pct: f64,
pub total_time_ms: u64,
pub avg_time_ms: u64,
}
#[derive(Debug, Serialize)]
pub struct MonthStats {
pub month: String,
pub commands: usize,
pub input_tokens: usize,
pub output_tokens: usize,
pub saved_tokens: usize,
pub savings_pct: f64,
pub total_time_ms: u64,
pub avg_time_ms: u64,
}
type CommandStats = (String, usize, usize, f64, u64);
impl Tracker {
pub fn new() -> Result<Self> {
let db_path = get_db_path()?;
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent)?;
}
let conn = Connection::open(&db_path)?;
let _ = conn.execute_batch(
"PRAGMA journal_mode=WAL;
PRAGMA busy_timeout=5000;",
);
conn.execute(
"CREATE TABLE IF NOT EXISTS commands (
id INTEGER PRIMARY KEY,
timestamp TEXT NOT NULL,
original_cmd TEXT NOT NULL,
rtk_cmd TEXT NOT NULL,
input_tokens INTEGER NOT NULL,
output_tokens INTEGER NOT NULL,
saved_tokens INTEGER NOT NULL,
savings_pct REAL NOT NULL
)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_timestamp ON commands(timestamp)",
[],
)?;
let _ = conn.execute(
"ALTER TABLE commands ADD COLUMN exec_time_ms INTEGER DEFAULT 0",
[],
);
let _ = conn.execute(
"ALTER TABLE commands ADD COLUMN project_path TEXT DEFAULT ''",
[],
);
let has_nulls: bool = conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM commands WHERE project_path IS NULL)",
[],
|row| row.get(0),
)
.unwrap_or(false);
if has_nulls {
let _ = conn.execute(
"UPDATE commands SET project_path = '' WHERE project_path IS NULL",
[],
);
}
let _ = conn.execute(
"CREATE INDEX IF NOT EXISTS idx_project_path_timestamp ON commands(project_path, timestamp)",
[],
);
conn.execute(
"CREATE TABLE IF NOT EXISTS parse_failures (
id INTEGER PRIMARY KEY,
timestamp TEXT NOT NULL,
raw_command TEXT NOT NULL,
error_message TEXT NOT NULL,
fallback_succeeded INTEGER NOT NULL DEFAULT 0
)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_pf_timestamp ON parse_failures(timestamp)",
[],
)?;
Ok(Self { conn })
}
#[cfg(test)]
pub fn new_in_memory() -> Result<Self> {
let conn = Connection::open_in_memory().context("Failed to open in-memory DB")?;
let tracker = Self { conn };
tracker.init_schema()?;
Ok(tracker)
}
#[cfg(test)]
fn init_schema(&self) -> Result<()> {
self.conn.execute(
"CREATE TABLE IF NOT EXISTS commands (
id INTEGER PRIMARY KEY,
timestamp TEXT NOT NULL,
original_cmd TEXT NOT NULL,
rtk_cmd TEXT NOT NULL,
input_tokens INTEGER NOT NULL,
output_tokens INTEGER NOT NULL,
saved_tokens INTEGER NOT NULL,
savings_pct REAL NOT NULL,
exec_time_ms INTEGER DEFAULT 0,
project_path TEXT DEFAULT ''
)",
[],
)?;
self.conn.execute(
"CREATE INDEX IF NOT EXISTS idx_timestamp ON commands(timestamp)",
[],
)?;
self.conn.execute(
"CREATE INDEX IF NOT EXISTS idx_project_path_timestamp ON commands(project_path, timestamp)",
[],
)?;
self.conn.execute(
"CREATE TABLE IF NOT EXISTS parse_failures (
id INTEGER PRIMARY KEY,
timestamp TEXT NOT NULL,
raw_command TEXT NOT NULL,
error_message TEXT NOT NULL,
fallback_succeeded INTEGER NOT NULL DEFAULT 0
)",
[],
)?;
self.conn.execute(
"CREATE INDEX IF NOT EXISTS idx_pf_timestamp ON parse_failures(timestamp)",
[],
)?;
Ok(())
}
pub fn record(
&self,
original_cmd: &str,
rtk_cmd: &str,
input_tokens: usize,
output_tokens: usize,
exec_time_ms: u64,
) -> Result<()> {
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
};
let project_path = current_project_path_string();
self.conn.execute(
"INSERT INTO commands (timestamp, original_cmd, rtk_cmd, project_path, input_tokens, output_tokens, saved_tokens, savings_pct, exec_time_ms)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
Utc::now().to_rfc3339(),
original_cmd,
rtk_cmd,
project_path,
input_tokens as i64,
output_tokens as i64,
saved as i64,
pct,
exec_time_ms as i64
],
)?;
self.cleanup_old()?;
Ok(())
}
fn cleanup_old(&self) -> Result<()> {
let cutoff = Utc::now() - chrono::Duration::days(DEFAULT_HISTORY_DAYS);
self.conn.execute(
"DELETE FROM commands WHERE timestamp < ?1",
params![cutoff.to_rfc3339()],
)?;
self.conn.execute(
"DELETE FROM parse_failures WHERE timestamp < ?1",
params![cutoff.to_rfc3339()],
)?;
Ok(())
}
pub fn reset_all(&self) -> Result<()> {
self.conn
.execute_batch(
"BEGIN;
DELETE FROM commands;
DELETE FROM parse_failures;
COMMIT;",
)
.context("Failed to reset tracking database")?;
Ok(())
}
pub fn record_parse_failure(
&self,
raw_command: &str,
error_message: &str,
fallback_succeeded: bool,
) -> Result<()> {
self.conn.execute(
"INSERT INTO parse_failures (timestamp, raw_command, error_message, fallback_succeeded)
VALUES (?1, ?2, ?3, ?4)",
params![
Utc::now().to_rfc3339(),
raw_command,
error_message,
fallback_succeeded as i32,
],
)?;
self.cleanup_old()?;
Ok(())
}
pub fn get_parse_failure_summary(&self) -> Result<ParseFailureSummary> {
let total: i64 = self
.conn
.query_row("SELECT COUNT(*) FROM parse_failures", [], |row| row.get(0))?;
let succeeded: i64 = self.conn.query_row(
"SELECT COUNT(*) FROM parse_failures WHERE fallback_succeeded = 1",
[],
|row| row.get(0),
)?;
let recovery_rate = if total > 0 {
(succeeded as f64 / total as f64) * 100.0
} else {
0.0
};
let mut stmt = self.conn.prepare(
"SELECT raw_command, COUNT(*) as cnt
FROM parse_failures
GROUP BY raw_command
ORDER BY cnt DESC
LIMIT 10",
)?;
let top_commands = stmt
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize))
})?
.collect::<Result<Vec<_>, _>>()?;
let mut stmt = self.conn.prepare(
"SELECT timestamp, raw_command, error_message, fallback_succeeded
FROM parse_failures
ORDER BY timestamp DESC
LIMIT 10",
)?;
let recent = stmt
.query_map([], |row| {
Ok(ParseFailureRecord {
timestamp: row.get(0)?,
raw_command: row.get(1)?,
error_message: row.get(2)?,
fallback_succeeded: row.get::<_, i32>(3)? != 0,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(ParseFailureSummary {
total: total as usize,
recovery_rate,
top_commands,
recent,
})
}
#[allow(dead_code)]
pub fn get_summary(&self) -> Result<GainSummary> {
self.get_summary_filtered(None)
}
pub fn get_summary_filtered(&self, project_path: Option<&str>) -> Result<GainSummary> {
let (project_exact, project_glob) = project_filter_params(project_path);
let mut total_commands = 0usize;
let mut total_input = 0usize;
let mut total_output = 0usize;
let mut total_saved = 0usize;
let mut total_time_ms = 0u64;
let mut stmt = self.conn.prepare(
"SELECT input_tokens, output_tokens, saved_tokens, exec_time_ms
FROM commands
WHERE (?1 IS NULL OR project_path = ?1 OR project_path GLOB ?2)",
)?;
let rows = stmt.query_map(params![project_exact, project_glob], |row| {
Ok((
row.get::<_, i64>(0)? as usize,
row.get::<_, i64>(1)? as usize,
row.get::<_, i64>(2)? as usize,
row.get::<_, i64>(3)? as u64,
))
})?;
for row in rows {
let (input, output, saved, time_ms) = row?;
total_commands += 1;
total_input += input;
total_output += output;
total_saved += saved;
total_time_ms += time_ms;
}
let avg_savings_pct = if total_input > 0 {
(total_saved as f64 / total_input as f64) * 100.0
} else {
0.0
};
let avg_time_ms = if total_commands > 0 {
total_time_ms / total_commands as u64
} else {
0
};
let by_command = self.get_by_command(project_path)?;
let by_day = self.get_by_day(project_path)?;
Ok(GainSummary {
total_commands,
total_input,
total_output,
total_saved,
avg_savings_pct,
total_time_ms,
avg_time_ms,
by_command,
by_day,
})
}
fn get_by_command(
&self,
project_path: Option<&str>,
) -> Result<Vec<CommandStats>> {
let (project_exact, project_glob) = project_filter_params(project_path);
let mut stmt = self.conn.prepare(
"SELECT rtk_cmd, COUNT(*), SUM(saved_tokens), AVG(savings_pct), AVG(exec_time_ms)
FROM commands
WHERE (?1 IS NULL OR project_path = ?1 OR project_path GLOB ?2)
GROUP BY rtk_cmd
ORDER BY SUM(saved_tokens) DESC
LIMIT 10",
)?;
let rows = stmt.query_map(params![project_exact, project_glob], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, i64>(1)? as usize,
row.get::<_, i64>(2)? as usize,
row.get::<_, f64>(3)?,
row.get::<_, f64>(4)? as u64,
))
})?;
Ok(rows.collect::<Result<Vec<_>, _>>()?)
}
fn get_by_day(
&self,
project_path: Option<&str>,
) -> Result<Vec<(String, usize)>> {
let (project_exact, project_glob) = project_filter_params(project_path);
let mut stmt = self.conn.prepare(
"SELECT DATE(timestamp), SUM(saved_tokens)
FROM commands
WHERE (?1 IS NULL OR project_path = ?1 OR project_path GLOB ?2)
GROUP BY DATE(timestamp)
ORDER BY DATE(timestamp) DESC
LIMIT 30",
)?;
let rows = stmt.query_map(params![project_exact, project_glob], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize))
})?;
let mut result: Vec<_> = rows.collect::<Result<Vec<_>, _>>()?;
result.reverse();
Ok(result)
}
pub fn get_all_days(&self) -> Result<Vec<DayStats>> {
self.get_all_days_filtered(None)
}
pub fn get_all_days_filtered(&self, project_path: Option<&str>) -> Result<Vec<DayStats>> {
let (project_exact, project_glob) = project_filter_params(project_path);
let mut stmt = self.conn.prepare(
"SELECT
DATE(timestamp) as date,
COUNT(*) as commands,
SUM(input_tokens) as input,
SUM(output_tokens) as output,
SUM(saved_tokens) as saved,
SUM(exec_time_ms) as total_time
FROM commands
WHERE (?1 IS NULL OR project_path = ?1 OR project_path GLOB ?2)
GROUP BY DATE(timestamp)
ORDER BY DATE(timestamp) DESC",
)?;
let rows = stmt.query_map(params![project_exact, project_glob], |row| {
let input = row.get::<_, i64>(2)? as usize;
let saved = row.get::<_, i64>(4)? as usize;
let commands = row.get::<_, i64>(1)? as usize;
let total_time = row.get::<_, i64>(5)? as u64;
let savings_pct = if input > 0 {
(saved as f64 / input as f64) * 100.0
} else {
0.0
};
let avg_time_ms = if commands > 0 {
total_time / commands as u64
} else {
0
};
Ok(DayStats {
date: row.get(0)?,
commands,
input_tokens: input,
output_tokens: row.get::<_, i64>(3)? as usize,
saved_tokens: saved,
savings_pct,
total_time_ms: total_time,
avg_time_ms,
})
})?;
let mut result: Vec<_> = rows.collect::<Result<Vec<_>, _>>()?;
result.reverse();
Ok(result)
}
pub fn get_by_week(&self) -> Result<Vec<WeekStats>> {
self.get_by_week_filtered(None)
}
pub fn get_by_week_filtered(&self, project_path: Option<&str>) -> Result<Vec<WeekStats>> {
let (project_exact, project_glob) = project_filter_params(project_path);
let mut stmt = self.conn.prepare(
"SELECT
DATE(timestamp, 'weekday 0', '-6 days') as week_start,
DATE(timestamp, 'weekday 0') as week_end,
COUNT(*) as commands,
SUM(input_tokens) as input,
SUM(output_tokens) as output,
SUM(saved_tokens) as saved,
SUM(exec_time_ms) as total_time
FROM commands
WHERE (?1 IS NULL OR project_path = ?1 OR project_path GLOB ?2)
GROUP BY week_start
ORDER BY week_start DESC",
)?;
let rows = stmt.query_map(params![project_exact, project_glob], |row| {
let input = row.get::<_, i64>(3)? as usize;
let saved = row.get::<_, i64>(5)? as usize;
let commands = row.get::<_, i64>(2)? as usize;
let total_time = row.get::<_, i64>(6)? as u64;
let savings_pct = if input > 0 {
(saved as f64 / input as f64) * 100.0
} else {
0.0
};
let avg_time_ms = if commands > 0 {
total_time / commands as u64
} else {
0
};
Ok(WeekStats {
week_start: row.get(0)?,
week_end: row.get(1)?,
commands,
input_tokens: input,
output_tokens: row.get::<_, i64>(4)? as usize,
saved_tokens: saved,
savings_pct,
total_time_ms: total_time,
avg_time_ms,
})
})?;
let mut result: Vec<_> = rows.collect::<Result<Vec<_>, _>>()?;
result.reverse();
Ok(result)
}
pub fn get_by_month(&self) -> Result<Vec<MonthStats>> {
self.get_by_month_filtered(None)
}
pub fn get_by_month_filtered(&self, project_path: Option<&str>) -> Result<Vec<MonthStats>> {
let (project_exact, project_glob) = project_filter_params(project_path);
let mut stmt = self.conn.prepare(
"SELECT
strftime('%Y-%m', timestamp) as month,
COUNT(*) as commands,
SUM(input_tokens) as input,
SUM(output_tokens) as output,
SUM(saved_tokens) as saved,
SUM(exec_time_ms) as total_time
FROM commands
WHERE (?1 IS NULL OR project_path = ?1 OR project_path GLOB ?2)
GROUP BY month
ORDER BY month DESC",
)?;
let rows = stmt.query_map(params![project_exact, project_glob], |row| {
let input = row.get::<_, i64>(2)? as usize;
let saved = row.get::<_, i64>(4)? as usize;
let commands = row.get::<_, i64>(1)? as usize;
let total_time = row.get::<_, i64>(5)? as u64;
let savings_pct = if input > 0 {
(saved as f64 / input as f64) * 100.0
} else {
0.0
};
let avg_time_ms = if commands > 0 {
total_time / commands as u64
} else {
0
};
Ok(MonthStats {
month: row.get(0)?,
commands,
input_tokens: input,
output_tokens: row.get::<_, i64>(3)? as usize,
saved_tokens: saved,
savings_pct,
total_time_ms: total_time,
avg_time_ms,
})
})?;
let mut result: Vec<_> = rows.collect::<Result<Vec<_>, _>>()?;
result.reverse();
Ok(result)
}
#[allow(dead_code)]
pub fn get_recent(&self, limit: usize) -> Result<Vec<CommandRecord>> {
self.get_recent_filtered(limit, None)
}
pub fn get_recent_filtered(
&self,
limit: usize,
project_path: Option<&str>,
) -> Result<Vec<CommandRecord>> {
let (project_exact, project_glob) = project_filter_params(project_path);
let mut stmt = self.conn.prepare(
"SELECT timestamp, rtk_cmd, saved_tokens, savings_pct
FROM commands
WHERE (?1 IS NULL OR project_path = ?1 OR project_path GLOB ?2)
ORDER BY timestamp DESC
LIMIT ?3",
)?;
let rows = stmt.query_map(
params![project_exact, project_glob, limit as i64],
|row| {
Ok(CommandRecord {
timestamp: DateTime::parse_from_rfc3339(&row.get::<_, String>(0)?)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now()),
rtk_cmd: row.get(1)?,
saved_tokens: row.get::<_, i64>(2)? as usize,
savings_pct: row.get(3)?,
})
},
)?;
Ok(rows.collect::<Result<Vec<_>, _>>()?)
}
pub fn count_commands_since(&self, since: chrono::DateTime<chrono::Utc>) -> Result<i64> {
let ts = since.format("%Y-%m-%dT%H:%M:%S").to_string();
let count: i64 = self.conn.query_row(
"SELECT COUNT(*) FROM commands WHERE timestamp >= ?1",
params![ts],
|row| row.get(0),
)?;
Ok(count)
}
pub fn top_commands(&self, limit: usize) -> Result<Vec<String>> {
let mut stmt = self.conn.prepare(
"SELECT rtk_cmd, COUNT(*) as cnt FROM commands
GROUP BY rtk_cmd ORDER BY cnt DESC LIMIT ?1",
)?;
let rows = stmt.query_map(params![limit as i64], |row| {
let cmd: String = row.get(0)?;
Ok(cmd.split_whitespace().nth(1).unwrap_or(&cmd).to_string())
})?;
Ok(rows.filter_map(|r| r.ok()).collect())
}
pub fn overall_savings_pct(&self) -> Result<f64> {
let (total_input, total_saved): (i64, i64) = self.conn.query_row(
"SELECT COALESCE(SUM(input_tokens), 0), COALESCE(SUM(saved_tokens), 0) FROM commands",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
if total_input > 0 {
Ok((total_saved as f64 / total_input as f64) * 100.0)
} else {
Ok(0.0)
}
}
pub fn total_tokens_saved(&self) -> Result<i64> {
let saved: i64 = self.conn.query_row(
"SELECT COALESCE(SUM(saved_tokens), 0) FROM commands",
[],
|row| row.get(0),
)?;
Ok(saved)
}
pub fn tokens_saved_24h(&self, since: chrono::DateTime<chrono::Utc>) -> Result<i64> {
let ts = since.format("%Y-%m-%dT%H:%M:%S").to_string();
let saved: i64 = self.conn.query_row(
"SELECT COALESCE(SUM(saved_tokens), 0) FROM commands WHERE timestamp >= ?1",
params![ts],
|row| row.get(0),
)?;
Ok(saved)
}
pub fn top_passthrough(&self, limit: usize) -> Result<Vec<(String, i64)>> {
let mut stmt = self.conn.prepare(
"SELECT TRIM(SUBSTR(original_cmd, 1, INSTR(original_cmd || ' ', ' ') - 1)) as tool,
COUNT(*) as cnt FROM commands
WHERE input_tokens = 0 AND output_tokens = 0
GROUP BY tool ORDER BY cnt DESC LIMIT ?1",
)?;
let rows = stmt.query_map(params![limit as i64], |row| {
let cmd: String = row.get(0)?;
let count: i64 = row.get(1)?;
Ok((cmd, count))
})?;
Ok(rows.filter_map(|r| r.ok()).collect())
}
pub fn parse_failures_since(&self, since: chrono::DateTime<chrono::Utc>) -> Result<i64> {
let ts = since.format("%Y-%m-%dT%H:%M:%S").to_string();
let count: i64 = self.conn.query_row(
"SELECT COUNT(*) FROM parse_failures WHERE timestamp >= ?1",
params![ts],
|row| row.get(0),
)?;
Ok(count)
}
pub fn low_savings_commands(&self, limit: usize) -> Result<Vec<(String, f64)>> {
let mut stmt = self.conn.prepare(
"SELECT rtk_cmd, AVG(savings_pct) as avg_sav FROM commands
WHERE input_tokens > 0
GROUP BY rtk_cmd
HAVING avg_sav < 30.0 AND avg_sav > 0.0
ORDER BY COUNT(*) DESC LIMIT ?1",
)?;
let rows = stmt.query_map(params![limit as i64], |row| {
let cmd: String = row.get(0)?;
let sav: f64 = row.get(1)?;
let short = cmd.split_whitespace().take(3).collect::<Vec<_>>().join(" ");
Ok((short, sav))
})?;
Ok(rows.filter_map(|r| r.ok()).collect())
}
pub fn avg_savings_per_command(&self) -> Result<f64> {
let avg: f64 = self.conn.query_row(
"SELECT COALESCE(AVG(avg_sav), 0.0) FROM (
SELECT rtk_cmd, AVG(savings_pct) as avg_sav
FROM commands WHERE input_tokens > 0
GROUP BY rtk_cmd
)",
[],
|row| row.get(0),
)?;
Ok(avg)
}
pub fn count_meta_command(&self, name: &str) -> Result<i64> {
let pattern = format!("rtk {}", name);
let count: i64 = self.conn.query_row(
"SELECT COUNT(*) FROM commands WHERE rtk_cmd LIKE ?1 || '%'",
params![pattern],
|row| row.get(0),
)?;
Ok(count)
}
pub fn first_seen_days(&self) -> Result<i64> {
let oldest: Option<String> =
match self
.conn
.query_row("SELECT MIN(timestamp) FROM commands", [], |row| row.get(0))
{
Ok(v) => v,
Err(rusqlite::Error::QueryReturnedNoRows) => None,
Err(e) => return Err(anyhow::anyhow!("Failed to query first seen timestamp: {e}")),
};
match oldest {
Some(ts) => {
let first = chrono::NaiveDateTime::parse_from_str(&ts, "%Y-%m-%dT%H:%M:%S")
.or_else(|_| chrono::NaiveDateTime::parse_from_str(&ts, "%Y-%m-%d %H:%M:%S"))
.map(|dt| dt.and_utc())
.unwrap_or_else(|_| chrono::Utc::now());
let days = (chrono::Utc::now() - first).num_days();
Ok(days.max(0))
}
None => Ok(0),
}
}
pub fn active_days_30d(&self) -> Result<i64> {
let since = (chrono::Utc::now() - chrono::Duration::days(30))
.format("%Y-%m-%dT%H:%M:%S")
.to_string();
let count: i64 = self.conn.query_row(
"SELECT COUNT(DISTINCT DATE(timestamp)) FROM commands WHERE timestamp >= ?1",
params![since],
|row| row.get(0),
)?;
Ok(count)
}
pub fn commands_total(&self) -> Result<i64> {
let count: i64 = self
.conn
.query_row("SELECT COUNT(*) FROM commands", [], |row| row.get(0))?;
Ok(count)
}
pub fn ecosystem_mix(&self) -> Result<Vec<(String, f64)>> {
let total: f64 = self.conn.query_row(
"SELECT COUNT(*) FROM commands WHERE input_tokens > 0 AND timestamp >= datetime('now', '-90 days')",
[],
|row| row.get(0),
)?;
if total == 0.0 {
return Ok(vec![]);
}
let mut stmt = self.conn.prepare(
"SELECT rtk_cmd, COUNT(*) as cnt FROM commands
WHERE input_tokens > 0 AND timestamp >= datetime('now', '-90 days')
GROUP BY rtk_cmd ORDER BY cnt DESC",
)?;
let mut categories: std::collections::HashMap<String, f64> =
std::collections::HashMap::new();
let rows = stmt.query_map([], |row| {
let cmd: String = row.get(0)?;
let cnt: f64 = row.get(1)?;
Ok((cmd, cnt))
})?;
for row in rows.flatten() {
let cat = categorize_command(&row.0);
*categories.entry(cat).or_default() += row.1;
}
let mut result: Vec<(String, f64)> = categories
.into_iter()
.map(|(cat, cnt)| (cat, (cnt / total * 100.0).round()))
.collect();
result.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
result.truncate(8);
Ok(result)
}
pub fn tokens_saved_30d(&self) -> Result<i64> {
let since = (chrono::Utc::now() - chrono::Duration::days(30))
.format("%Y-%m-%dT%H:%M:%S")
.to_string();
let saved: i64 = self.conn.query_row(
"SELECT COALESCE(SUM(saved_tokens), 0) FROM commands WHERE timestamp >= ?1",
params![since],
|row| row.get(0),
)?;
Ok(saved)
}
pub fn projects_count(&self) -> Result<i64> {
let count: i64 = self.conn.query_row(
"SELECT COUNT(DISTINCT project_path) FROM commands WHERE project_path != ''",
[],
|row| row.get(0),
)?;
Ok(count)
}
}
fn categorize_command(rtk_cmd: &str) -> String {
let parts: Vec<&str> = rtk_cmd.split_whitespace().collect();
let tool = parts.get(1).copied().unwrap_or("other");
match tool {
"git" | "gh" | "gt" => "git",
"cargo" => "cargo",
"npm" | "npx" | "pnpm" | "vitest" | "tsc" | "lint" | "prettier" | "next" | "playwright"
| "prisma" => "js",
"pytest" | "ruff" | "mypy" | "pip" => "python",
"go" | "golangci-lint" => "go",
"docker" | "kubectl" => "cloud",
"rspec" | "rubocop" | "rake" => "ruby",
"dotnet" => "dotnet",
"ls" | "tree" | "grep" | "find" | "wc" | "read" | "env" | "json" | "log" | "smart"
| "diff" | "deps" | "summary" | "format" => "system",
_ => "other",
}
.to_string()
}
fn get_db_path() -> Result<PathBuf> {
if let Ok(custom_path) = std::env::var("RTK_DB_PATH") {
return Ok(PathBuf::from(custom_path));
}
if let Ok(config) = crate::core::config::Config::load() {
if let Some(db_path) = config.tracking.database_path {
return Ok(db_path);
}
}
let data_dir = dirs::data_local_dir().unwrap_or_else(|| PathBuf::from("."));
Ok(data_dir.join(RTK_DATA_DIR).join(HISTORY_DB))
}
#[derive(Debug)]
pub struct ParseFailureRecord {
pub timestamp: String,
pub raw_command: String,
#[allow(dead_code)]
pub error_message: String,
pub fallback_succeeded: bool,
}
#[derive(Debug)]
pub struct ParseFailureSummary {
pub total: usize,
pub recovery_rate: f64,
pub top_commands: Vec<(String, usize)>,
pub recent: Vec<ParseFailureRecord>,
}
pub fn record_parse_failure_silent(raw_command: &str, error_message: &str, succeeded: bool) {
if let Ok(tracker) = Tracker::new() {
let _ = tracker.record_parse_failure(raw_command, error_message, succeeded);
}
}
pub fn estimate_tokens(text: &str) -> usize {
(text.len() as f64 / 4.0).ceil() as usize
}
pub struct TimedExecution {
start: Instant,
}
impl TimedExecution {
pub fn start() -> Self {
Self {
start: Instant::now(),
}
}
pub fn track(&self, original_cmd: &str, rtk_cmd: &str, input: &str, output: &str) {
let elapsed_ms = self.start.elapsed().as_millis() as u64;
let input_tokens = estimate_tokens(input);
let output_tokens = estimate_tokens(output);
if let Ok(tracker) = Tracker::new() {
let _ = tracker.record(
original_cmd,
rtk_cmd,
input_tokens,
output_tokens,
elapsed_ms,
);
}
}
pub fn track_passthrough(&self, original_cmd: &str, rtk_cmd: &str) {
let elapsed_ms = self.start.elapsed().as_millis() as u64;
if let Ok(tracker) = Tracker::new() {
let _ = tracker.record(original_cmd, rtk_cmd, 0, 0, elapsed_ms);
}
}
}
pub fn args_display(args: &[OsString]) -> String {
args.iter()
.map(|a| a.to_string_lossy())
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_estimate_tokens() {
assert_eq!(estimate_tokens(""), 0);
assert_eq!(estimate_tokens("abcd"), 1);
assert_eq!(estimate_tokens("abcde"), 2);
assert_eq!(estimate_tokens("a"), 1);
assert_eq!(estimate_tokens("12345678"), 2);
}
#[test]
fn test_args_display() {
let args = vec![OsString::from("status"), OsString::from("--short")];
assert_eq!(args_display(&args), "status --short");
assert_eq!(args_display(&[]), "");
let single = vec![OsString::from("log")];
assert_eq!(args_display(&single), "log");
}
#[test]
fn test_tracker_record_and_recent() {
let tracker = Tracker::new().expect("Failed to create tracker");
let test_cmd = format!("rtk git status test_{}", std::process::id());
tracker
.record("git status", &test_cmd, 100, 20, 50)
.expect("Failed to record");
let recent = tracker.get_recent(10).expect("Failed to get recent");
let test_record = recent
.iter()
.find(|r| r.rtk_cmd == test_cmd)
.expect("Test record not found in recent commands");
assert_eq!(test_record.saved_tokens, 80);
assert_eq!(test_record.savings_pct, 80.0);
}
#[test]
fn test_track_passthrough_no_dilution() {
let tracker = Tracker::new().expect("Failed to create tracker");
let pid = std::process::id();
let cmd1 = format!("rtk cmd1_test_{}", pid);
let cmd2 = format!("rtk cmd2_passthrough_test_{}", pid);
tracker
.record("cmd1", &cmd1, 1000, 200, 10)
.expect("Failed to record cmd1");
tracker
.record("cmd2", &cmd2, 0, 0, 5)
.expect("Failed to record passthrough");
let recent = tracker.get_recent(20).expect("Failed to get recent");
let record1 = recent
.iter()
.find(|r| r.rtk_cmd == cmd1)
.expect("cmd1 record not found");
let record2 = recent
.iter()
.find(|r| r.rtk_cmd == cmd2)
.expect("passthrough record not found");
assert_eq!(record1.saved_tokens, 800);
assert_eq!(record1.savings_pct, 80.0);
assert_eq!(record2.saved_tokens, 0);
assert_eq!(record2.savings_pct, 0.0);
}
#[test]
fn test_timed_execution_records_time() {
let timer = TimedExecution::start();
std::thread::sleep(std::time::Duration::from_millis(10));
timer.track("test cmd", "rtk test", "raw input data", "filtered");
let tracker = Tracker::new().expect("Failed to create tracker");
let recent = tracker.get_recent(5).expect("Failed to get recent");
assert!(recent.iter().any(|r| r.rtk_cmd == "rtk test"));
}
#[test]
fn test_timed_execution_passthrough() {
let timer = TimedExecution::start();
timer.track_passthrough("git tag", "rtk git tag (passthrough)");
let tracker = Tracker::new().expect("Failed to create tracker");
let recent = tracker.get_recent(5).expect("Failed to get recent");
let pt = recent
.iter()
.find(|r| r.rtk_cmd.contains("passthrough"))
.expect("Passthrough record not found");
assert_eq!(pt.savings_pct, 0.0);
assert_eq!(pt.saved_tokens, 0);
}
#[test]
fn test_db_path_env_and_default() {
use std::env;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
let _guard = ENV_LOCK.lock().unwrap();
let custom_path = env::temp_dir().join("rtk_test_custom.db");
env::set_var("RTK_DB_PATH", &custom_path);
let db_path = get_db_path().expect("Failed to get db path");
assert_eq!(db_path, custom_path);
env::remove_var("RTK_DB_PATH");
let db_path = get_db_path().expect("Failed to get db path");
assert!(
db_path.ends_with("rtk/history.db"),
"expected default path ending with rtk/history.db, got: {}",
db_path.display()
);
}
#[test]
fn test_project_filter_params_glob_pattern() {
let (exact, glob) = project_filter_params(Some("/home/user/project"));
assert_eq!(exact.unwrap(), "/home/user/project");
let glob_val = glob.unwrap();
assert!(glob_val.ends_with('*'), "GLOB pattern must end with *");
assert!(!glob_val.contains('%'), "Must not contain LIKE wildcard %");
assert_eq!(
glob_val,
format!("/home/user/project{}*", std::path::MAIN_SEPARATOR)
);
}
#[test]
fn test_project_filter_params_none() {
let (exact, glob) = project_filter_params(None);
assert!(exact.is_none());
assert!(glob.is_none());
}
#[test]
fn test_project_filter_params_underscore_safe() {
let (exact, glob) = project_filter_params(Some("/home/user/my_project"));
assert_eq!(exact.unwrap(), "/home/user/my_project");
let glob_val = glob.unwrap();
assert!(glob_val.contains("my_project"));
assert_eq!(
glob_val,
format!("/home/user/my_project{}*", std::path::MAIN_SEPARATOR)
);
}
#[test]
fn test_parse_failure_roundtrip() {
let tracker = Tracker::new().expect("Failed to create tracker");
let test_cmd = format!("git -C /path status test_{}", std::process::id());
tracker
.record_parse_failure(&test_cmd, "unrecognized subcommand", true)
.expect("Failed to record parse failure");
let summary = tracker
.get_parse_failure_summary()
.expect("Failed to get summary");
assert!(summary.total >= 1);
assert!(summary.recent.iter().any(|r| r.raw_command == test_cmd));
}
#[test]
fn test_parse_failure_recovery_rate() {
let tracker = Tracker::new().expect("Failed to create tracker");
let pid = std::process::id();
tracker
.record_parse_failure(&format!("cmd_ok1_{}", pid), "err", true)
.unwrap();
tracker
.record_parse_failure(&format!("cmd_ok2_{}", pid), "err", true)
.unwrap();
tracker
.record_parse_failure(&format!("cmd_fail_{}", pid), "err", false)
.unwrap();
let summary = tracker.get_parse_failure_summary().unwrap();
assert!(summary.recovery_rate >= 0.0 && summary.recovery_rate <= 100.0);
}
#[test]
fn test_reset_all_clears_both_tables() {
let tracker = Tracker::new_in_memory().expect("Failed to create in-memory tracker");
let pid = std::process::id();
tracker
.record(
"git status",
&format!("rtk git status reset_test_{}", pid),
100,
20,
50,
)
.expect("Failed to record command");
tracker
.record_parse_failure(&format!("bad_cmd_reset_test_{}", pid), "parse error", false)
.expect("Failed to record parse failure");
tracker.reset_all().expect("Failed to reset");
let summary = tracker.get_summary().expect("Failed to get summary");
assert_eq!(
summary.total_commands, 0,
"commands table should be empty after reset"
);
let failures = tracker
.get_parse_failure_summary()
.expect("Failed to get failure summary");
assert_eq!(
failures.total, 0,
"parse_failures table should be empty after reset"
);
}
}