diff --git a/src/gain.rs b/src/gain.rs index f715296..0f759ec 100644 --- a/src/gain.rs +++ b/src/gain.rs @@ -16,10 +16,15 @@ pub fn run( monthly: bool, all: bool, format: &str, + failures: bool, _verbose: u8, ) -> Result<()> { let tracker = Tracker::new().context("Failed to initialize tracking database")?; + if failures { + return show_failures(&tracker); + } + // Handle export formats match format { "json" => return export_json(&tracker, daily, weekly, monthly, all), @@ -516,3 +521,59 @@ fn export_csv( Ok(()) } + +fn show_failures(tracker: &Tracker) -> Result<()> { + let summary = tracker + .get_parse_failure_summary() + .context("Failed to load parse failure data")?; + + if summary.total == 0 { + println!("No parse failures recorded."); + println!("This means all commands parsed successfully (or fallback hasn't triggered yet)."); + return Ok(()); + } + + println!("{}", styled("RTK Parse Failures", true)); + println!("{}", "═".repeat(60)); + println!(); + + print_kpi("Total failures", summary.total.to_string()); + print_kpi("Recovery rate", format!("{:.1}%", summary.recovery_rate)); + println!(); + + if !summary.top_commands.is_empty() { + println!("{}", styled("Top Commands (by frequency)", true)); + println!("{}", "─".repeat(60)); + for (cmd, count) in &summary.top_commands { + let cmd_display = if cmd.len() > 50 { + format!("{}...", &cmd[..47]) + } else { + cmd.clone() + }; + println!(" {:>4}x {}", count, cmd_display); + } + println!(); + } + + if !summary.recent.is_empty() { + println!("{}", styled("Recent Failures (last 10)", true)); + println!("{}", "─".repeat(60)); + for rec in &summary.recent { + let ts_short = if rec.timestamp.len() >= 16 { + &rec.timestamp[..16] + } else { + &rec.timestamp + }; + let status = if rec.fallback_succeeded { "ok" } else { "FAIL" }; + let cmd_display = if rec.raw_command.len() > 40 { + format!("{}...", &rec.raw_command[..37]) + } else { + rec.raw_command.clone() + }; + println!(" {} [{}] {}", ts_short, status, cmd_display); + } + println!(); + } + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 0e58173..1cbb1c5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -48,6 +48,7 @@ mod vitest_cmd; mod wget_cmd; use anyhow::{Context, Result}; +use clap::error::ErrorKind; use clap::{Parser, Subcommand}; use std::ffi::OsString; use std::path::{Path, PathBuf}; @@ -324,6 +325,9 @@ enum Commands { /// Output format: text, json, csv #[arg(short, long, default_value = "text")] format: String, + /// Show parse failure log (commands that fell back to raw execution) + #[arg(short = 'F', long)] + failures: bool, }, /// Claude Code economics: spending (ccusage) vs savings (rtk) analysis @@ -841,8 +845,59 @@ enum GoCommands { Other(Vec), } +fn run_fallback(parse_error: clap::Error) -> Result<()> { + let args: Vec = std::env::args().skip(1).collect(); + + // No args → show Clap's error (user ran just "rtk" with bad syntax) + if args.is_empty() { + parse_error.exit(); + } + + eprintln!("[rtk: parse failed, running raw]"); + + let status = std::process::Command::new(&args[0]) + .args(&args[1..]) + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .status(); + + let raw_command = args.join(" "); + let error_message = parse_error.to_string(); + + match status { + Ok(s) => { + // Track as passthrough + let timer = tracking::TimedExecution::start(); + timer.track_passthrough(&raw_command, &format!("rtk fallback: {}", raw_command)); + + tracking::record_parse_failure_silent(&raw_command, &error_message, true); + + if !s.success() { + std::process::exit(s.code().unwrap_or(1)); + } + } + Err(e) => { + tracking::record_parse_failure_silent(&raw_command, &error_message, false); + // Command not found or other OS error — show Clap's original error + eprintln!("[rtk: fallback failed: {}]", e); + parse_error.exit(); + } + } + + Ok(()) +} + fn main() -> Result<()> { - let cli = Cli::parse(); + let cli = match Cli::try_parse() { + Ok(cli) => cli, + Err(e) => { + if matches!(e.kind(), ErrorKind::DisplayHelp | ErrorKind::DisplayVersion) { + e.exit(); + } + return run_fallback(e); + } + }; match cli.command { Commands::Ls { args } => { @@ -1133,6 +1188,7 @@ fn main() -> Result<()> { monthly, all, format, + failures, } => { gain::run( graph, @@ -1144,6 +1200,7 @@ fn main() -> Result<()> { monthly, all, &format, + failures, cli.verbose, )?; } @@ -1461,3 +1518,78 @@ fn main() -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + #[test] + fn test_try_parse_valid_git_status() { + let result = Cli::try_parse_from(["rtk", "git", "status"]); + assert!(result.is_ok(), "git status should parse successfully"); + } + + #[test] + fn test_try_parse_help_is_display_help() { + match Cli::try_parse_from(["rtk", "--help"]) { + Err(e) => assert_eq!(e.kind(), ErrorKind::DisplayHelp), + Ok(_) => panic!("Expected DisplayHelp error"), + } + } + + #[test] + fn test_try_parse_version_is_display_version() { + match Cli::try_parse_from(["rtk", "--version"]) { + Err(e) => assert_eq!(e.kind(), ErrorKind::DisplayVersion), + Ok(_) => panic!("Expected DisplayVersion error"), + } + } + + #[test] + fn test_try_parse_unknown_subcommand_is_error() { + match Cli::try_parse_from(["rtk", "nonexistent-command"]) { + Err(e) => assert!(!matches!( + e.kind(), + ErrorKind::DisplayHelp | ErrorKind::DisplayVersion + )), + Ok(_) => panic!("Expected parse error for unknown subcommand"), + } + } + + #[test] + fn test_try_parse_git_with_dash_c_fails() { + // This is the case that triggers fallback: git -C /path status + match Cli::try_parse_from(["rtk", "git", "-C", "/path", "status"]) { + Err(e) => assert!(!matches!( + e.kind(), + ErrorKind::DisplayHelp | ErrorKind::DisplayVersion + )), + Ok(_) => panic!("Expected parse error for git -C"), + } + } + + #[test] + fn test_gain_failures_flag_parses() { + let result = Cli::try_parse_from(["rtk", "gain", "--failures"]); + assert!(result.is_ok()); + if let Ok(cli) = result { + match cli.command { + Commands::Gain { failures, .. } => assert!(failures), + _ => panic!("Expected Gain command"), + } + } + } + + #[test] + fn test_gain_failures_short_flag_parses() { + let result = Cli::try_parse_from(["rtk", "gain", "-F"]); + assert!(result.is_ok()); + if let Ok(cli) = result { + match cli.command { + Commands::Gain { failures, .. } => assert!(failures), + _ => panic!("Expected Gain command"), + } + } + } +} diff --git a/src/tracking.rs b/src/tracking.rs index eb2acf5..256a674 100644 --- a/src/tracking.rs +++ b/src/tracking.rs @@ -252,6 +252,21 @@ impl Tracker { [], ); + 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 }) } @@ -317,9 +332,91 @@ impl Tracker { "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(()) } + /// Record a parse failure for analytics. + 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, + ], + )?; + Ok(()) + } + + /// Get parse failure summary for `rtk gain --failures`. + pub fn get_parse_failure_summary(&self) -> Result { + 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 + }; + + // Top commands by frequency + 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::, _>>()?; + + // Recent 10 + 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::, _>>()?; + + Ok(ParseFailureSummary { + total: total as usize, + recovery_rate, + top_commands, + recent, + }) + } + /// Get overall summary statistics across all recorded commands. /// /// Returns aggregated metrics including: @@ -695,6 +792,32 @@ fn get_db_path() -> Result { Ok(data_dir.join("rtk").join("history.db")) } +/// Individual parse failure record. +#[derive(Debug)] +pub struct ParseFailureRecord { + pub timestamp: String, + pub raw_command: String, + pub error_message: String, + pub fallback_succeeded: bool, +} + +/// Aggregated parse failure summary. +#[derive(Debug)] +pub struct ParseFailureSummary { + pub total: usize, + pub recovery_rate: f64, + pub top_commands: Vec<(String, usize)>, + pub recent: Vec, +} + +/// Record a parse failure without ever crashing. +/// Silently ignores all errors — used in the fallback path. +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); + } +} + /// Estimate token count from text using ~4 chars = 1 token heuristic. /// /// This is a fast approximation suitable for tracking purposes. @@ -1037,4 +1160,45 @@ mod tests { let db_path = get_db_path().expect("Failed to get db path"); assert!(db_path.ends_with("rtk/history.db")); } + + // 9. record_parse_failure + get_parse_failure_summary roundtrip + #[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)); + } + + // 10. recovery_rate calculation + #[test] + fn test_parse_failure_recovery_rate() { + let tracker = Tracker::new().expect("Failed to create tracker"); + let pid = std::process::id(); + + // 2 successes, 1 failure + 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(); + // We can't assert exact rate because other tests may have added records, + // but we can verify recovery_rate is between 0 and 100 + assert!(summary.recovery_rate >= 0.0 && summary.recovery_rate <= 100.0); + } }