diff --git a/CHANGELOG.md b/CHANGELOG.md index e98489a2..1e2acda6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Bug Fixes +* **gain:** use weighted savings rate in per-command stats — `AVG(savings_pct)` replaced by `SUM(saved_tokens)/SUM(input_tokens)`, fixing commands like `grep` showing 14.8% instead of 98.6%; column renamed `Avg%` → `Rate` ([#891](https://github.com/rtk-ai/rtk/pull/891)) ([b3ab873](https://github.com/rtk-ai/rtk/commit/b3ab873)) * **diff:** correct truncation overflow count in condense_unified_diff ([#833](https://github.com/rtk-ai/rtk/pull/833)) ([5399f83](https://github.com/rtk-ai/rtk/commit/5399f83)) * **git:** replace vague truncation markers with exact counts in log and grep output ([#833](https://github.com/rtk-ai/rtk/pull/833)) ([185fb97](https://github.com/rtk-ai/rtk/commit/185fb97)) diff --git a/CLAUDE.md b/CLAUDE.md index 0dddf14e..e6183a51 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,6 +75,8 @@ For the full architecture, component details, and module development patterns, s Module responsibilities are documented in each folder's `README.md` and each file's `//!` doc header. Browse `src/cmds/*/` to discover available filters. +Supported ecosystems: git/gh/gt, cargo, go/golangci-lint, npm/pnpm/npx, ruff/pytest/pip/mypy, rspec/rubocop/rake, dotnet, playwright/vitest/jest, docker/kubectl/aws. + ### Proxy Mode **Purpose**: Execute commands without filtering but track usage for metrics. diff --git a/src/analytics/gain.rs b/src/analytics/gain.rs index 3938334a..f4bb120e 100644 --- a/src/analytics/gain.rs +++ b/src/analytics/gain.rs @@ -177,7 +177,7 @@ pub fn run( println!("{}", "─".repeat(table_width)); println!( "{:>3} {:count_width$} {:>saved_width$} {:>6} {:>time_width$} {: Result> { let (project_exact, project_glob) = project_filter_params(project_path); // added let mut stmt = self.conn.prepare( - "SELECT rtk_cmd, COUNT(*), SUM(saved_tokens), AVG(savings_pct), AVG(exec_time_ms) + "SELECT rtk_cmd, COUNT(*), SUM(saved_tokens), + CASE WHEN SUM(input_tokens) > 0 THEN SUM(saved_tokens) * 100.0 / SUM(input_tokens) ELSE 0.0 END, + AVG(exec_time_ms) FROM commands WHERE (?1 IS NULL OR project_path = ?1 OR project_path GLOB ?2) GROUP BY rtk_cmd @@ -1426,4 +1433,71 @@ mod tests { // but we can verify recovery_rate is between 0 and 100 assert!(summary.recovery_rate >= 0.0 && summary.recovery_rate <= 100.0); } + + // 14. get_by_command uses weighted savings rate, not unweighted average + // + // Regression test for: AVG(savings_pct) gave wrong results when small invocations + // with 0% savings diluted the average of high-volume commands. + // + // Setup: one small command (10% savings) + one large command (95% savings). + // Unweighted avg would be ~52.5%. Weighted rate must be ~95%. + // + // Uses a unique fake project path so project-filtered query returns only our rows, + // avoiding the LIMIT 10 cap on the shared DB. + #[test] + fn test_get_by_command_weighted_savings_rate() { + let tracker = Tracker::new().expect("Failed to create tracker"); + let pid = std::process::id(); + let cmd_name = format!("weighted_test_{}", pid); + // Unique project path keeps our rows isolated from the shared DB + let fake_project = format!("/tmp/rtk_weighted_test_{}", pid); + + // Override project_path by inserting directly via conn + let saved_small = 10_i64; // 100 in - 90 out = 10 saved → 10% + let saved_large = 95_000_i64; // 100_000 in - 5_000 out = 95_000 saved → 95% + tracker + .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)", + rusqlite::params![ + chrono::Utc::now().to_rfc3339(), + &cmd_name, &cmd_name, &fake_project, + 100_i64, 90_i64, saved_small, 10.0_f64, 5_i64 + ], + ) + .expect("Failed to insert small invocation"); + tracker + .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)", + rusqlite::params![ + chrono::Utc::now().to_rfc3339(), + &cmd_name, &cmd_name, &fake_project, + 100_000_i64, 5_000_i64, saved_large, 95.0_f64, 10_i64 + ], + ) + .expect("Failed to insert large invocation"); + + let by_cmd = tracker + .get_by_command(Some(&fake_project)) + .expect("Failed to get by_command stats"); + + let entry = by_cmd + .iter() + .find(|(name, _, _, _, _)| name == &cmd_name) + .expect("Test command not found in by_command stats"); + + let (_name, _count, _saved, rate, _time) = entry; + + // Weighted rate = (10 + 95_000) * 100.0 / (100 + 100_000) ≈ 94.99% + // Unweighted avg would be (10.0 + 95.0) / 2 = 52.5% + // The gap proves the fix works. + assert!( + *rate > 90.0, + "Expected weighted rate >90%, got {:.1}% — unweighted avg would be ~52.5%", + rate + ); + } } diff --git a/src/discover/registry.rs b/src/discover/registry.rs index cc975b3b..2ea90ecd 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -890,9 +890,8 @@ mod tests { #[test] fn test_registry_covers_all_cargo_subcommands() { - // Verify that every CargoCommand variant (Build, Test, Clippy, Check, Fmt) - // except Other has a matching pattern in the registry - for subcmd in ["build", "test", "clippy", "check", "fmt"] { + // Verify that every CargoCommand variant except Other has a matching pattern + for subcmd in ["build", "test", "clippy", "check", "fmt", "run", "publish"] { let cmd = format!("cargo {subcmd}"); match classify_command(&cmd) { Classification::Supported { .. } => {} @@ -906,7 +905,7 @@ mod tests { // Verify that every GitCommand subcommand has a matching pattern for subcmd in [ "status", "log", "diff", "show", "add", "commit", "push", "pull", "branch", "fetch", - "stash", "worktree", + "stash", "worktree", "checkout", "merge", "rebase", ] { let cmd = format!("git {subcmd}"); match classify_command(&cmd) { @@ -2348,4 +2347,130 @@ mod tests { assert_eq!(strip_git_global_opts("git status"), "git status"); assert_eq!(strip_git_global_opts("cargo test"), "cargo test"); } + + // Passthrough subcommand coverage: discover should classify these as Supported + // (routing to passthrough handlers) rather than "TOP UNHANDLED COMMANDS". + + #[test] + fn test_classify_git_checkout_passthrough() { + assert_eq!( + classify_command("git checkout main"), + Classification::Supported { + rtk_equivalent: "rtk git", + category: "Git", + estimated_savings_pct: 70.0, + status: RtkStatus::Passthrough, + } + ); + } + + #[test] + fn test_classify_git_merge_passthrough() { + assert_eq!( + classify_command("git merge feature/my-branch"), + Classification::Supported { + rtk_equivalent: "rtk git", + category: "Git", + estimated_savings_pct: 70.0, + status: RtkStatus::Passthrough, + } + ); + } + + #[test] + fn test_classify_git_rebase_passthrough() { + assert_eq!( + classify_command("git rebase origin/main"), + Classification::Supported { + rtk_equivalent: "rtk git", + category: "Git", + estimated_savings_pct: 70.0, + status: RtkStatus::Passthrough, + } + ); + } + + #[test] + fn test_classify_cargo_run_passthrough() { + assert_eq!( + classify_command("cargo run -- verify"), + Classification::Supported { + rtk_equivalent: "rtk cargo", + category: "Cargo", + estimated_savings_pct: 80.0, + status: RtkStatus::Passthrough, + } + ); + } + + #[test] + fn test_classify_cargo_publish_passthrough() { + assert_eq!( + classify_command("cargo publish -p my-crate"), + Classification::Supported { + rtk_equivalent: "rtk cargo", + category: "Cargo", + estimated_savings_pct: 80.0, + status: RtkStatus::Passthrough, + } + ); + } + + #[test] + fn test_classify_pnpm_build_passthrough() { + assert_eq!( + classify_command("pnpm build"), + Classification::Supported { + rtk_equivalent: "rtk pnpm", + category: "PackageManager", + estimated_savings_pct: 80.0, + status: RtkStatus::Passthrough, + } + ); + } + + #[test] + fn test_classify_pnpm_exec_passthrough() { + assert_eq!( + classify_command("pnpm exec eslint --max-warnings=0"), + Classification::Supported { + rtk_equivalent: "rtk pnpm", + category: "PackageManager", + estimated_savings_pct: 80.0, + status: RtkStatus::Passthrough, + } + ); + } + + #[test] + fn test_rewrite_git_checkout() { + assert_eq!( + rewrite_command("git checkout main", &[]), + Some("rtk git checkout main".to_string()) + ); + } + + #[test] + fn test_rewrite_git_merge() { + assert_eq!( + rewrite_command("git merge --no-edit feature", &[]), + Some("rtk git merge --no-edit feature".to_string()) + ); + } + + #[test] + fn test_rewrite_cargo_run() { + assert_eq!( + rewrite_command("cargo run -- verify", &[]), + Some("rtk cargo run -- verify".to_string()) + ); + } + + #[test] + fn test_rewrite_pnpm_build() { + assert_eq!( + rewrite_command("pnpm build", &[]), + Some("rtk pnpm build".to_string()) + ); + } } diff --git a/src/discover/rules.rs b/src/discover/rules.rs index e5a36e7c..80066a79 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -15,10 +15,10 @@ pub struct RtkRule { // Patterns ordered to match RULES indices exactly. pub const PATTERNS: &[&str] = &[ - r"^git\s+(?:-[Cc]\s+\S+\s+)*(status|log|diff|show|add|commit|push|pull|branch|fetch|stash|worktree)", + r"^git\s+(?:-[Cc]\s+\S+\s+)*(status|log|diff|show|add|commit|push|pull|branch|fetch|stash|worktree|checkout|merge|rebase)", r"^gh\s+(pr|issue|run|repo|api|release)", - r"^cargo\s+(build|test|clippy|check|fmt|install)", - r"^pnpm\s+(list|ls|outdated|install)", + r"^cargo\s+(build|test|clippy|check|fmt|install|run|publish)", + r"^pnpm\s+(list|ls|outdated|install|build|exec)", r"^npm\s+(run|exec)", r"^npx\s+", r"^(cat|head|tail)\s+", @@ -102,7 +102,11 @@ pub const RULES: &[RtkRule] = &[ ("add", 59.0), ("commit", 59.0), ], - subcmd_status: &[], + subcmd_status: &[ + ("checkout", RtkStatus::Passthrough), + ("merge", RtkStatus::Passthrough), + ("rebase", RtkStatus::Passthrough), + ], }, RtkRule { rtk_cmd: "rtk gh", @@ -118,7 +122,11 @@ pub const RULES: &[RtkRule] = &[ category: "Cargo", savings_pct: 80.0, subcmd_savings: &[("test", 90.0), ("check", 80.0)], - subcmd_status: &[("fmt", RtkStatus::Passthrough)], + subcmd_status: &[ + ("fmt", RtkStatus::Passthrough), + ("run", RtkStatus::Passthrough), + ("publish", RtkStatus::Passthrough), + ], }, RtkRule { rtk_cmd: "rtk pnpm", @@ -126,7 +134,10 @@ pub const RULES: &[RtkRule] = &[ category: "PackageManager", savings_pct: 80.0, subcmd_savings: &[], - subcmd_status: &[], + subcmd_status: &[ + ("build", RtkStatus::Passthrough), + ("exec", RtkStatus::Passthrough), + ], }, RtkRule { rtk_cmd: "rtk npm",