Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions src/gain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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(())
}
134 changes: 133 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -841,8 +845,59 @@ enum GoCommands {
Other(Vec<OsString>),
}

fn run_fallback(parse_error: clap::Error) -> Result<()> {
let args: Vec<String> = 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 } => {
Expand Down Expand Up @@ -1133,6 +1188,7 @@ fn main() -> Result<()> {
monthly,
all,
format,
failures,
} => {
gain::run(
graph,
Expand All @@ -1144,6 +1200,7 @@ fn main() -> Result<()> {
monthly,
all,
&format,
failures,
cli.verbose,
)?;
}
Expand Down Expand Up @@ -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"),
}
}
}
}
Loading