diff --git a/hooks/rtk-rewrite.sh b/hooks/rtk-rewrite.sh index 59e02ca..8c36577 100644 --- a/hooks/rtk-rewrite.sh +++ b/hooks/rtk-rewrite.sh @@ -165,16 +165,18 @@ elif echo "$MATCH_CMD" | grep -qE '^pnpm[[:space:]]+(list|ls|outdated)([[:space: REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^pnpm /rtk pnpm /')" # --- Python tooling --- +elif echo "$MATCH_CMD" | grep -qE '^uv[[:space:]]+'; then + REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^uv /rtk uv /')" elif echo "$MATCH_CMD" | grep -qE '^pytest([[:space:]]|$)'; then REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^pytest/rtk pytest/')" elif echo "$MATCH_CMD" | grep -qE '^python[[:space:]]+-m[[:space:]]+pytest([[:space:]]|$)'; then REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^python -m pytest/rtk pytest/')" +elif echo "$MATCH_CMD" | grep -qE '^([^[:space:]]+/)?python(3)?[[:space:]]+-m[[:space:]]+pytest([[:space:]]|$)'; then + REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed -E 's@^([^[:space:]]+/)?python(3)? -m pytest@rtk pytest@')" elif echo "$MATCH_CMD" | grep -qE '^ruff[[:space:]]+(check|format)([[:space:]]|$)'; then REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^ruff /rtk ruff /')" elif echo "$MATCH_CMD" | grep -qE '^pip[[:space:]]+(list|outdated|install|show)([[:space:]]|$)'; then REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^pip /rtk pip /')" -elif echo "$MATCH_CMD" | grep -qE '^uv[[:space:]]+pip[[:space:]]+(list|outdated|install|show)([[:space:]]|$)'; then - REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^uv pip /rtk pip /')" # --- Go tooling --- elif echo "$MATCH_CMD" | grep -qE '^go[[:space:]]+test([[:space:]]|$)'; then diff --git a/hooks/test-rtk-rewrite.sh b/hooks/test-rtk-rewrite.sh index 2c5535b..ad47f03 100755 --- a/hooks/test-rtk-rewrite.sh +++ b/hooks/test-rtk-rewrite.sh @@ -117,6 +117,34 @@ test_rewrite "npx prisma migrate" \ "npx prisma migrate" \ "rtk prisma migrate" +test_rewrite "uv pip list" \ + "uv pip list" \ + "rtk uv pip list" + +test_rewrite "uv run python -m pytest -q" \ + "uv run python -m pytest -q" \ + "rtk uv run python -m pytest -q" + +test_rewrite "uv run mypy" \ + "uv run mypy src/" \ + "rtk uv run mypy src/" + +test_rewrite "uv run ruff" \ + "uv run ruff check src/" \ + "rtk uv run ruff check src/" + +test_rewrite "uv run pylint" \ + "uv run pylint src/" \ + "rtk uv run pylint src/" + +test_rewrite "uv run flake8" \ + "uv run flake8 src/" \ + "rtk uv run flake8 src/" + +test_rewrite "uv sync --frozen" \ + "uv sync --frozen" \ + "rtk uv sync --frozen" + echo "" # ---- SECTION 2: Env var prefix handling (THE BIG FIX) ---- @@ -149,6 +177,26 @@ test_rewrite "env + docker compose" \ "COMPOSE_PROJECT_NAME=test docker compose up -d" \ "COMPOSE_PROJECT_NAME=test rtk docker compose up -d" +test_rewrite "env + uv run pytest" \ + "PYTHONPATH=. uv run python -m pytest tests/" \ + "PYTHONPATH=. rtk uv run python -m pytest tests/" + +test_rewrite "venv python pytest" \ + ".venv/bin/python -m pytest tests/unit/" \ + "rtk pytest tests/unit/" + +test_rewrite "env + venv python pytest" \ + "PYTHONPATH=. .venv/bin/python -m pytest tests/unit/" \ + "PYTHONPATH=. rtk pytest tests/unit/" + +test_rewrite "ven python pytest" \ + ".ven/bin/python -m pytest tests/unit/" \ + "rtk pytest tests/unit/" + +test_rewrite "env + ven python pytest" \ + "PYTHONPATH=. .ven/bin/python -m pytest tests/unit/" \ + "PYTHONPATH=. rtk pytest tests/unit/" + echo "" # ---- SECTION 3: New patterns ---- diff --git a/src/main.rs b/src/main.rs index 5bec4da..1415b2a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -44,6 +44,7 @@ mod tracking; mod tree; mod tsc_cmd; mod utils; +mod uv_cmd; mod vitest_cmd; mod wget_cmd; @@ -505,6 +506,13 @@ enum Commands { args: Vec, }, + /// uv package manager/runner with first-class subcommand dispatch + Uv { + /// uv arguments (e.g., run, pip, sync) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Go commands with compact output Go { #[command(subcommand)] @@ -1349,6 +1357,10 @@ fn main() -> Result<()> { pip_cmd::run(&args, cli.verbose)?; } + Commands::Uv { args } => { + uv_cmd::run(&args, cli.verbose)?; + } + Commands::Go { command } => match command { GoCommands::Test { args } => { go_cmd::run_test(&args, cli.verbose)?; diff --git a/src/uv_cmd.rs b/src/uv_cmd.rs new file mode 100644 index 0000000..2e2d5b3 --- /dev/null +++ b/src/uv_cmd.rs @@ -0,0 +1,312 @@ +use crate::lint_cmd; +use crate::pip_cmd; +use crate::pytest_cmd; +use crate::ruff_cmd; +use crate::tracking; +use anyhow::{Context, Result}; +use std::path::Path; +use std::process::Command; + +pub fn run(args: &[String], verbose: u8) -> Result<()> { + if args.is_empty() { + anyhow::bail!("uv: no subcommand specified"); + } + + match args[0].as_str() { + "run" => run_run(&args[1..], verbose), + "pip" => pip_cmd::run(&args[1..], verbose), + "sync" => run_sync(&args[1..], verbose), + _ => run_passthrough(args, verbose), + } +} + +fn run_run(args: &[String], verbose: u8) -> Result<()> { + if let Some((tool, start_idx)) = routed_uv_run_tool(args) { + let rest = &args[start_idx..]; + return run_routed_uv_tool(tool, rest, verbose); + } + + run_passthrough_with_prefix("run", args, verbose) +} + +fn run_routed_uv_tool(tool: &str, args: &[String], verbose: u8) -> Result<()> { + match tool { + "pytest" => pytest_cmd::run(args, verbose), + "ruff" => ruff_cmd::run(args, verbose), + "mypy" | "pylint" | "flake8" => { + let mut lint_args = vec![tool.to_string()]; + lint_args.extend(args.iter().cloned()); + lint_cmd::run(&lint_args, verbose) + } + _ => run_passthrough_with_prefix("run", args, verbose), + } +} + +fn routed_uv_run_tool(args: &[String]) -> Option<(&str, usize)> { + if args.is_empty() { + return None; + } + + if args.len() >= 3 && is_python_executable(&args[0]) && args[1] == "-m" { + let tool = args[2].as_str(); + if matches!(tool, "pytest" | "ruff" | "mypy" | "pylint" | "flake8") { + return Some((tool, 3)); + } + } + + let tool = args[0].as_str(); + if matches!(tool, "pytest" | "ruff" | "mypy" | "pylint" | "flake8") { + return Some((tool, 1)); + } + + None +} + +fn is_python_executable(candidate: &str) -> bool { + if candidate == "python" || candidate == "python3" { + return true; + } + + let Some(file_name) = Path::new(candidate).file_name().and_then(|s| s.to_str()) else { + return false; + }; + + matches!(file_name, "python" | "python3") +} + +fn run_sync(args: &[String], verbose: u8) -> Result<()> { + let timer = tracking::TimedExecution::start(); + + let mut cmd = Command::new("uv"); + cmd.arg("sync"); + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: uv sync {}", args.join(" ")); + } + + let output = cmd.output().context("Failed to run uv sync")?; + 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_uv_sync_output(&raw); + + let exit_code = output + .status + .code() + .unwrap_or(if output.status.success() { 0 } else { 1 }); + + if let Some(hint) = crate::tee::tee_and_hint(&raw, "uv_sync", exit_code) { + println!("{}\n{}", filtered, hint); + } else { + println!("{}", filtered); + } + + timer.track( + &format!("uv sync {}", args.join(" ")), + &format!("rtk uv sync {}", args.join(" ")), + &raw, + &filtered, + ); + + if !output.status.success() { + std::process::exit(exit_code); + } + + Ok(()) +} + +fn run_passthrough_with_prefix(prefix: &str, args: &[String], verbose: u8) -> Result<()> { + let mut all_args = Vec::with_capacity(args.len() + 1); + all_args.push(prefix.to_string()); + all_args.extend(args.iter().cloned()); + run_passthrough(&all_args, verbose) +} + +fn run_passthrough(args: &[String], verbose: u8) -> Result<()> { + let timer = tracking::TimedExecution::start(); + + let mut cmd = Command::new("uv"); + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: uv {}", args.join(" ")); + } + + let output = cmd + .output() + .with_context(|| format!("Failed to run uv {}", args.join(" ")))?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let raw = format!("{}\n{}", stdout, stderr); + + print!("{}", stdout); + eprint!("{}", stderr); + + timer.track( + &format!("uv {}", args.join(" ")), + &format!("rtk uv {}", args.join(" ")), + &raw, + &raw, + ); + + if !output.status.success() { + std::process::exit(output.status.code().unwrap_or(1)); + } + + Ok(()) +} + +fn filter_uv_sync_output(output: &str) -> String { + let mut summary_lines = Vec::new(); + let mut package_lines = Vec::new(); + + for raw_line in output.lines() { + let line = raw_line.trim(); + if line.is_empty() { + continue; + } + + let lower = line.to_ascii_lowercase(); + let is_summary = line.starts_with("Resolved") + || line.starts_with("Prepared") + || line.starts_with("Installed") + || line.starts_with("Uninstalled") + || line.starts_with("Added") + || line.starts_with("Removed") + || line.starts_with("Updated") + || line.starts_with("Audited"); + let is_error = lower.contains("error") || lower.contains("failed"); + let is_package_delta = line.starts_with('+') || line.starts_with('-'); + + if is_summary || is_error { + summary_lines.push(line.to_string()); + } else if is_package_delta { + package_lines.push(line.to_string()); + } + } + + if summary_lines.is_empty() && package_lines.is_empty() { + return "✓ uv sync completed".to_string(); + } + + let mut out = String::from("uv sync\n═══════════════════════════════════════\n"); + for line in summary_lines { + out.push_str(&line); + out.push('\n'); + } + + if !package_lines.is_empty() { + out.push_str("\nPackage changes:\n"); + for line in package_lines.iter().take(15) { + out.push_str(line); + out.push('\n'); + } + if package_lines.len() > 15 { + out.push_str(&format!("... +{} more\n", package_lines.len() - 15)); + } + } + + out.trim().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_filter_uv_sync_output_summary() { + let output = r#" +Resolved 145 packages in 4ms +Installed 2 packages in 21ms ++ pytest==8.3.2 +- pytest==8.2.0 +"#; + let filtered = filter_uv_sync_output(output); + assert!(filtered.contains("uv sync")); + assert!(filtered.contains("Resolved 145 packages")); + assert!(filtered.contains("Installed 2 packages")); + assert!(filtered.contains("pytest==8.3.2")); + } + + #[test] + fn test_filter_uv_sync_output_fallback() { + let filtered = filter_uv_sync_output("done"); + assert_eq!(filtered, "✓ uv sync completed"); + } + + #[test] + fn test_is_python_executable() { + assert!(is_python_executable("python")); + assert!(is_python_executable("python3")); + assert!(is_python_executable(".ven/bin/python")); + assert!(is_python_executable(".venv/bin/python")); + assert!(is_python_executable("/tmp/venv/bin/python3")); + assert!(!is_python_executable("pytest")); + assert!(!is_python_executable(".venv/bin/pypy")); + } + + #[test] + fn test_routed_uv_run_tool_direct() { + assert_eq!( + routed_uv_run_tool(&["mypy".to_string(), "src/".to_string()]), + Some(("mypy", 1)) + ); + assert_eq!( + routed_uv_run_tool(&["ruff".to_string(), "check".to_string()]), + Some(("ruff", 1)) + ); + assert_eq!( + routed_uv_run_tool(&["pytest".to_string(), "-q".to_string()]), + Some(("pytest", 1)) + ); + } + + #[test] + fn test_routed_uv_run_tool_python_module() { + assert_eq!( + routed_uv_run_tool(&[ + ".venv/bin/python".to_string(), + "-m".to_string(), + "pylint".to_string(), + ".".to_string() + ]), + Some(("pylint", 3)) + ); + assert_eq!( + routed_uv_run_tool(&[ + ".ven/bin/python".to_string(), + "-m".to_string(), + "flake8".to_string(), + ".".to_string() + ]), + Some(("flake8", 3)) + ); + assert_eq!( + routed_uv_run_tool(&[ + "python3".to_string(), + "-m".to_string(), + "mypy".to_string(), + "src".to_string() + ]), + Some(("mypy", 3)) + ); + } + + #[test] + fn test_routed_uv_run_tool_passthrough_cases() { + assert_eq!( + routed_uv_run_tool(&["python".to_string(), "-m".to_string(), "black".to_string()]), + None + ); + assert_eq!( + routed_uv_run_tool(&["alembic".to_string(), "upgrade".to_string()]), + None + ); + } +}