diff --git a/src/cargo_cmd.rs b/src/cargo_cmd.rs new file mode 100644 index 000000000..2c885c56e --- /dev/null +++ b/src/cargo_cmd.rs @@ -0,0 +1,503 @@ +use crate::tracking; +use crate::utils::truncate; +use anyhow::{Context, Result}; +use std::collections::HashMap; +use std::process::Command; + +#[derive(Debug, Clone)] +pub enum CargoCommand { + Build, + Test, + Clippy, +} + +pub fn run(cmd: CargoCommand, args: &[String], verbose: u8) -> Result<()> { + match cmd { + CargoCommand::Build => run_build(args, verbose), + CargoCommand::Test => run_test(args, verbose), + CargoCommand::Clippy => run_clippy(args, verbose), + } +} + +fn run_build(args: &[String], verbose: u8) -> Result<()> { + let mut cmd = Command::new("cargo"); + cmd.arg("build"); + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: cargo build {}", args.join(" ")); + } + + let output = cmd.output().context("Failed to run cargo build")?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let raw = format!("{}\n{}", stdout, stderr); + + let filtered = filter_cargo_build(&raw); + println!("{}", filtered); + + tracking::track( + &format!("cargo build {}", args.join(" ")), + &format!("rtk cargo build {}", args.join(" ")), + &raw, + &filtered, + ); + + if !output.status.success() { + std::process::exit(output.status.code().unwrap_or(1)); + } + + Ok(()) +} + +fn run_test(args: &[String], verbose: u8) -> Result<()> { + let mut cmd = Command::new("cargo"); + cmd.arg("test"); + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: cargo test {}", args.join(" ")); + } + + let output = cmd.output().context("Failed to run cargo test")?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let raw = format!("{}\n{}", stdout, stderr); + + let filtered = filter_cargo_test(&raw); + println!("{}", filtered); + + tracking::track( + &format!("cargo test {}", args.join(" ")), + &format!("rtk cargo test {}", args.join(" ")), + &raw, + &filtered, + ); + + std::process::exit(output.status.code().unwrap_or(1)); +} + +fn run_clippy(args: &[String], verbose: u8) -> Result<()> { + let mut cmd = Command::new("cargo"); + cmd.arg("clippy"); + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: cargo clippy {}", args.join(" ")); + } + + let output = cmd.output().context("Failed to run cargo clippy")?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let raw = format!("{}\n{}", stdout, stderr); + + let filtered = filter_cargo_clippy(&raw); + println!("{}", filtered); + + tracking::track( + &format!("cargo clippy {}", args.join(" ")), + &format!("rtk cargo clippy {}", args.join(" ")), + &raw, + &filtered, + ); + + if !output.status.success() { + std::process::exit(output.status.code().unwrap_or(1)); + } + + Ok(()) +} + +/// Filter cargo build output - strip "Compiling" lines, keep errors + summary +fn filter_cargo_build(output: &str) -> String { + let mut errors: Vec = Vec::new(); + let mut warnings = 0; + let mut error_count = 0; + let mut compiled = 0; + let mut in_error = false; + let mut current_error = Vec::new(); + + for line in output.lines() { + if line.trim_start().starts_with("Compiling") { + compiled += 1; + continue; + } + if line.trim_start().starts_with("Downloading") + || line.trim_start().starts_with("Downloaded") + { + continue; + } + if line.trim_start().starts_with("Finished") { + continue; + } + + // Detect error/warning blocks + if line.starts_with("error[") || line.starts_with("error:") { + // Skip "error: aborting due to" summary lines + if line.contains("aborting due to") || line.contains("could not compile") { + continue; + } + if in_error && !current_error.is_empty() { + errors.push(current_error.join("\n")); + current_error.clear(); + } + error_count += 1; + in_error = true; + current_error.push(line.to_string()); + } else if line.starts_with("warning:") + && line.contains("generated") + && line.contains("warning") + { + // "warning: `crate` generated N warnings" summary line + continue; + } else if line.starts_with("warning:") || line.starts_with("warning[") { + if in_error && !current_error.is_empty() { + errors.push(current_error.join("\n")); + current_error.clear(); + } + warnings += 1; + in_error = true; + current_error.push(line.to_string()); + } else if in_error { + if line.trim().is_empty() && current_error.len() > 3 { + errors.push(current_error.join("\n")); + current_error.clear(); + in_error = false; + } else { + current_error.push(line.to_string()); + } + } + } + + if !current_error.is_empty() { + errors.push(current_error.join("\n")); + } + + if error_count == 0 && warnings == 0 { + return format!("✓ cargo build ({} crates compiled)", compiled); + } + + let mut result = String::new(); + result.push_str(&format!( + "cargo build: {} errors, {} warnings ({} crates)\n", + error_count, warnings, compiled + )); + result.push_str("═══════════════════════════════════════\n"); + + for (i, err) in errors.iter().enumerate().take(15) { + result.push_str(err); + result.push('\n'); + if i < errors.len() - 1 { + result.push('\n'); + } + } + + if errors.len() > 15 { + result.push_str(&format!("\n... +{} more issues\n", errors.len() - 15)); + } + + result.trim().to_string() +} + +/// Filter cargo test output - show failures + summary only +fn filter_cargo_test(output: &str) -> String { + let mut failures: Vec = Vec::new(); + let mut summary_lines: Vec = Vec::new(); + let mut in_failure_section = false; + let mut current_failure = Vec::new(); + + for line in output.lines() { + // Skip compilation lines + if line.trim_start().starts_with("Compiling") + || line.trim_start().starts_with("Downloading") + || line.trim_start().starts_with("Downloaded") + || line.trim_start().starts_with("Finished") + { + continue; + } + + // Skip "running N tests" and individual "test ... ok" lines + if line.starts_with("running ") || (line.starts_with("test ") && line.ends_with("... ok")) { + continue; + } + + // Detect failures section + if line == "failures:" { + in_failure_section = true; + continue; + } + + if in_failure_section { + if line.starts_with("test result:") { + in_failure_section = false; + summary_lines.push(line.to_string()); + } else if line.starts_with(" ") || line.starts_with("---- ") { + current_failure.push(line.to_string()); + } else if line.trim().is_empty() && !current_failure.is_empty() { + failures.push(current_failure.join("\n")); + current_failure.clear(); + } else if !line.trim().is_empty() { + current_failure.push(line.to_string()); + } + } + + // Capture test result summary + if !in_failure_section && line.starts_with("test result:") { + summary_lines.push(line.to_string()); + } + } + + if !current_failure.is_empty() { + failures.push(current_failure.join("\n")); + } + + let mut result = String::new(); + + if failures.is_empty() && !summary_lines.is_empty() { + // All passed + for line in &summary_lines { + result.push_str(&format!("✓ {}\n", line)); + } + return result.trim().to_string(); + } + + if !failures.is_empty() { + result.push_str(&format!("FAILURES ({}):\n", failures.len())); + result.push_str("═══════════════════════════════════════\n"); + for (i, failure) in failures.iter().enumerate().take(10) { + result.push_str(&format!("{}. {}\n", i + 1, truncate(failure, 200))); + } + if failures.len() > 10 { + result.push_str(&format!("\n... +{} more failures\n", failures.len() - 10)); + } + result.push('\n'); + } + + for line in &summary_lines { + result.push_str(&format!("{}\n", line)); + } + + if result.trim().is_empty() { + // Fallback: show last meaningful lines + let meaningful: Vec<&str> = output + .lines() + .filter(|l| !l.trim().is_empty() && !l.trim_start().starts_with("Compiling")) + .collect(); + for line in meaningful.iter().rev().take(5).rev() { + result.push_str(&format!("{}\n", line)); + } + } + + result.trim().to_string() +} + +/// Filter cargo clippy output - group warnings by lint rule +fn filter_cargo_clippy(output: &str) -> String { + let mut by_rule: HashMap> = HashMap::new(); + let mut error_count = 0; + let mut warning_count = 0; + + // Parse clippy output lines + // Format: "warning: description\n --> file:line:col\n |\n | code\n" + let mut current_rule = String::new(); + + for line in output.lines() { + // Skip compilation lines + if line.trim_start().starts_with("Compiling") + || line.trim_start().starts_with("Checking") + || line.trim_start().starts_with("Downloading") + || line.trim_start().starts_with("Downloaded") + || line.trim_start().starts_with("Finished") + { + continue; + } + + // "warning: unused variable [unused_variables]" or "warning: description [clippy::rule_name]" + if (line.starts_with("warning:") || line.starts_with("warning[")) + || (line.starts_with("error:") || line.starts_with("error[")) + { + // Skip summary lines: "warning: `rtk` (bin) generated 5 warnings" + if line.contains("generated") && line.contains("warning") { + continue; + } + // Skip "error: aborting" / "error: could not compile" + if line.contains("aborting due to") || line.contains("could not compile") { + continue; + } + + let is_error = line.starts_with("error"); + if is_error { + error_count += 1; + } else { + warning_count += 1; + } + + // Extract rule name from brackets + current_rule = if let Some(bracket_start) = line.rfind('[') { + if let Some(bracket_end) = line.rfind(']') { + line[bracket_start + 1..bracket_end].to_string() + } else { + line.to_string() + } + } else { + // No bracket: use the message itself as the rule + let prefix = if is_error { "error: " } else { "warning: " }; + line.strip_prefix(prefix).unwrap_or(line).to_string() + }; + } else if line.trim_start().starts_with("--> ") { + let location = line.trim_start().trim_start_matches("--> ").to_string(); + if !current_rule.is_empty() { + by_rule + .entry(current_rule.clone()) + .or_default() + .push(location); + } + } + } + + if error_count == 0 && warning_count == 0 { + return "✓ cargo clippy: No issues found".to_string(); + } + + let mut result = String::new(); + result.push_str(&format!( + "cargo clippy: {} errors, {} warnings\n", + error_count, warning_count + )); + result.push_str("═══════════════════════════════════════\n"); + + // Sort rules by frequency + let mut rule_counts: Vec<_> = by_rule.iter().collect(); + rule_counts.sort_by(|a, b| b.1.len().cmp(&a.1.len())); + + for (rule, locations) in rule_counts.iter().take(15) { + result.push_str(&format!(" {} ({}x)\n", rule, locations.len())); + for loc in locations.iter().take(3) { + result.push_str(&format!(" {}\n", loc)); + } + if locations.len() > 3 { + result.push_str(&format!(" ... +{} more\n", locations.len() - 3)); + } + } + + if by_rule.len() > 15 { + result.push_str(&format!("\n... +{} more rules\n", by_rule.len() - 15)); + } + + result.trim().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_filter_cargo_build_success() { + let output = r#" Compiling libc v0.2.153 + Compiling cfg-if v1.0.0 + Compiling rtk v0.5.0 + Finished dev [unoptimized + debuginfo] target(s) in 15.23s +"#; + let result = filter_cargo_build(output); + assert!(result.contains("✓ cargo build")); + assert!(result.contains("3 crates compiled")); + } + + #[test] + fn test_filter_cargo_build_errors() { + let output = r#" Compiling rtk v0.5.0 +error[E0308]: mismatched types + --> src/main.rs:10:5 + | +10| "hello" + | ^^^^^^^ expected `i32`, found `&str` + +error: aborting due to 1 previous error +"#; + let result = filter_cargo_build(output); + assert!(result.contains("1 errors")); + assert!(result.contains("E0308")); + assert!(result.contains("mismatched types")); + } + + #[test] + fn test_filter_cargo_test_all_pass() { + let output = r#" Compiling rtk v0.5.0 + Finished test [unoptimized + debuginfo] target(s) in 2.53s + Running target/debug/deps/rtk-abc123 + +running 15 tests +test utils::tests::test_truncate_short_string ... ok +test utils::tests::test_truncate_long_string ... ok +test utils::tests::test_strip_ansi_simple ... ok + +test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s +"#; + let result = filter_cargo_test(output); + assert!(result.contains("✓ test result: ok. 15 passed")); + assert!(!result.contains("Compiling")); + assert!(!result.contains("test utils")); + } + + #[test] + fn test_filter_cargo_test_failures() { + let output = r#"running 5 tests +test foo::test_a ... ok +test foo::test_b ... FAILED +test foo::test_c ... ok + +failures: + +---- foo::test_b stdout ---- +thread 'foo::test_b' panicked at 'assert_eq!(1, 2)' + +failures: + foo::test_b + +test result: FAILED. 4 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out +"#; + let result = filter_cargo_test(output); + assert!(result.contains("FAILURES")); + assert!(result.contains("test_b")); + assert!(result.contains("test result:")); + } + + #[test] + fn test_filter_cargo_clippy_clean() { + let output = r#" Checking rtk v0.5.0 + Finished dev [unoptimized + debuginfo] target(s) in 1.53s +"#; + let result = filter_cargo_clippy(output); + assert!(result.contains("✓ cargo clippy: No issues found")); + } + + #[test] + fn test_filter_cargo_clippy_warnings() { + let output = r#" Checking rtk v0.5.0 +warning: unused variable: `x` [unused_variables] + --> src/main.rs:10:9 + | +10| let x = 5; + | ^ help: if this is intentional, prefix it with an underscore: `_x` + +warning: this function has too many arguments [clippy::too_many_arguments] + --> src/git.rs:16:1 + | +16| pub fn run(a: i32, b: i32, c: i32, d: i32, e: i32, f: i32, g: i32, h: i32) {} + | + +warning: `rtk` (bin) generated 2 warnings + Finished dev [unoptimized + debuginfo] target(s) in 1.53s +"#; + let result = filter_cargo_clippy(output); + assert!(result.contains("0 errors, 2 warnings")); + assert!(result.contains("unused_variables")); + assert!(result.contains("clippy::too_many_arguments")); + } +} diff --git a/src/git.rs b/src/git.rs index 89274ca7f..780cd45aa 100644 --- a/src/git.rs +++ b/src/git.rs @@ -90,7 +90,7 @@ fn run_diff(args: &[String], max_lines: Option, verbose: u8) -> Result<() Ok(()) } -fn compact_diff(diff: &str, max_lines: usize) -> String { +pub(crate) fn compact_diff(diff: &str, max_lines: usize) -> String { let mut result = Vec::new(); let mut current_file = String::new(); let mut added = 0; diff --git a/src/json_cmd.rs b/src/json_cmd.rs index b546c604a..406b85abe 100644 --- a/src/json_cmd.rs +++ b/src/json_cmd.rs @@ -1,8 +1,8 @@ +use crate::tracking; use anyhow::{Context, Result}; use serde_json::Value; use std::fs; use std::path::Path; -use crate::tracking; /// Show JSON structure without values pub fn run(file: &Path, max_depth: usize, verbose: u8) -> Result<()> { @@ -13,15 +13,24 @@ pub fn run(file: &Path, max_depth: usize, verbose: u8) -> Result<()> { let content = fs::read_to_string(file) .with_context(|| format!("Failed to read file: {}", file.display()))?; - let value: Value = serde_json::from_str(&content) - .with_context(|| format!("Failed to parse JSON: {}", file.display()))?; - - let schema = extract_schema(&value, 0, max_depth); + let schema = filter_json_string(&content, max_depth)?; println!("{}", schema); - tracking::track(&format!("cat {}", file.display()), "rtk json", &content, &schema); + tracking::track( + &format!("cat {}", file.display()), + "rtk json", + &content, + &schema, + ); Ok(()) } +/// Parse a JSON string and return its schema representation. +/// Useful for piping JSON from other commands (e.g., `gh api`, `curl`). +pub fn filter_json_string(json_str: &str, max_depth: usize) -> Result { + let value: Value = serde_json::from_str(json_str).context("Failed to parse JSON")?; + Ok(extract_schema(&value, 0, max_depth)) +} + fn extract_schema(value: &Value, depth: usize, max_depth: usize) -> String { let indent = " ".repeat(depth); @@ -82,7 +91,10 @@ fn extract_schema(value: &Value, depth: usize, max_depth: usize) -> String { let val_trimmed = val_schema.trim(); // Inline simple types - let is_simple = matches!(val, Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_)); + let is_simple = matches!( + val, + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) + ); if is_simple { if i < keys.len() - 1 { diff --git a/src/main.rs b/src/main.rs index e8c73019f..036df6b03 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod cargo_cmd; mod cc_economics; mod ccusage; mod config; @@ -357,6 +358,12 @@ enum Commands { #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, }, + + /// Cargo commands with compact output + Cargo { + #[command(subcommand)] + command: CargoCommands, + }, } #[derive(Subcommand)] @@ -512,6 +519,28 @@ enum PrismaMigrateCommands { }, } +#[derive(Subcommand)] +enum CargoCommands { + /// Build with compact output (strip Compiling lines, keep errors) + Build { + /// Additional cargo build arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Test with failures-only output + Test { + /// Additional cargo test arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Clippy with warnings grouped by lint rule + Clippy { + /// Additional cargo clippy arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, +} + fn main() -> Result<()> { let cli = Cli::parse(); @@ -823,6 +852,18 @@ fn main() -> Result<()> { Commands::Playwright { args } => { playwright_cmd::run(&args, cli.verbose)?; } + + Commands::Cargo { command } => match command { + CargoCommands::Build { args } => { + cargo_cmd::run(cargo_cmd::CargoCommand::Build, &args, cli.verbose)?; + } + CargoCommands::Test { args } => { + cargo_cmd::run(cargo_cmd::CargoCommand::Test, &args, cli.verbose)?; + } + CargoCommands::Clippy { args } => { + cargo_cmd::run(cargo_cmd::CargoCommand::Clippy, &args, cli.verbose)?; + } + }, } Ok(()) diff --git a/src/utils.rs b/src/utils.rs index 1551b8c52..b7061cbd2 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -131,6 +131,77 @@ pub fn format_usd(amount: f64) -> String { } } +/// Format a confirmation message: "ok " +/// Used for write operations (merge, create, comment, edit, etc.) +/// +/// # Examples +/// ``` +/// use rtk::utils::ok_confirmation; +/// assert_eq!(ok_confirmation("merged", "#42"), "ok merged #42"); +/// assert_eq!(ok_confirmation("created", "PR #5 https://..."), "ok created PR #5 https://..."); +/// ``` +pub fn ok_confirmation(action: &str, detail: &str) -> String { + if detail.is_empty() { + format!("ok {}", action) + } else { + format!("ok {} {}", action, detail) + } +} + +/// Detect the package manager used in the current directory. +/// Returns "pnpm", "yarn", or "npm" based on lockfile presence. +/// +/// # Examples +/// ```no_run +/// use rtk::utils::detect_package_manager; +/// let pm = detect_package_manager(); +/// // Returns "pnpm" if pnpm-lock.yaml exists, "yarn" if yarn.lock, else "npm" +/// ``` +#[allow(dead_code)] +pub fn detect_package_manager() -> &'static str { + if std::path::Path::new("pnpm-lock.yaml").exists() { + "pnpm" + } else if std::path::Path::new("yarn.lock").exists() { + "yarn" + } else { + "npm" + } +} + +/// Build a Command using the detected package manager's exec mechanism. +/// Returns a Command ready to have tool-specific args appended. +#[allow(dead_code)] +pub fn package_manager_exec(tool: &str) -> Command { + let tool_exists = Command::new("which") + .arg(tool) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + + if tool_exists { + Command::new(tool) + } else { + let pm = detect_package_manager(); + match pm { + "pnpm" => { + let mut c = Command::new("pnpm"); + c.arg("exec").arg("--").arg(tool); + c + } + "yarn" => { + let mut c = Command::new("yarn"); + c.arg("exec").arg("--").arg(tool); + c + } + _ => { + let mut c = Command::new("npx"); + c.arg("--no-install").arg("--").arg(tool); + c + } + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -240,4 +311,26 @@ mod tests { assert_eq!(format_usd(0.01), "$0.01"); assert_eq!(format_usd(0.009), "$0.0090"); } + + #[test] + fn test_ok_confirmation_with_detail() { + assert_eq!(ok_confirmation("merged", "#42"), "ok merged #42"); + assert_eq!( + ok_confirmation("created", "PR #5 https://github.com/foo/bar/pull/5"), + "ok created PR #5 https://github.com/foo/bar/pull/5" + ); + } + + #[test] + fn test_ok_confirmation_no_detail() { + assert_eq!(ok_confirmation("commented", ""), "ok commented"); + } + + #[test] + fn test_detect_package_manager_default() { + // In the test environment (rtk repo), there's no JS lockfile + // so it should default to "npm" + let pm = detect_package_manager(); + assert!(["pnpm", "yarn", "npm"].contains(&pm)); + } }