diff --git a/src/analytics/README.md b/src/analytics/README.md index 584b52d401..b6c6517056 100644 --- a/src/analytics/README.md +++ b/src/analytics/README.md @@ -4,7 +4,7 @@ ## Scope -**Read-only dashboards** over the tracking database. Analytics presents the value that `cmds/` creates — it queries token savings, correlates with external spending data, and surfaces adoption opportunities. It never modifies the tracking DB. +**Read-only dashboards** over the tracking database. Queries token savings, correlates with external spending data, and surfaces adoption metrics. Never modifies the tracking DB. Owns: `rtk gain` (savings dashboard), `rtk cc-economics` (cost reduction), `rtk session` (adoption analysis), and Claude Code usage data parsing. @@ -15,7 +15,7 @@ Boundary rule: if a new module writes to the DB, it belongs in `core/` or `cmds/ ## Purpose Token savings analytics, economic modeling, and adoption metrics. -These modules read from the SQLite tracking database to produce dashboards, spending estimates, and session-level adoption reports that help users understand the value RTK provides. +These modules read from the SQLite tracking database to produce dashboards, spending estimates, and session-level adoption reports. ## Adding New Functionality To add a new analytics view: (1) create a new `*_cmd.rs` file in this directory, (2) query `core/tracking` for the metrics you need using the existing `TrackingDb` API, (3) register the command in `main.rs` under the `Commands` enum, and (4) add `#[cfg(test)]` unit tests with sample tracking data. Analytics modules should be read-only against the tracking database and never modify it. diff --git a/src/analytics/cc_economics.rs b/src/analytics/cc_economics.rs index 693dc61e2d..0375931027 100644 --- a/src/analytics/cc_economics.rs +++ b/src/analytics/cc_economics.rs @@ -14,9 +14,6 @@ use crate::core::utils::{format_cpt, format_tokens, format_usd}; // ── Constants ── -#[allow(dead_code)] -const BILLION: f64 = 1e9; - // API pricing ratios (verified Feb 2026, consistent across Claude models <=200K context) // Source: https://docs.anthropic.com/en/docs/about-claude/models const WEIGHT_OUTPUT: f64 = 5.0; // Output = 5x input diff --git a/src/analytics/ccusage.rs b/src/analytics/ccusage.rs index 49bd5bc8da..15d73109be 100644 --- a/src/analytics/ccusage.rs +++ b/src/analytics/ccusage.rs @@ -111,12 +111,6 @@ fn build_command() -> Option { None } -/// Check if ccusage CLI is available (binary or via npx) -#[allow(dead_code)] -pub fn is_available() -> bool { - build_command().is_some() -} - /// Fetch usage data from ccusage for the last 90 days /// /// Returns `Ok(None)` if ccusage is unavailable (graceful degradation) @@ -328,11 +322,4 @@ mod tests { assert_eq!(periods[0].metrics.cache_creation_tokens, 0); // default assert_eq!(periods[0].metrics.cache_read_tokens, 0); } - - #[test] - fn test_is_available() { - // Just smoke test - actual availability depends on system - let _available = is_available(); - // No assertion - just ensure it doesn't panic - } } diff --git a/src/cmds/README.md b/src/cmds/README.md index a84e8e7442..f48119d55f 100644 --- a/src/cmds/README.md +++ b/src/cmds/README.md @@ -2,7 +2,7 @@ ## Scope -**Command execution and output filtering** — this is the core value RTK delivers. Every module here calls an external CLI tool (`Command::new("some_tool")`), transforms its stdout/stderr to reduce token consumption, and records savings via `core/tracking`. +**Command execution and output filtering.** Every module here calls an external CLI tool (`Command::new("some_tool")`), transforms its stdout/stderr to reduce token consumption, and records savings via `core/tracking`. Owns: all command-specific filter logic, organized by ecosystem (git, rust, js, python, go, dotnet, cloud, system). Cross-ecosystem routing (e.g., `lint_cmd` detecting Python and delegating to `ruff_cmd`) is an intra-component concern. @@ -35,47 +35,92 @@ Each subdirectory has its own README with file descriptions, parsing strategies, - **[`system/`](system/README.md)** — ls, tree, read, grep, find, wc, env, json, log, deps, summary, format, smart — format_cmd routing, filter levels, language detection - **[`ruby/`](ruby/README.md)** — rake/rails test, rspec, rubocop — JSON injection pattern, `ruby_exec()` bundle exec auto-detection -## Common Pattern +## Execution Flow: `runner::run_filtered()` -Every command module follows this structure: +The shared wrapper in [`core/runner.rs`](../core/runner.rs) encapsulates the six-phase execution skeleton. Modules build the `Command` (custom arg logic), then delegate to `run_filtered()` for everything else. + +``` + cmd.output() Filter applied to tee_and_hint() + | stdout or combined | + v | v + +---------+ stdout +-------+-------+ filtered +-------+ + | Execute |--------->| filter_fn() |----------->| Print | + +---------+ stderr +---------------+ +-------+ + | | + v v + +----------+ +---------+ + | raw = | | Track | + | stdout + | | savings | + | stderr | +---------+ + +----------+ | + v + +-----------+ + | Ok(code) | + | returned | + +-----------+ +``` + +**Six phases in order:** + +1. **Execute** — `cmd.output()` captures stdout + stderr +2. **Filter** — `filter_fn` receives stdout-only or combined, returns compressed string +3. **Print** — filtered output printed; if tee enabled, appends recovery hint on failure +4. **Stderr passthrough** — when `filter_stdout_only`: stderr printed via `eprintln!()` unconditionally +5. **Track** — `timer.track()` records raw vs filtered for token savings +6. **Exit code** — returns `Ok(exit_code)` to caller; `main.rs` calls `process::exit(code)` once + +**`RunOptions` builder:** + +| Constructor | Behavior | +|-------------|----------| +| `RunOptions::default()` | Combined stdout+stderr to filter, no tee | +| `RunOptions::with_tee("label")` | Combined filtering + tee recovery | +| `RunOptions::stdout_only()` | Stdout-only to filter, stderr passthrough, no tee | +| `RunOptions::stdout_only().tee("label")` | Stdout-only + tee recovery | + +**Example — filtered command (recommended):** ```rust -pub fn run(args: MyArgs, verbose: u8) -> Result<()> { - let timer = tracking::TimedExecution::start(); - let output = resolved_command("mycmd").args(&args).output().context("Failed to execute mycmd")?; - let raw = format!("{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); - - let filtered = filter_output(&raw).unwrap_or_else(|e| { - eprintln!("rtk: filter warning: {}", e); - raw.clone() // Fallback to raw on filter failure - }); - - let exit_code = output.status.code().unwrap_or(1); - if let Some(hint) = tee::tee_and_hint(&raw, "mycmd", exit_code) { - println!("{}\n{}", filtered, hint); - } else { - println!("{}", filtered); - } - - timer.track("mycmd args", "rtk mycmd args", &raw, &filtered); - if !output.status.success() { std::process::exit(exit_code); } - Ok(()) +pub fn run(args: &[String], verbose: u8) -> Result { + let mut cmd = resolved_command("mycmd"); + for arg in args { cmd.arg(arg); } + if verbose > 0 { eprintln!("Running: mycmd {}", args.join(" ")); } + + runner::run_filtered( + cmd, "mycmd", &args.join(" "), + filter_mycmd_output, + runner::RunOptions::stdout_only().tee("mycmd"), + ) } ``` -Six phases: **timer** → **execute** → **filter (with fallback)** → **tee on failure** → **track** → **exit code**. See [core/README.md](../core/README.md#consumer-contracts) for the contracts each phase must honor. +Exit code handling is **fully automatic** when using `run_filtered()` — the wrapper extracts the exit code (including Unix signal handling via 128+signal), tracks savings, and returns `Ok(exit_code)`. Module authors just return the result. -## Token Savings by Category +**Example — passthrough command (no filtering):** + +```rust +pub fn run_passthrough(args: &[OsString], verbose: u8) -> Result { + let status = resolved_command("mycmd").args(args) + .stdin(Stdio::inherit()).stdout(Stdio::inherit()).stderr(Stdio::inherit()) + .status().context("Failed to run mycmd")?; + Ok(exit_code_from_status(&status, "mycmd")) +} +``` + +**Example — manual execution (custom logic):** + +```rust +pub fn run(args: &[String], verbose: u8) -> Result { + let output = resolved_command("mycmd").args(args) + .output().context("Failed to run mycmd")?; + let exit_code = exit_code_from_output(&output, "mycmd"); + // ... custom filtering, tracking ... + Ok(exit_code) +} +``` + +Modules with deviations (subcommand dispatch, parser trait systems, two-command fallback, synthetic output). -| Category | Commands | Typical Savings | Strategy | -|----------|----------|----------------|----------| -| Test Runners | vitest, pytest, cargo test, go test, playwright | 90-99% | Show failures only, aggregate passes | -| Build Tools | cargo build, npm, pnpm, dotnet | 70-90% | Strip progress bars, summarize errors | -| VCS | git status/log/diff/show | 70-80% | Compact commit hashes, stat summaries | -| Linters | eslint/biome, ruff, tsc, mypy, golangci-lint | 80-85% | Group by file/rule, strip context | -| Package Managers | pip, cargo install, pnpm list | 75-80% | Remove decorative output, compact trees | -| File Operations | ls, find, grep, cat/head/tail | 60-75% | Tree format, grouped results, truncation | -| Infrastructure | docker, kubectl, aws, terraform | 75-85% | Essential info only | ## Cross-Command Dependencies @@ -89,7 +134,25 @@ These behaviors must be uniform across all command modules. Full audit details i ### Exit Code Propagation -Modules must capture the underlying command's exit code, propagate it via `std::process::exit()` only on failure, and return `Ok(())` on success. When the process is killed by signal (`.code()` returns `None`), default to exit code 1. +All module `run()` functions return `Result` where the `i32` is the underlying command's exit code. `main.rs` calls `std::process::exit(code)` once at the single exit point — **modules never call `process::exit()` directly**. + +| Return value | Meaning | Who exits | +|--------------|---------|-----------| +| `Ok(0)` | Command succeeded | `main.rs` exits 0 | +| `Ok(N)` | Command failed with code N | `main.rs` exits N | +| `Err(e)` | RTK itself failed (not the command) | `main.rs` prints error, exits 1 | + +**How exit codes are extracted:** + +| Execution style | Helper | Signal handling | +|----------------|--------|-----------------| +| `cmd.output()` (filtered) | `exit_code_from_output(&output, "tool")` | 128+signal on Unix | +| `cmd.status()` (passthrough) | `exit_code_from_status(&status, "tool")` | 128+signal on Unix | +| `run_filtered()` (wrapper) | Automatic — no manual code needed | Built-in | + +**When using `run_filtered()`**: exit code handling is fully automatic. The wrapper extracts the exit code, handles signals, and returns `Ok(exit_code)`. Module authors just return the wrapper's result — no exit code logic needed. + +**When doing manual execution**: use `exit_code_from_output()` or `exit_code_from_status()` and return `Ok(exit_code)`. Never call `process::exit()`, never use `.code().unwrap_or(1)` (loses signal info). ### Filter Failure Passthrough @@ -105,50 +168,36 @@ Modules must capture stderr and include it in the raw string passed to `timer.tr ### Tracking Completeness -All modules must call `timer.track()` on every path — success, failure, and fallback. Never exit before tracking. +All modules must call `timer.track()` on every path — success, failure, and fallback. Since modules return `Ok(exit_code)` instead of calling `process::exit()`, tracking always runs before the program exits. ### Verbose Flag All modules accept `verbose: u8`. Use it to print debug info (command being run, savings %, filter tier). Do not accept and ignore it. -### Gaps (to be fixed) - -**Exit code** — 5 different patterns coexist, should be reviewed for uniform behavior: -- `vitest_cmd.rs`, `tsc_cmd.rs`, `psql_cmd.rs` — exit unconditionally, even on success -- `lint_cmd.rs` — swallows signal kills silently -- `golangci_cmd.rs` — maps signal kill to exit 130 (correct but unique) - -**Filter passthrough** — silent passthrough, no warning: -- `gh_cmd.rs`, `pip_cmd.rs`, `container.rs`, `dotnet_cmd.rs` — `run_passthrough()` skips filtering without warning -- `pnpm_cmd.rs` — 3-tier degradation but no tee recovery on final tier - -**Tee recovery** — missing from some high-risk modules: -- `pnpm_cmd.rs` — 3-tier parser, no tee -- `gh_cmd.rs` — aggressive markdown filtering, no tee -- `ruff_cmd.rs`, `golangci_cmd.rs` — JSON parsers, no tee -- `psql_cmd.rs` — has tee but exits before calling it on error path - -**Stderr handling** — 3 patterns coexist. Some modules combine stderr into raw (correct), others print via `eprintln!()` and exclude from tracking (inflates savings %). See `docs/ISO_ANALYZE.md` section 4. - -**Tracking** — exit before track on error path: -- `ls.rs`, `tree.rs` — lost metrics on failure -- `container.rs` — inconsistent across subcommands - -**Verbose** — accept parameter but ignore it: -- `container.rs` — all internal functions prefix `_verbose` -- `diff_cmd.rs` — `_verbose` unused ## Adding a New Command Filter -Adding a new filter or command requires changes in multiple places: +Adding a new filter or command requires changes in multiple places. For TOML-vs-Rust decision criteria, see [CONTRIBUTING.md](../../CONTRIBUTING.md#toml-vs-rust-which-one). + +### Rust module (structured output, flag injection, state machines) -1. **Create the filter** — TOML file in [`src/filters/`](../filters/README.md) or Rust module in `src/cmds//` -2. **Add rewrite pattern** — Entry in `src/discover/rules.rs` (PATTERNS + RULES arrays at matching index) so hooks auto-rewrite the command -3. **Register in main.rs** — (Rust modules only) Three changes: - - Add `pub mod mymod;` to the ecosystem's `mod.rs` (e.g., `src/cmds/system/mod.rs`) +1. **Create module** in `src/cmds//mycmd_cmd.rs`: + - Write the `filter_mycmd()` function (pure: `&str -> String`, no side effects) + - Write `pub fn run(...) -> Result` using `runner::run_filtered()` — build the `Command`, choose `RunOptions`, delegate + - Use `RunOptions::stdout_only()` when the filter parses structured stdout (JSON, NDJSON) — stderr would corrupt parsing + - Use `RunOptions::default()` when filtering combined text output + - Add `.tee("label")` when the filter parses structured output (enables raw output recovery on failure) + - **Exit codes**: handled automatically by `run_filtered()` — just return its result +2. **Register module**: + - Add `pub mod mycmd_cmd;` to the ecosystem's `mod.rs` - Add variant to `Commands` enum in `main.rs` with `#[arg(trailing_var_arg = true, allow_hyphen_values = true)]` - - Add routing match arm in `main.rs` to call `mymod::run()` + - Add routing match arm in `main.rs`: `Commands::Mycmd { args } => mycmd_cmd::run(&args, cli.verbose)?,` +3. **Add rewrite pattern** — Entry in `src/discover/rules.rs` (PATTERNS + RULES arrays at matching index) so hooks auto-rewrite the command 4. **Write tests** — Real fixture, snapshot test, token savings >= 60% (see [testing rules](../../.claude/rules/cli-testing.md)) -5. **Update docs** — README.md command list, CHANGELOG.md +5. **Update docs** — Ecosystem README, CHANGELOG.md + +### TOML filter (simple line-based filtering) -Follow the [Common Pattern](#common-pattern) above for the module template (timer, fallback, tee, tracking, exit code). For TOML-vs-Rust decision criteria, see [CONTRIBUTING.md](../../CONTRIBUTING.md#toml-vs-rust-which-one). +1. **Create filter** in [`src/filters/`](../filters/README.md) +2. **Add rewrite pattern** in `src/discover/rules.rs` +3. **Write tests** and **update docs** diff --git a/src/cmds/cloud/aws_cmd.rs b/src/cmds/cloud/aws_cmd.rs index bb1757ec13..2b1e9929ff 100644 --- a/src/cmds/cloud/aws_cmd.rs +++ b/src/cmds/cloud/aws_cmd.rs @@ -4,7 +4,10 @@ //! Specialized filters for high-frequency commands (STS, S3, EC2, ECS, RDS, CloudFormation). use crate::core::tracking; -use crate::core::utils::{join_with_overflow, resolved_command, truncate_iso_date}; +use crate::core::utils::{ + exit_code_from_output, exit_code_from_status, join_with_overflow, resolved_command, + truncate_iso_date, +}; use crate::json_cmd; use anyhow::{Context, Result}; use serde_json::Value; @@ -13,7 +16,7 @@ const MAX_ITEMS: usize = 20; const JSON_COMPRESS_DEPTH: usize = 4; /// Run an AWS CLI command with token-optimized output -pub fn run(subcommand: &str, args: &[String], verbose: u8) -> Result<()> { +pub fn run(subcommand: &str, args: &[String], verbose: u8) -> Result { // Build the full sub-path: e.g. "sts" + ["get-caller-identity"] -> "sts get-caller-identity" let full_sub = if args.is_empty() { subcommand.to_string() @@ -58,7 +61,7 @@ fn is_structured_operation(args: &[String]) -> bool { } /// Generic strategy: force --output json for structured ops, compress via json_cmd schema -fn run_generic(subcommand: &str, args: &[String], verbose: u8, full_sub: &str) -> Result<()> { +fn run_generic(subcommand: &str, args: &[String], verbose: u8, full_sub: &str) -> Result { let timer = tracking::TimedExecution::start(); let mut cmd = resolved_command("aws"); @@ -95,7 +98,7 @@ fn run_generic(subcommand: &str, args: &[String], verbose: u8, full_sub: &str) - &stderr, ); eprintln!("{}", stderr.trim()); - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "aws")); } let filtered = match json_cmd::filter_json_string(&raw, JSON_COMPRESS_DEPTH) { @@ -117,7 +120,7 @@ fn run_generic(subcommand: &str, args: &[String], verbose: u8, full_sub: &str) - &filtered, ); - Ok(()) + Ok(0) } fn run_aws_json( @@ -163,7 +166,7 @@ fn run_aws_json( Ok((stdout, stderr, output.status)) } -fn run_sts_identity(extra_args: &[String], verbose: u8) -> Result<()> { +fn run_sts_identity(extra_args: &[String], verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); let (raw, stderr, status) = run_aws_json(&["sts", "get-caller-identity"], extra_args, verbose)?; @@ -174,7 +177,7 @@ fn run_sts_identity(extra_args: &[String], verbose: u8) -> Result<()> { &stderr, &stderr, ); - std::process::exit(status.code().unwrap_or(1)); + return Ok(exit_code_from_status(&status, "aws")); } let filtered = match filter_sts_identity(&raw) { @@ -189,10 +192,10 @@ fn run_sts_identity(extra_args: &[String], verbose: u8) -> Result<()> { &raw, &filtered, ); - Ok(()) + Ok(0) } -fn run_s3_ls(extra_args: &[String], verbose: u8) -> Result<()> { +fn run_s3_ls(extra_args: &[String], verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); // s3 ls doesn't support --output json, run as-is and filter text @@ -213,17 +216,17 @@ fn run_s3_ls(extra_args: &[String], verbose: u8) -> Result<()> { let stderr = String::from_utf8_lossy(&output.stderr).to_string(); timer.track("aws s3 ls", "rtk aws s3 ls", &stderr, &stderr); eprintln!("{}", stderr.trim()); - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "aws")); } let filtered = filter_s3_ls(&raw); println!("{}", filtered); timer.track("aws s3 ls", "rtk aws s3 ls", &raw, &filtered); - Ok(()) + Ok(0) } -fn run_ec2_describe(extra_args: &[String], verbose: u8) -> Result<()> { +fn run_ec2_describe(extra_args: &[String], verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); let (raw, stderr, status) = run_aws_json(&["ec2", "describe-instances"], extra_args, verbose)?; @@ -234,7 +237,7 @@ fn run_ec2_describe(extra_args: &[String], verbose: u8) -> Result<()> { &stderr, &stderr, ); - std::process::exit(status.code().unwrap_or(1)); + return Ok(exit_code_from_status(&status, "aws")); } let filtered = match filter_ec2_instances(&raw) { @@ -249,10 +252,10 @@ fn run_ec2_describe(extra_args: &[String], verbose: u8) -> Result<()> { &raw, &filtered, ); - Ok(()) + Ok(0) } -fn run_ecs_list_services(extra_args: &[String], verbose: u8) -> Result<()> { +fn run_ecs_list_services(extra_args: &[String], verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); let (raw, stderr, status) = run_aws_json(&["ecs", "list-services"], extra_args, verbose)?; @@ -263,7 +266,7 @@ fn run_ecs_list_services(extra_args: &[String], verbose: u8) -> Result<()> { &stderr, &stderr, ); - std::process::exit(status.code().unwrap_or(1)); + return Ok(exit_code_from_status(&status, "aws")); } let filtered = match filter_ecs_list_services(&raw) { @@ -278,10 +281,10 @@ fn run_ecs_list_services(extra_args: &[String], verbose: u8) -> Result<()> { &raw, &filtered, ); - Ok(()) + Ok(0) } -fn run_ecs_describe_services(extra_args: &[String], verbose: u8) -> Result<()> { +fn run_ecs_describe_services(extra_args: &[String], verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); let (raw, stderr, status) = run_aws_json(&["ecs", "describe-services"], extra_args, verbose)?; @@ -292,7 +295,7 @@ fn run_ecs_describe_services(extra_args: &[String], verbose: u8) -> Result<()> { &stderr, &stderr, ); - std::process::exit(status.code().unwrap_or(1)); + return Ok(exit_code_from_status(&status, "aws")); } let filtered = match filter_ecs_describe_services(&raw) { @@ -307,10 +310,10 @@ fn run_ecs_describe_services(extra_args: &[String], verbose: u8) -> Result<()> { &raw, &filtered, ); - Ok(()) + Ok(0) } -fn run_rds_describe(extra_args: &[String], verbose: u8) -> Result<()> { +fn run_rds_describe(extra_args: &[String], verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); let (raw, stderr, status) = run_aws_json(&["rds", "describe-db-instances"], extra_args, verbose)?; @@ -322,7 +325,7 @@ fn run_rds_describe(extra_args: &[String], verbose: u8) -> Result<()> { &stderr, &stderr, ); - std::process::exit(status.code().unwrap_or(1)); + return Ok(exit_code_from_status(&status, "aws")); } let filtered = match filter_rds_instances(&raw) { @@ -337,10 +340,10 @@ fn run_rds_describe(extra_args: &[String], verbose: u8) -> Result<()> { &raw, &filtered, ); - Ok(()) + Ok(0) } -fn run_cfn_list_stacks(extra_args: &[String], verbose: u8) -> Result<()> { +fn run_cfn_list_stacks(extra_args: &[String], verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); let (raw, stderr, status) = run_aws_json(&["cloudformation", "list-stacks"], extra_args, verbose)?; @@ -352,7 +355,7 @@ fn run_cfn_list_stacks(extra_args: &[String], verbose: u8) -> Result<()> { &stderr, &stderr, ); - std::process::exit(status.code().unwrap_or(1)); + return Ok(exit_code_from_status(&status, "aws")); } let filtered = match filter_cfn_list_stacks(&raw) { @@ -367,10 +370,10 @@ fn run_cfn_list_stacks(extra_args: &[String], verbose: u8) -> Result<()> { &raw, &filtered, ); - Ok(()) + Ok(0) } -fn run_cfn_describe_stacks(extra_args: &[String], verbose: u8) -> Result<()> { +fn run_cfn_describe_stacks(extra_args: &[String], verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); let (raw, stderr, status) = run_aws_json(&["cloudformation", "describe-stacks"], extra_args, verbose)?; @@ -382,7 +385,7 @@ fn run_cfn_describe_stacks(extra_args: &[String], verbose: u8) -> Result<()> { &stderr, &stderr, ); - std::process::exit(status.code().unwrap_or(1)); + return Ok(exit_code_from_status(&status, "aws")); } let filtered = match filter_cfn_describe_stacks(&raw) { @@ -397,7 +400,7 @@ fn run_cfn_describe_stacks(extra_args: &[String], verbose: u8) -> Result<()> { &raw, &filtered, ); - Ok(()) + Ok(0) } // --- Filter functions (all use serde_json::Value for resilience) --- diff --git a/src/cmds/cloud/container.rs b/src/cmds/cloud/container.rs index b4e0057ab8..0d6cede85d 100644 --- a/src/cmds/cloud/container.rs +++ b/src/cmds/cloud/container.rs @@ -1,7 +1,7 @@ //! Filters Docker and kubectl output into compact summaries. use crate::core::tracking; -use crate::core::utils::resolved_command; +use crate::core::utils::{exit_code_from_output, exit_code_from_status, resolved_command}; use anyhow::{Context, Result}; use std::ffi::OsString; @@ -15,7 +15,7 @@ pub enum ContainerCmd { KubectlLogs, } -pub fn run(cmd: ContainerCmd, args: &[String], verbose: u8) -> Result<()> { +pub fn run(cmd: ContainerCmd, args: &[String], verbose: u8) -> Result { match cmd { ContainerCmd::DockerPs => docker_ps(verbose), ContainerCmd::DockerImages => docker_images(verbose), @@ -26,7 +26,7 @@ pub fn run(cmd: ContainerCmd, args: &[String], verbose: u8) -> Result<()> { } } -fn docker_ps(_verbose: u8) -> Result<()> { +fn docker_ps(_verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); let raw = resolved_command("docker") @@ -48,7 +48,7 @@ fn docker_ps(_verbose: u8) -> Result<()> { let stderr = String::from_utf8_lossy(&output.stderr); eprint!("{}", stderr); timer.track("docker ps", "rtk docker ps", &raw, &raw); - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "docker")); } let stdout = String::from_utf8_lossy(&output.stdout); @@ -58,7 +58,7 @@ fn docker_ps(_verbose: u8) -> Result<()> { rtk.push_str("[docker] 0 containers"); println!("{}", rtk); timer.track("docker ps", "rtk docker ps", &raw, &rtk); - return Ok(()); + return Ok(0); } let count = stdout.lines().count(); @@ -92,10 +92,10 @@ fn docker_ps(_verbose: u8) -> Result<()> { print!("{}", rtk); timer.track("docker ps", "rtk docker ps", &raw, &rtk); - Ok(()) + Ok(0) } -fn docker_images(_verbose: u8) -> Result<()> { +fn docker_images(_verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); let raw = resolved_command("docker") @@ -113,7 +113,7 @@ fn docker_images(_verbose: u8) -> Result<()> { let stderr = String::from_utf8_lossy(&output.stderr); eprint!("{}", stderr); timer.track("docker images", "rtk docker images", &raw, &raw); - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "docker")); } let stdout = String::from_utf8_lossy(&output.stdout); @@ -124,7 +124,7 @@ fn docker_images(_verbose: u8) -> Result<()> { rtk.push_str("[docker] 0 images"); println!("{}", rtk); timer.track("docker images", "rtk docker images", &raw, &rtk); - return Ok(()); + return Ok(0); } let mut total_size_mb: f64 = 0.0; @@ -173,16 +173,16 @@ fn docker_images(_verbose: u8) -> Result<()> { print!("{}", rtk); timer.track("docker images", "rtk docker images", &raw, &rtk); - Ok(()) + Ok(0) } -fn docker_logs(args: &[String], _verbose: u8) -> Result<()> { +fn docker_logs(args: &[String], _verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); let container = args.first().map(|s| s.as_str()).unwrap_or(""); if container.is_empty() { println!("Usage: rtk docker logs "); - return Ok(()); + return Ok(0); } let output = resolved_command("docker") @@ -204,7 +204,7 @@ fn docker_logs(args: &[String], _verbose: u8) -> Result<()> { &raw, &raw, ); - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "docker")); } let analyzed = crate::log_cmd::run_stdin_str(&raw); @@ -216,10 +216,10 @@ fn docker_logs(args: &[String], _verbose: u8) -> Result<()> { &raw, &rtk, ); - Ok(()) + Ok(0) } -fn kubectl_pods(args: &[String], _verbose: u8) -> Result<()> { +fn kubectl_pods(args: &[String], _verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); let mut cmd = resolved_command("kubectl"); @@ -238,7 +238,7 @@ fn kubectl_pods(args: &[String], _verbose: u8) -> Result<()> { eprint!("{}", stderr); } timer.track("kubectl get pods", "rtk kubectl pods", &raw, &raw); - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "kubectl")); } let json: serde_json::Value = match serde_json::from_str(&raw) { @@ -247,7 +247,7 @@ fn kubectl_pods(args: &[String], _verbose: u8) -> Result<()> { rtk.push_str("No pods found"); println!("{}", rtk); timer.track("kubectl get pods", "rtk kubectl pods", &raw, &rtk); - return Ok(()); + return Ok(0); } }; @@ -255,7 +255,7 @@ fn kubectl_pods(args: &[String], _verbose: u8) -> Result<()> { rtk.push_str("No pods found"); println!("{}", rtk); timer.track("kubectl get pods", "rtk kubectl pods", &raw, &rtk); - return Ok(()); + return Ok(0); }; let (mut running, mut pending, mut failed, mut restarts_total) = (0, 0, 0, 0i64); let mut issues: Vec = Vec::new(); @@ -323,10 +323,10 @@ fn kubectl_pods(args: &[String], _verbose: u8) -> Result<()> { print!("{}", rtk); timer.track("kubectl get pods", "rtk kubectl pods", &raw, &rtk); - Ok(()) + Ok(0) } -fn kubectl_services(args: &[String], _verbose: u8) -> Result<()> { +fn kubectl_services(args: &[String], _verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); let mut cmd = resolved_command("kubectl"); @@ -345,7 +345,7 @@ fn kubectl_services(args: &[String], _verbose: u8) -> Result<()> { eprint!("{}", stderr); } timer.track("kubectl get svc", "rtk kubectl svc", &raw, &raw); - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "kubectl")); } let json: serde_json::Value = match serde_json::from_str(&raw) { @@ -354,7 +354,7 @@ fn kubectl_services(args: &[String], _verbose: u8) -> Result<()> { rtk.push_str("No services found"); println!("{}", rtk); timer.track("kubectl get svc", "rtk kubectl svc", &raw, &rtk); - return Ok(()); + return Ok(0); } }; @@ -362,7 +362,7 @@ fn kubectl_services(args: &[String], _verbose: u8) -> Result<()> { rtk.push_str("No services found"); println!("{}", rtk); timer.track("kubectl get svc", "rtk kubectl svc", &raw, &rtk); - return Ok(()); + return Ok(0); }; rtk.push_str(&format!("{} services:\n", services.len())); @@ -403,16 +403,16 @@ fn kubectl_services(args: &[String], _verbose: u8) -> Result<()> { print!("{}", rtk); timer.track("kubectl get svc", "rtk kubectl svc", &raw, &rtk); - Ok(()) + Ok(0) } -fn kubectl_logs(args: &[String], _verbose: u8) -> Result<()> { +fn kubectl_logs(args: &[String], _verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); let pod = args.first().map(|s| s.as_str()).unwrap_or(""); if pod.is_empty() { println!("Usage: rtk kubectl logs "); - return Ok(()); + return Ok(0); } let mut cmd = resolved_command("kubectl"); @@ -435,7 +435,7 @@ fn kubectl_logs(args: &[String], _verbose: u8) -> Result<()> { &raw, &raw, ); - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "kubectl")); } let analyzed = crate::log_cmd::run_stdin_str(&raw); @@ -447,7 +447,7 @@ fn kubectl_logs(args: &[String], _verbose: u8) -> Result<()> { &raw, &rtk, ); - Ok(()) + Ok(0) } /// Format `docker compose ps --format` output into compact form. @@ -588,7 +588,7 @@ fn compact_ports(ports: &str) -> String { } /// Runs an unsupported docker subcommand by passing it through directly -pub fn run_docker_passthrough(args: &[OsString], verbose: u8) -> Result<()> { +pub fn run_docker_passthrough(args: &[OsString], verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); if verbose > 0 { @@ -605,14 +605,11 @@ pub fn run_docker_passthrough(args: &[OsString], verbose: u8) -> Result<()> { &format!("rtk docker {} (passthrough)", args_str), ); - if !status.success() { - std::process::exit(status.code().unwrap_or(1)); - } - Ok(()) + Ok(exit_code_from_status(&status, "docker")) } /// Run `docker compose ps` with compact output -pub fn run_compose_ps(verbose: u8) -> Result<()> { +pub fn run_compose_ps(verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); // Raw output for token tracking @@ -624,7 +621,7 @@ pub fn run_compose_ps(verbose: u8) -> Result<()> { if !raw_output.status.success() { let stderr = String::from_utf8_lossy(&raw_output.stderr); eprintln!("{}", stderr); - std::process::exit(raw_output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&raw_output, "docker")); } let raw = String::from_utf8_lossy(&raw_output.stdout).to_string(); @@ -642,7 +639,7 @@ pub fn run_compose_ps(verbose: u8) -> Result<()> { if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); eprintln!("{}", stderr); - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "docker")); } let structured = String::from_utf8_lossy(&output.stdout).to_string(); @@ -653,11 +650,11 @@ pub fn run_compose_ps(verbose: u8) -> Result<()> { let rtk = format_compose_ps(&structured); println!("{}", rtk); timer.track("docker compose ps", "rtk docker compose ps", &raw, &rtk); - Ok(()) + Ok(0) } /// Run `docker compose logs` with deduplication -pub fn run_compose_logs(service: Option<&str>, verbose: u8) -> Result<()> { +pub fn run_compose_logs(service: Option<&str>, verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); let mut cmd = resolved_command("docker"); @@ -671,7 +668,7 @@ pub fn run_compose_logs(service: Option<&str>, verbose: u8) -> Result<()> { if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); eprintln!("{}", stderr); - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "docker")); } let stdout = String::from_utf8_lossy(&output.stdout); @@ -691,11 +688,11 @@ pub fn run_compose_logs(service: Option<&str>, verbose: u8) -> Result<()> { &raw, &rtk, ); - Ok(()) + Ok(0) } /// Run `docker compose build` with summary output -pub fn run_compose_build(service: Option<&str>, verbose: u8) -> Result<()> { +pub fn run_compose_build(service: Option<&str>, verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); let mut cmd = resolved_command("docker"); @@ -709,7 +706,7 @@ pub fn run_compose_build(service: Option<&str>, verbose: u8) -> Result<()> { if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); eprintln!("{}", stderr); - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "docker")); } let stdout = String::from_utf8_lossy(&output.stdout); @@ -729,11 +726,11 @@ pub fn run_compose_build(service: Option<&str>, verbose: u8) -> Result<()> { &raw, &rtk, ); - Ok(()) + Ok(0) } /// Runs an unsupported docker compose subcommand by passing it through directly -pub fn run_compose_passthrough(args: &[OsString], verbose: u8) -> Result<()> { +pub fn run_compose_passthrough(args: &[OsString], verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); if verbose > 0 { @@ -751,14 +748,11 @@ pub fn run_compose_passthrough(args: &[OsString], verbose: u8) -> Result<()> { &format!("rtk docker compose {} (passthrough)", args_str), ); - if !status.success() { - std::process::exit(status.code().unwrap_or(1)); - } - Ok(()) + Ok(exit_code_from_status(&status, "docker")) } /// Runs an unsupported kubectl subcommand by passing it through directly -pub fn run_kubectl_passthrough(args: &[OsString], verbose: u8) -> Result<()> { +pub fn run_kubectl_passthrough(args: &[OsString], verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); if verbose > 0 { @@ -775,10 +769,7 @@ pub fn run_kubectl_passthrough(args: &[OsString], verbose: u8) -> Result<()> { &format!("rtk kubectl {} (passthrough)", args_str), ); - if !status.success() { - std::process::exit(status.code().unwrap_or(1)); - } - Ok(()) + Ok(exit_code_from_status(&status, "kubectl")) } #[cfg(test)] diff --git a/src/cmds/cloud/curl_cmd.rs b/src/cmds/cloud/curl_cmd.rs index 7141ad72df..d6930ef67d 100644 --- a/src/cmds/cloud/curl_cmd.rs +++ b/src/cmds/cloud/curl_cmd.rs @@ -1,11 +1,13 @@ //! Runs curl and auto-compresses JSON responses. use crate::core::tracking; -use crate::core::utils::{resolved_command, truncate}; +use crate::core::utils::{exit_code_from_output, resolved_command, truncate}; use crate::json_cmd; use anyhow::{Context, Result}; -pub fn run(args: &[String], verbose: u8) -> Result<()> { +/// Not using run_filtered: on failure, curl can return HTML error pages (404, 500) +/// that the JSON schema filter would mangle. The early exit skips filtering entirely. +pub fn run(args: &[String], verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); let mut cmd = resolved_command("curl"); cmd.arg("-s"); // Silent mode (no progress bar) @@ -22,6 +24,7 @@ pub fn run(args: &[String], verbose: u8) -> Result<()> { let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); + // Early exit: don't feed HTTP error bodies (HTML 404 etc.) through JSON schema filter if !output.status.success() { let msg = if stderr.trim().is_empty() { stdout.trim().to_string() @@ -29,7 +32,7 @@ pub fn run(args: &[String], verbose: u8) -> Result<()> { stderr.trim().to_string() }; eprintln!("FAILED: curl {}", msg); - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "curl")); } let raw = stdout.to_string(); @@ -45,7 +48,7 @@ pub fn run(args: &[String], verbose: u8) -> Result<()> { &filtered, ); - Ok(()) + Ok(0) } fn filter_curl_output(output: &str) -> String { diff --git a/src/cmds/cloud/psql_cmd.rs b/src/cmds/cloud/psql_cmd.rs index 9ec243bed4..18295eefaf 100644 --- a/src/cmds/cloud/psql_cmd.rs +++ b/src/cmds/cloud/psql_cmd.rs @@ -4,7 +4,7 @@ //! and produces compact tab-separated or key=value output. use crate::core::tracking; -use crate::core::utils::resolved_command; +use crate::core::utils::{exit_code_from_output, resolved_command}; use anyhow::{Context, Result}; use lazy_static::lazy_static; use regex::Regex; @@ -19,7 +19,9 @@ lazy_static! { static ref RECORD_HEADER: Regex = Regex::new(r"^-\[ RECORD (\d+) \]-").unwrap(); } -pub fn run(args: &[String], verbose: u8) -> Result<()> { +/// Not using run_filtered: on failure, psql error messages containing `|` chars +/// would be misinterpreted as table data by the table/expanded format parser. +pub fn run(args: &[String], verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); let mut cmd = resolved_command("psql"); @@ -37,14 +39,15 @@ pub fn run(args: &[String], verbose: u8) -> Result<()> { let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); - let exit_code = output.status.code().unwrap_or(1); + let exit_code = exit_code_from_output(&output, "psql"); if !stderr.is_empty() { eprint!("{}", stderr); } + // Early exit: don't pass psql error messages through table/expanded format parser if exit_code != 0 { - std::process::exit(exit_code); + return Ok(exit_code); } let filtered = filter_psql_output(&stdout); @@ -62,7 +65,7 @@ pub fn run(args: &[String], verbose: u8) -> Result<()> { &filtered, ); - Ok(()) + Ok(0) } fn filter_psql_output(output: &str) -> String { diff --git a/src/cmds/cloud/wget_cmd.rs b/src/cmds/cloud/wget_cmd.rs index 32996ac34b..fd1e26b643 100644 --- a/src/cmds/cloud/wget_cmd.rs +++ b/src/cmds/cloud/wget_cmd.rs @@ -1,9 +1,9 @@ use crate::core::tracking; -use crate::core::utils::resolved_command; +use crate::core::utils::{exit_code_from_output, resolved_command}; use anyhow::{Context, Result}; /// Compact wget - strips progress bars, shows only result -pub fn run(url: &str, args: &[String], verbose: u8) -> Result<()> { +pub fn run(url: &str, args: &[String], verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); if verbose > 0 { @@ -45,14 +45,14 @@ pub fn run(url: &str, args: &[String], verbose: u8) -> Result<()> { let msg = format!("{} FAILED: {}", compact_url(url), error); println!("{}", msg); timer.track(&format!("wget {}", url), "rtk wget", &raw_output, &msg); - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "wget")); } - Ok(()) + Ok(0) } /// Run wget and output to stdout (for piping) -pub fn run_stdout(url: &str, args: &[String], verbose: u8) -> Result<()> { +pub fn run_stdout(url: &str, args: &[String], verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); if verbose > 0 { @@ -108,10 +108,10 @@ pub fn run_stdout(url: &str, args: &[String], verbose: u8) -> Result<()> { let msg = format!("{} FAILED: {}", compact_url(url), error); println!("{}", msg); timer.track(&format!("wget -O - {}", url), "rtk wget -o", &stderr, &msg); - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "wget")); } - Ok(()) + Ok(0) } fn extract_filename_from_output(stderr: &str, url: &str, args: &[String]) -> String { diff --git a/src/cmds/dotnet/dotnet_cmd.rs b/src/cmds/dotnet/dotnet_cmd.rs index 5f05088308..f1e5fe0d55 100644 --- a/src/cmds/dotnet/dotnet_cmd.rs +++ b/src/cmds/dotnet/dotnet_cmd.rs @@ -2,7 +2,7 @@ use crate::binlog; use crate::core::tracking; -use crate::core::utils::{resolved_command, truncate}; +use crate::core::utils::{exit_code_from_output, resolved_command, truncate}; use crate::dotnet_format_report; use crate::dotnet_trx; use anyhow::{Context, Result}; @@ -18,19 +18,19 @@ const DOTNET_CLI_UI_LANGUAGE: &str = "DOTNET_CLI_UI_LANGUAGE"; const DOTNET_CLI_UI_LANGUAGE_VALUE: &str = "en-US"; static TEMP_PATH_COUNTER: AtomicU64 = AtomicU64::new(0); -pub fn run_build(args: &[String], verbose: u8) -> Result<()> { +pub fn run_build(args: &[String], verbose: u8) -> Result { run_dotnet_with_binlog("build", args, verbose) } -pub fn run_test(args: &[String], verbose: u8) -> Result<()> { +pub fn run_test(args: &[String], verbose: u8) -> Result { run_dotnet_with_binlog("test", args, verbose) } -pub fn run_restore(args: &[String], verbose: u8) -> Result<()> { +pub fn run_restore(args: &[String], verbose: u8) -> Result { run_dotnet_with_binlog("restore", args, verbose) } -pub fn run_format(args: &[String], verbose: u8) -> Result<()> { +pub fn run_format(args: &[String], verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); let (report_path, cleanup_report_path) = resolve_format_report_path(args); let mut cmd = resolved_command("dotnet"); @@ -69,14 +69,10 @@ pub fn run_format(args: &[String], verbose: u8) -> Result<()> { } } - if !output.status.success() { - std::process::exit(output.status.code().unwrap_or(1)); - } - - Ok(()) + Ok(exit_code_from_output(&output, "dotnet")) } -pub fn run_passthrough(args: &[OsString], verbose: u8) -> Result<()> { +pub fn run_passthrough(args: &[OsString], verbose: u8) -> Result { if args.is_empty() { anyhow::bail!("dotnet: no subcommand specified"); } @@ -113,14 +109,10 @@ pub fn run_passthrough(args: &[OsString], verbose: u8) -> Result<()> { &raw, ); - if !output.status.success() { - std::process::exit(output.status.code().unwrap_or(1)); - } - - Ok(()) + Ok(exit_code_from_output(&output, "dotnet")) } -fn run_dotnet_with_binlog(subcommand: &str, args: &[String], verbose: u8) -> Result<()> { +fn run_dotnet_with_binlog(subcommand: &str, args: &[String], verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); let binlog_path = build_binlog_path(subcommand); let should_expect_binlog = subcommand != "test" || has_binlog_arg(args); @@ -261,11 +253,7 @@ fn run_dotnet_with_binlog(subcommand: &str, args: &[String], verbose: u8) -> Res eprintln!("Binlog cleaned up: {}", binlog_path.display()); } - if !output.status.success() { - std::process::exit(output.status.code().unwrap_or(1)); - } - - Ok(()) + Ok(exit_code_from_output(&output, "dotnet")) } fn build_binlog_path(subcommand: &str) -> PathBuf { diff --git a/src/cmds/git/gh_cmd.rs b/src/cmds/git/gh_cmd.rs index e008a2f19e..3c202950f4 100644 --- a/src/cmds/git/gh_cmd.rs +++ b/src/cmds/git/gh_cmd.rs @@ -4,7 +4,9 @@ //! Focuses on extracting essential information from JSON outputs. use crate::core::tracking; -use crate::core::utils::{ok_confirmation, resolved_command, truncate}; +use crate::core::utils::{ + exit_code_from_output, exit_code_from_status, ok_confirmation, resolved_command, truncate, +}; use crate::git; use anyhow::{Context, Result}; use lazy_static::lazy_static; @@ -161,7 +163,7 @@ fn extract_identifier_and_extra_args(args: &[String]) -> Option<(String, Vec Result<()> { +pub fn run(subcommand: &str, args: &[String], verbose: u8, ultra_compact: bool) -> Result { // When user explicitly passes --json, they want raw gh JSON output, not RTK filtering if has_json_flag(args) { return run_passthrough("gh", subcommand, args); @@ -180,7 +182,7 @@ pub fn run(subcommand: &str, args: &[String], verbose: u8, ultra_compact: bool) } } -fn run_pr(args: &[String], verbose: u8, ultra_compact: bool) -> Result<()> { +fn run_pr(args: &[String], verbose: u8, ultra_compact: bool) -> Result { if args.is_empty() { return run_passthrough("gh", "pr", args); } @@ -199,7 +201,7 @@ fn run_pr(args: &[String], verbose: u8, ultra_compact: bool) -> Result<()> { } } -fn list_prs(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> { +fn list_prs(args: &[String], _verbose: u8, ultra_compact: bool) -> Result { let timer = tracking::TimedExecution::start(); let mut cmd = resolved_command("gh"); @@ -222,7 +224,7 @@ fn list_prs(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> { let stderr = String::from_utf8_lossy(&output.stderr).to_string(); timer.track("gh pr list", "rtk gh pr list", &stderr, &stderr); eprintln!("{}", stderr.trim()); - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "gh")); } let json: Value = @@ -280,7 +282,7 @@ fn list_prs(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> { } timer.track("gh pr list", "rtk gh pr list", &raw, &filtered); - Ok(()) + Ok(0) } fn should_passthrough_pr_view(extra_args: &[String]) -> bool { @@ -295,7 +297,7 @@ fn should_passthrough_issue_view(extra_args: &[String]) -> bool { .any(|a| a == "--json" || a == "--jq" || a == "--web" || a == "--comments") } -fn view_pr(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> { +fn view_pr(args: &[String], _verbose: u8, ultra_compact: bool) -> Result { let timer = tracking::TimedExecution::start(); let (pr_number, extra_args) = match extract_identifier_and_extra_args(args) { @@ -333,7 +335,7 @@ fn view_pr(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> { &stderr, ); eprintln!("{}", stderr.trim()); - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "gh")); } let json: Value = @@ -469,10 +471,10 @@ fn view_pr(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> { &raw, &filtered, ); - Ok(()) + Ok(0) } -fn pr_checks(args: &[String], _verbose: u8, _ultra_compact: bool) -> Result<()> { +fn pr_checks(args: &[String], _verbose: u8, _ultra_compact: bool) -> Result { let timer = tracking::TimedExecution::start(); let (pr_number, extra_args) = match extract_identifier_and_extra_args(args) { @@ -498,7 +500,7 @@ fn pr_checks(args: &[String], _verbose: u8, _ultra_compact: bool) -> Result<()> &stderr, ); eprintln!("{}", stderr.trim()); - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "gh")); } let stdout = String::from_utf8_lossy(&output.stdout); @@ -557,10 +559,10 @@ fn pr_checks(args: &[String], _verbose: u8, _ultra_compact: bool) -> Result<()> &raw, &filtered, ); - Ok(()) + Ok(0) } -fn pr_status(_verbose: u8, _ultra_compact: bool) -> Result<()> { +fn pr_status(_verbose: u8, _ultra_compact: bool) -> Result { let timer = tracking::TimedExecution::start(); let mut cmd = resolved_command("gh"); @@ -578,7 +580,7 @@ fn pr_status(_verbose: u8, _ultra_compact: bool) -> Result<()> { let stderr = String::from_utf8_lossy(&output.stderr).to_string(); timer.track("gh pr status", "rtk gh pr status", &stderr, &stderr); eprintln!("{}", stderr.trim()); - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "gh")); } let json: Value = @@ -601,10 +603,10 @@ fn pr_status(_verbose: u8, _ultra_compact: bool) -> Result<()> { } timer.track("gh pr status", "rtk gh pr status", &raw, &filtered); - Ok(()) + Ok(0) } -fn run_issue(args: &[String], verbose: u8, ultra_compact: bool) -> Result<()> { +fn run_issue(args: &[String], verbose: u8, ultra_compact: bool) -> Result { if args.is_empty() { return run_passthrough("gh", "issue", args); } @@ -616,7 +618,7 @@ fn run_issue(args: &[String], verbose: u8, ultra_compact: bool) -> Result<()> { } } -fn list_issues(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> { +fn list_issues(args: &[String], _verbose: u8, ultra_compact: bool) -> Result { let timer = tracking::TimedExecution::start(); let mut cmd = resolved_command("gh"); @@ -633,7 +635,7 @@ fn list_issues(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> let stderr = String::from_utf8_lossy(&output.stderr).to_string(); timer.track("gh issue list", "rtk gh issue list", &stderr, &stderr); eprintln!("{}", stderr.trim()); - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "gh")); } let json: Value = @@ -675,10 +677,10 @@ fn list_issues(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> } timer.track("gh issue list", "rtk gh issue list", &raw, &filtered); - Ok(()) + Ok(0) } -fn view_issue(args: &[String], _verbose: u8) -> Result<()> { +fn view_issue(args: &[String], _verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); let (issue_number, extra_args) = match extract_identifier_and_extra_args(args) { @@ -717,7 +719,7 @@ fn view_issue(args: &[String], _verbose: u8) -> Result<()> { &stderr, ); eprintln!("{}", stderr.trim()); - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "gh")); } let json: Value = @@ -775,10 +777,10 @@ fn view_issue(args: &[String], _verbose: u8) -> Result<()> { &raw, &filtered, ); - Ok(()) + Ok(0) } -fn run_workflow(args: &[String], verbose: u8, ultra_compact: bool) -> Result<()> { +fn run_workflow(args: &[String], verbose: u8, ultra_compact: bool) -> Result { if args.is_empty() { return run_passthrough("gh", "run", args); } @@ -790,7 +792,7 @@ fn run_workflow(args: &[String], verbose: u8, ultra_compact: bool) -> Result<()> } } -fn list_runs(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> { +fn list_runs(args: &[String], _verbose: u8, ultra_compact: bool) -> Result { let timer = tracking::TimedExecution::start(); let mut cmd = resolved_command("gh"); @@ -813,7 +815,7 @@ fn list_runs(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> { let stderr = String::from_utf8_lossy(&output.stderr).to_string(); timer.track("gh run list", "rtk gh run list", &stderr, &stderr); eprintln!("{}", stderr.trim()); - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "gh")); } let json: Value = @@ -870,7 +872,7 @@ fn list_runs(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> { } timer.track("gh run list", "rtk gh run list", &raw, &filtered); - Ok(()) + Ok(0) } /// Check if run view args should bypass filtering and pass through directly. @@ -882,7 +884,7 @@ fn should_passthrough_run_view(extra_args: &[String]) -> bool { .any(|a| a == "--log-failed" || a == "--log" || a == "--json") } -fn view_run(args: &[String], _verbose: u8) -> Result<()> { +fn view_run(args: &[String], _verbose: u8) -> Result { let (run_id, extra_args) = match extract_identifier_and_extra_args(args) { Some(result) => result, None => return Err(anyhow::anyhow!("Run ID required")), @@ -913,7 +915,7 @@ fn view_run(args: &[String], _verbose: u8) -> Result<()> { &stderr, ); eprintln!("{}", stderr.trim()); - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "gh")); } // Parse output and show only failures @@ -954,10 +956,10 @@ fn view_run(args: &[String], _verbose: u8) -> Result<()> { &raw, &filtered, ); - Ok(()) + Ok(0) } -fn run_repo(args: &[String], _verbose: u8, _ultra_compact: bool) -> Result<()> { +fn run_repo(args: &[String], _verbose: u8, _ultra_compact: bool) -> Result { // Parse subcommand (default to "view") let (subcommand, rest_args) = if args.is_empty() { ("view", args) @@ -990,7 +992,7 @@ fn run_repo(args: &[String], _verbose: u8, _ultra_compact: bool) -> Result<()> { let stderr = String::from_utf8_lossy(&output.stderr).to_string(); timer.track("gh repo view", "rtk gh repo view", &stderr, &stderr); eprintln!("{}", stderr.trim()); - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "gh")); } let json: Value = @@ -1031,10 +1033,10 @@ fn run_repo(args: &[String], _verbose: u8, _ultra_compact: bool) -> Result<()> { print!("{}", line); timer.track("gh repo view", "rtk gh repo view", &raw, &filtered); - Ok(()) + Ok(0) } -fn pr_create(args: &[String], _verbose: u8) -> Result<()> { +fn pr_create(args: &[String], _verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); let mut cmd = resolved_command("gh"); @@ -1050,7 +1052,7 @@ fn pr_create(args: &[String], _verbose: u8) -> Result<()> { if !output.status.success() { timer.track("gh pr create", "rtk gh pr create", &stderr, &stderr); eprintln!("{}", stderr.trim()); - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "gh")); } // gh pr create outputs the URL on success @@ -1069,10 +1071,10 @@ fn pr_create(args: &[String], _verbose: u8) -> Result<()> { println!("{}", filtered); timer.track("gh pr create", "rtk gh pr create", &stdout, &filtered); - Ok(()) + Ok(0) } -fn pr_merge(args: &[String], _verbose: u8) -> Result<()> { +fn pr_merge(args: &[String], _verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); let mut cmd = resolved_command("gh"); @@ -1088,7 +1090,7 @@ fn pr_merge(args: &[String], _verbose: u8) -> Result<()> { if !output.status.success() { timer.track("gh pr merge", "rtk gh pr merge", &stderr, &stderr); eprintln!("{}", stderr.trim()); - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "gh")); } // Extract PR number from args (first non-flag arg) @@ -1115,7 +1117,7 @@ fn pr_merge(args: &[String], _verbose: u8) -> Result<()> { }; timer.track("gh pr merge", "rtk gh pr merge", &raw, &filtered); - Ok(()) + Ok(0) } /// Flags that change `gh pr diff` output from unified diff to a different format. @@ -1130,7 +1132,7 @@ fn has_non_diff_format_flag(args: &[String]) -> bool { }) } -fn pr_diff(args: &[String], _verbose: u8) -> Result<()> { +fn pr_diff(args: &[String], _verbose: u8) -> Result { // --no-compact: pass full diff through (gh CLI doesn't know this flag, strip it) let no_compact = args.iter().any(|a| a == "--no-compact"); let gh_args: Vec = args @@ -1160,7 +1162,7 @@ fn pr_diff(args: &[String], _verbose: u8) -> Result<()> { let stderr = String::from_utf8_lossy(&output.stderr).to_string(); timer.track("gh pr diff", "rtk gh pr diff", &stderr, &stderr); eprintln!("{}", stderr.trim()); - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "gh")); } let filtered = if raw.trim().is_empty() { @@ -1174,11 +1176,11 @@ fn pr_diff(args: &[String], _verbose: u8) -> Result<()> { }; timer.track("gh pr diff", "rtk gh pr diff", &raw, &filtered); - Ok(()) + Ok(0) } /// Generic PR action handler for comment/edit -fn pr_action(action: &str, args: &[String], _verbose: u8) -> Result<()> { +fn pr_action(action: &str, args: &[String], _verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); let subcmd = &args[0]; @@ -1202,7 +1204,7 @@ fn pr_action(action: &str, args: &[String], _verbose: u8) -> Result<()> { &stderr, ); eprintln!("{}", stderr.trim()); - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "gh")); } // Extract PR number from args (skip args[0] which is the subcommand) @@ -1228,10 +1230,10 @@ fn pr_action(action: &str, args: &[String], _verbose: u8) -> Result<()> { &raw, &filtered, ); - Ok(()) + Ok(0) } -fn run_api(args: &[String], _verbose: u8) -> Result<()> { +fn run_api(args: &[String], _verbose: u8) -> Result { // gh api is an explicit/advanced command — the user knows what they asked for. // Converting JSON to a schema destroys all values and forces Claude to re-fetch. // Passthrough preserves the full response and tracks metrics at 0% savings. @@ -1239,7 +1241,7 @@ fn run_api(args: &[String], _verbose: u8) -> Result<()> { } /// Pass through a command with base args + extra args, tracking as passthrough. -fn run_passthrough_with_extra(cmd: &str, base_args: &[&str], extra_args: &[String]) -> Result<()> { +fn run_passthrough_with_extra(cmd: &str, base_args: &[&str], extra_args: &[String]) -> Result { let timer = tracking::TimedExecution::start(); let mut command = resolved_command(cmd); @@ -1263,14 +1265,10 @@ fn run_passthrough_with_extra(cmd: &str, base_args: &[&str], extra_args: &[Strin ); timer.track_passthrough(&full_cmd, &format!("rtk {} (passthrough)", full_cmd)); - if !status.success() { - std::process::exit(status.code().unwrap_or(1)); - } - - Ok(()) + Ok(exit_code_from_status(&status, "gh")) } -fn run_passthrough(cmd: &str, subcommand: &str, args: &[String]) -> Result<()> { +fn run_passthrough(cmd: &str, subcommand: &str, args: &[String]) -> Result { let timer = tracking::TimedExecution::start(); let mut command = resolved_command(cmd); @@ -1289,11 +1287,7 @@ fn run_passthrough(cmd: &str, subcommand: &str, args: &[String]) -> Result<()> { &format!("rtk {} {} {} (passthrough)", cmd, subcommand, args_str), ); - if !status.success() { - std::process::exit(status.code().unwrap_or(1)); - } - - Ok(()) + Ok(exit_code_from_status(&status, "gh")) } #[cfg(test)] diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index 1ed848d631..8d3d0eb4bf 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -2,7 +2,7 @@ use crate::core::config; use crate::core::tracking; -use crate::core::utils::resolved_command; +use crate::core::utils::{exit_code_from_output, exit_code_from_status, resolved_command}; use anyhow::{Context, Result}; use std::ffi::OsString; use std::process::Command; @@ -39,7 +39,7 @@ pub fn run( max_lines: Option, verbose: u8, global_args: &[String], -) -> Result<()> { +) -> Result { match cmd { GitCommand::Diff => run_diff(args, max_lines, verbose, global_args), GitCommand::Log => run_log(args, max_lines, verbose, global_args), @@ -63,7 +63,7 @@ fn run_diff( max_lines: Option, verbose: u8, global_args: &[String], -) -> Result<()> { +) -> Result { let timer = tracking::TimedExecution::start(); // Check if user wants stat output @@ -90,7 +90,7 @@ fn run_diff( if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); eprintln!("{}", stderr); - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "git")); } let stdout = String::from_utf8_lossy(&output.stdout); @@ -103,7 +103,7 @@ fn run_diff( &stdout, ); - return Ok(()); + return Ok(0); } // Default RTK behavior: stat first, then compacted diff @@ -129,7 +129,7 @@ fn run_diff( &raw, &raw, ); - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "git")); } if verbose > 0 { @@ -165,7 +165,7 @@ fn run_diff( &final_output, ); - Ok(()) + Ok(0) } fn run_show( @@ -173,7 +173,7 @@ fn run_show( max_lines: Option, verbose: u8, global_args: &[String], -) -> Result<()> { +) -> Result { let timer = tracking::TimedExecution::start(); // If user wants --stat or --format only, pass through @@ -199,7 +199,7 @@ fn run_show( if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); eprintln!("{}", stderr); - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "git")); } let stdout = String::from_utf8_lossy(&output.stdout); if wants_blob_show { @@ -215,7 +215,7 @@ fn run_show( &stdout, ); - return Ok(()); + return Ok(0); } // Get raw output for tracking @@ -239,7 +239,7 @@ fn run_show( if !summary_output.status.success() { let stderr = String::from_utf8_lossy(&summary_output.stderr); eprintln!("{}", stderr); - std::process::exit(summary_output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&summary_output, "git")); } let summary = String::from_utf8_lossy(&summary_output.stdout); println!("{}", summary.trim()); @@ -284,7 +284,7 @@ fn run_show( &final_output, ); - Ok(()) + Ok(0) } fn is_blob_show_arg(arg: &str) -> bool { @@ -386,7 +386,7 @@ fn run_log( _max_lines: Option, verbose: u8, global_args: &[String], -) -> Result<()> { +) -> Result { let timer = tracking::TimedExecution::start(); let mut cmd = git_cmd(global_args); @@ -444,8 +444,7 @@ fn run_log( if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); eprintln!("{}", stderr); - // Propagate git's exit code - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "git")); } let stdout = String::from_utf8_lossy(&output.stdout); @@ -465,7 +464,7 @@ fn run_log( &filtered, ); - Ok(()) + Ok(0) } /// Filter git log output: truncate long messages, cap lines @@ -740,7 +739,7 @@ fn filter_status_with_args(output: &str) -> String { } } -fn run_status(args: &[String], verbose: u8, global_args: &[String]) -> Result<()> { +fn run_status(args: &[String], verbose: u8, global_args: &[String]) -> Result { let timer = tracking::TimedExecution::start(); // If user provided flags, apply minimal filtering @@ -765,7 +764,7 @@ fn run_status(args: &[String], verbose: u8, global_args: &[String]) -> Result<() &raw, &raw, ); - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "git")); } if verbose > 0 || !stderr.is_empty() { @@ -783,7 +782,7 @@ fn run_status(args: &[String], verbose: u8, global_args: &[String]) -> Result<() &filtered, ); - return Ok(()); + return Ok(0); } // Default RTK compact mode (no args provided) @@ -806,7 +805,7 @@ fn run_status(args: &[String], verbose: u8, global_args: &[String]) -> Result<() let message = "Not a git repository".to_string(); eprintln!("{}", message); timer.track("git status", "rtk git status", &raw_output, &message); - std::process::exit(output.status.code().unwrap_or(128)); + return Ok(exit_code_from_output(&output, "git")); } let formatted = format_status_output(&stdout); @@ -816,10 +815,10 @@ fn run_status(args: &[String], verbose: u8, global_args: &[String]) -> Result<() // Track for statistics timer.track("git status", "rtk git status", &raw_output, &formatted); - Ok(()) + Ok(0) } -fn run_add(args: &[String], verbose: u8, global_args: &[String]) -> Result<()> { +fn run_add(args: &[String], verbose: u8, global_args: &[String]) -> Result { let timer = tracking::TimedExecution::start(); let mut cmd = git_cmd(global_args); @@ -884,11 +883,10 @@ fn run_add(args: &[String], verbose: u8, global_args: &[String]) -> Result<()> { if !stdout.trim().is_empty() { eprintln!("{}", stdout); } - // Propagate git's exit code - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "git")); } - Ok(()) + Ok(0) } fn build_commit_command(args: &[String], global_args: &[String]) -> Command { @@ -900,7 +898,7 @@ fn build_commit_command(args: &[String], global_args: &[String]) -> Command { cmd } -fn run_commit(args: &[String], verbose: u8, global_args: &[String]) -> Result<()> { +fn run_commit(args: &[String], verbose: u8, global_args: &[String]) -> Result { let timer = tracking::TimedExecution::start(); let original_cmd = format!("git commit {}", args.join(" ")); @@ -954,14 +952,14 @@ fn run_commit(args: &[String], verbose: u8, global_args: &[String]) -> Result<() eprint!("{}", stdout); } timer.track(&original_cmd, "rtk git commit", &raw_output, &raw_output); - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "git")); } } - Ok(()) + Ok(0) } -fn run_push(args: &[String], verbose: u8, global_args: &[String]) -> Result<()> { +fn run_push(args: &[String], verbose: u8, global_args: &[String]) -> Result { let timer = tracking::TimedExecution::start(); if verbose > 0 { @@ -1017,13 +1015,13 @@ fn run_push(args: &[String], verbose: u8, global_args: &[String]) -> Result<()> if !stdout.trim().is_empty() { eprintln!("{}", stdout); } - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "git")); } - Ok(()) + Ok(0) } -fn run_pull(args: &[String], verbose: u8, global_args: &[String]) -> Result<()> { +fn run_pull(args: &[String], verbose: u8, global_args: &[String]) -> Result { let timer = tracking::TimedExecution::start(); if verbose > 0 { @@ -1103,13 +1101,13 @@ fn run_pull(args: &[String], verbose: u8, global_args: &[String]) -> Result<()> if !stdout.trim().is_empty() { eprintln!("{}", stdout); } - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "git")); } - Ok(()) + Ok(0) } -fn run_branch(args: &[String], verbose: u8, global_args: &[String]) -> Result<()> { +fn run_branch(args: &[String], verbose: u8, global_args: &[String]) -> Result { let timer = tracking::TimedExecution::start(); if verbose > 0 { @@ -1183,9 +1181,9 @@ fn run_branch(args: &[String], verbose: u8, global_args: &[String]) -> Result<() if !stderr.trim().is_empty() { eprintln!("{}", stderr); } - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "git")); } - return Ok(()); + return Ok(0); } // Write operation: action flags, or positional args without list flags (= branch creation) @@ -1223,9 +1221,9 @@ fn run_branch(args: &[String], verbose: u8, global_args: &[String]) -> Result<() if !stdout.trim().is_empty() { eprintln!("{}", stdout); } - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "git")); } - return Ok(()); + return Ok(0); } // List mode: show compact branch list @@ -1254,7 +1252,7 @@ fn run_branch(args: &[String], verbose: u8, global_args: &[String]) -> Result<() &raw, &raw, ); - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "git")); } let filtered = filter_branch_output(&stdout); @@ -1267,7 +1265,7 @@ fn run_branch(args: &[String], verbose: u8, global_args: &[String]) -> Result<() &filtered, ); - Ok(()) + Ok(0) } fn filter_branch_output(output: &str) -> String { @@ -1324,7 +1322,7 @@ fn filter_branch_output(output: &str) -> String { result.join("\n") } -fn run_fetch(args: &[String], verbose: u8, global_args: &[String]) -> Result<()> { +fn run_fetch(args: &[String], verbose: u8, global_args: &[String]) -> Result { let timer = tracking::TimedExecution::start(); if verbose > 0 { @@ -1347,7 +1345,7 @@ fn run_fetch(args: &[String], verbose: u8, global_args: &[String]) -> Result<()> if !stderr.trim().is_empty() { eprintln!("{}", stderr); } - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "git")); } // Count new refs from stderr (git fetch outputs to stderr) @@ -1365,7 +1363,7 @@ fn run_fetch(args: &[String], verbose: u8, global_args: &[String]) -> Result<()> println!("{}", msg); timer.track("git fetch", "rtk git fetch", &raw, &msg); - Ok(()) + Ok(0) } fn run_stash( @@ -1373,7 +1371,7 @@ fn run_stash( args: &[String], verbose: u8, global_args: &[String], -) -> Result<()> { +) -> Result { let timer = tracking::TimedExecution::start(); if verbose > 0 { @@ -1393,7 +1391,7 @@ fn run_stash( let msg = "No stashes"; println!("{}", msg); timer.track("git stash list", "rtk git stash list", &raw, msg); - return Ok(()); + return Ok(0); } let filtered = filter_stash_list(&stdout); @@ -1454,7 +1452,7 @@ fn run_stash( ); if !output.status.success() { - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "git")); } } Some(sub) => { @@ -1489,7 +1487,7 @@ fn run_stash( ); if !output.status.success() { - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "git")); } } None => { @@ -1525,12 +1523,12 @@ fn run_stash( timer.track("git stash", "rtk git stash", &combined, &msg); if !output.status.success() { - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "git")); } } } - Ok(()) + Ok(0) } fn filter_stash_list(output: &str) -> String { @@ -1554,7 +1552,7 @@ fn filter_stash_list(output: &str) -> String { result.join("\n") } -fn run_worktree(args: &[String], verbose: u8, global_args: &[String]) -> Result<()> { +fn run_worktree(args: &[String], verbose: u8, global_args: &[String]) -> Result { let timer = tracking::TimedExecution::start(); if verbose > 0 { @@ -1597,9 +1595,9 @@ fn run_worktree(args: &[String], verbose: u8, global_args: &[String]) -> Result< if !stderr.trim().is_empty() { eprintln!("{}", stderr); } - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(exit_code_from_output(&output, "git")); } - return Ok(()); + return Ok(0); } // Default: list mode @@ -1615,7 +1613,7 @@ fn run_worktree(args: &[String], verbose: u8, global_args: &[String]) -> Result< println!("{}", filtered); timer.track("git worktree list", "rtk git worktree", &raw, &filtered); - Ok(()) + Ok(0) } fn filter_worktree_list(output: &str) -> String { @@ -1646,7 +1644,7 @@ fn filter_worktree_list(output: &str) -> String { } /// Runs an unsupported git subcommand by passing it through directly -pub fn run_passthrough(args: &[OsString], global_args: &[String], verbose: u8) -> Result<()> { +pub fn run_passthrough(args: &[OsString], global_args: &[String], verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); if verbose > 0 { @@ -1664,9 +1662,9 @@ pub fn run_passthrough(args: &[OsString], global_args: &[String], verbose: u8) - ); if !status.success() { - std::process::exit(status.code().unwrap_or(1)); + return Ok(exit_code_from_status(&status, "git")); } - Ok(()) + Ok(0) } #[cfg(test)] diff --git a/src/cmds/git/gt_cmd.rs b/src/cmds/git/gt_cmd.rs index 580778ff14..19a03ead56 100644 --- a/src/cmds/git/gt_cmd.rs +++ b/src/cmds/git/gt_cmd.rs @@ -1,7 +1,10 @@ //! Filters Graphite (gt) CLI output for stacking workflows. use crate::core::tracking; -use crate::core::utils::{ok_confirmation, resolved_command, strip_ansi, truncate}; +use crate::core::utils::{ + exit_code_from_output, exit_code_from_status, ok_confirmation, resolved_command, strip_ansi, + truncate, +}; use anyhow::{Context, Result}; use lazy_static::lazy_static; use regex::Regex; @@ -25,7 +28,7 @@ fn run_gt_filtered( verbose: u8, tee_label: &str, filter_fn: fn(&str) -> String, -) -> Result<()> { +) -> Result { let timer = tracking::TimedExecution::start(); let mut cmd = resolved_command("gt"); @@ -52,7 +55,7 @@ fn run_gt_filtered( let stderr = String::from_utf8_lossy(&cmd_output.stderr); let raw = format!("{}\n{}", stdout, stderr); - let exit_code = cmd_output.status.code().unwrap_or(1); + let exit_code = exit_code_from_output(&cmd_output, "gt"); let clean = strip_ansi(stdout.trim()); let output = if verbose > 0 { @@ -79,18 +82,14 @@ fn run_gt_filtered( let rtk_label = format!("rtk {}", label); timer.track(&label, &rtk_label, &raw, &output); - if !cmd_output.status.success() { - std::process::exit(exit_code); - } - - Ok(()) + Ok(exit_code) } fn filter_identity(input: &str) -> String { input.to_string() } -pub fn run_log(args: &[String], verbose: u8) -> Result<()> { +pub fn run_log(args: &[String], verbose: u8) -> Result { match args.first().map(|s| s.as_str()) { Some("short") => run_gt_filtered( &["log", "short"], @@ -110,27 +109,27 @@ pub fn run_log(args: &[String], verbose: u8) -> Result<()> { } } -pub fn run_submit(args: &[String], verbose: u8) -> Result<()> { +pub fn run_submit(args: &[String], verbose: u8) -> Result { run_gt_filtered(&["submit"], args, verbose, "gt_submit", filter_gt_submit) } -pub fn run_sync(args: &[String], verbose: u8) -> Result<()> { +pub fn run_sync(args: &[String], verbose: u8) -> Result { run_gt_filtered(&["sync"], args, verbose, "gt_sync", filter_gt_sync) } -pub fn run_restack(args: &[String], verbose: u8) -> Result<()> { +pub fn run_restack(args: &[String], verbose: u8) -> Result { run_gt_filtered(&["restack"], args, verbose, "gt_restack", filter_gt_restack) } -pub fn run_create(args: &[String], verbose: u8) -> Result<()> { +pub fn run_create(args: &[String], verbose: u8) -> Result { run_gt_filtered(&["create"], args, verbose, "gt_create", filter_gt_create) } -pub fn run_branch(args: &[String], verbose: u8) -> Result<()> { +pub fn run_branch(args: &[String], verbose: u8) -> Result { run_gt_filtered(&["branch"], args, verbose, "gt_branch", filter_identity) } -pub fn run_other(args: &[OsString], verbose: u8) -> Result<()> { +pub fn run_other(args: &[OsString], verbose: u8) -> Result { if args.is_empty() { anyhow::bail!("gt: no subcommand specified"); } @@ -169,7 +168,7 @@ pub fn run_other(args: &[OsString], verbose: u8) -> Result<()> { } } -fn passthrough_gt(subcommand: &str, args: &[String], verbose: u8) -> Result<()> { +fn passthrough_gt(subcommand: &str, args: &[String], verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); let mut cmd = resolved_command("gt"); @@ -196,11 +195,7 @@ fn passthrough_gt(subcommand: &str, args: &[String], verbose: u8) -> Result<()> &format!("rtk gt {} (passthrough)", args_str), ); - if !status.success() { - std::process::exit(status.code().unwrap_or(1)); - } - - Ok(()) + Ok(exit_code_from_status(&status, "gt")) } const MAX_LOG_ENTRIES: usize = 15; diff --git a/src/cmds/go/go_cmd.rs b/src/cmds/go/go_cmd.rs index 47771e7aed..5d64f6a184 100644 --- a/src/cmds/go/go_cmd.rs +++ b/src/cmds/go/go_cmd.rs @@ -1,7 +1,7 @@ //! Filters Go command output — test results, build errors, vet warnings. use crate::core::tracking; -use crate::core::utils::{resolved_command, truncate}; +use crate::core::utils::{exit_code_from_output, resolved_command, truncate}; use crate::golangci_cmd; use anyhow::{Context, Result}; use serde::Deserialize; @@ -39,13 +39,10 @@ struct PackageResult { failed_tests: Vec<(String, Vec)>, // (test_name, output_lines) } -pub fn run_test(args: &[String], verbose: u8) -> Result<()> { - let timer = tracking::TimedExecution::start(); - +pub fn run_test(args: &[String], verbose: u8) -> Result { let mut cmd = resolved_command("go"); cmd.arg("test"); - // Force JSON output if not already specified if !args.iter().any(|a| a == "-json") { cmd.arg("-json"); } @@ -58,49 +55,16 @@ pub fn run_test(args: &[String], verbose: u8) -> Result<()> { eprintln!("Running: go test -json {}", args.join(" ")); } - let output = cmd - .output() - .context("Failed to run go test. Is Go installed?")?; - - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - let raw = format!("{}\n{}", stdout, stderr); - - let exit_code = output - .status - .code() - .unwrap_or(if output.status.success() { 0 } else { 1 }); - let filtered = filter_go_test_json(&stdout); - - if let Some(hint) = crate::core::tee::tee_and_hint(&raw, "go_test", exit_code) { - println!("{}\n{}", filtered, hint); - } else { - println!("{}", filtered); - } - - // Include stderr if present (build errors, etc.) - if !stderr.trim().is_empty() { - eprintln!("{}", stderr.trim()); - } - - timer.track( - &format!("go test {}", args.join(" ")), - &format!("rtk go test {}", args.join(" ")), - &raw, - &filtered, - ); - - // Preserve exit code for CI/CD - if !output.status.success() { - std::process::exit(exit_code); - } - - Ok(()) + crate::core::runner::run_filtered( + cmd, + "go test", + &args.join(" "), + filter_go_test_json, + crate::core::runner::RunOptions::stdout_only().tee("go_test"), + ) } -pub fn run_build(args: &[String], verbose: u8) -> Result<()> { - let timer = tracking::TimedExecution::start(); - +pub fn run_build(args: &[String], verbose: u8) -> Result { let mut cmd = resolved_command("go"); cmd.arg("build"); @@ -112,48 +76,16 @@ pub fn run_build(args: &[String], verbose: u8) -> Result<()> { eprintln!("Running: go build {}", args.join(" ")); } - let output = cmd - .output() - .context("Failed to run go build. Is Go installed?")?; - - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - let raw = format!("{}\n{}", stdout, stderr); - - let exit_code = output - .status - .code() - .unwrap_or(if output.status.success() { 0 } else { 1 }); - let filtered = filter_go_build(&raw); - - if let Some(hint) = crate::core::tee::tee_and_hint(&raw, "go_build", exit_code) { - if !filtered.is_empty() { - println!("{}\n{}", filtered, hint); - } else { - println!("{}", hint); - } - } else if !filtered.is_empty() { - println!("{}", filtered); - } - - timer.track( - &format!("go build {}", args.join(" ")), - &format!("rtk go build {}", args.join(" ")), - &raw, - &filtered, - ); - - // Preserve exit code for CI/CD - if !output.status.success() { - std::process::exit(exit_code); - } - - Ok(()) + crate::core::runner::run_filtered( + cmd, + "go build", + &args.join(" "), + filter_go_build, + crate::core::runner::RunOptions::with_tee("go_build"), + ) } -pub fn run_vet(args: &[String], verbose: u8) -> Result<()> { - let timer = tracking::TimedExecution::start(); - +pub fn run_vet(args: &[String], verbose: u8) -> Result { let mut cmd = resolved_command("go"); cmd.arg("vet"); @@ -165,46 +97,16 @@ pub fn run_vet(args: &[String], verbose: u8) -> Result<()> { eprintln!("Running: go vet {}", args.join(" ")); } - let output = cmd - .output() - .context("Failed to run go vet. Is Go installed?")?; - - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - let raw = format!("{}\n{}", stdout, stderr); - - let exit_code = output - .status - .code() - .unwrap_or(if output.status.success() { 0 } else { 1 }); - let filtered = filter_go_vet(&raw); - - if let Some(hint) = crate::core::tee::tee_and_hint(&raw, "go_vet", exit_code) { - if !filtered.is_empty() { - println!("{}\n{}", filtered, hint); - } else { - println!("{}", hint); - } - } else if !filtered.is_empty() { - println!("{}", filtered); - } - - timer.track( - &format!("go vet {}", args.join(" ")), - &format!("rtk go vet {}", args.join(" ")), - &raw, - &filtered, - ); - - // Preserve exit code for CI/CD - if !output.status.success() { - std::process::exit(exit_code); - } - - Ok(()) + crate::core::runner::run_filtered( + cmd, + "go vet", + &args.join(" "), + filter_go_vet, + crate::core::runner::RunOptions::with_tee("go_vet"), + ) } -pub fn run_other(args: &[OsString], verbose: u8) -> Result<()> { +pub fn run_other(args: &[OsString], verbose: u8) -> Result { if args.is_empty() { anyhow::bail!("go: no subcommand specified"); } @@ -248,12 +150,7 @@ pub fn run_other(args: &[OsString], verbose: u8) -> Result<()> { &raw, // No filtering for unsupported commands ); - // Preserve exit code - if !output.status.success() { - std::process::exit(output.status.code().unwrap_or(1)); - } - - Ok(()) + Ok(exit_code_from_output(&output, "go")) } /// Detect golangci-lint major version when invoked via `go tool`. @@ -319,7 +216,7 @@ fn match_go_tool(args: &[OsString]) -> Option<(GoTool, &[OsString])> { /// Run `go tool golangci-lint` and filter its output via the golangci JSON filter. /// Reusing parts of golangci_cmd. -fn run_go_tool_golangci_lint(args: &[OsString], verbose: u8) -> Result<()> { +fn run_go_tool_golangci_lint(args: &[OsString], verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); let version = detect_go_tool_golangci_version(); @@ -380,20 +277,10 @@ fn run_go_tool_golangci_lint(args: &[OsString], verbose: u8) -> Result<()> { &filtered, ); - // golangci-lint: exit 0 = clean, exit 1 = lint issues, exit 2+ = config/build error - match output.status.code() { - Some(0) | Some(1) => Ok(()), - Some(code) => { - if !stderr.trim().is_empty() { - eprintln!("{}", stderr.trim()); - } - std::process::exit(code); - } - None => { - eprintln!("go tool golangci-lint: killed by signal"); - std::process::exit(130); - } - } + let exit_code = exit_code_from_output(&output, "go tool golangci-lint"); + // golangci-lint: exit 0 = clean, exit 1 = lint issues found (not an error), + // exit 2+ = config/build error, None = killed by signal (OOM, SIGKILL) + Ok(if exit_code == 1 { 0 } else { exit_code }) } /// Parse go test -json output (NDJSON format) diff --git a/src/cmds/go/golangci_cmd.rs b/src/cmds/go/golangci_cmd.rs index 14cfa382c3..94a50e6c3e 100644 --- a/src/cmds/go/golangci_cmd.rs +++ b/src/cmds/go/golangci_cmd.rs @@ -1,9 +1,8 @@ //! Filters golangci-lint output, grouping issues by rule. use crate::core::config; -use crate::core::tracking; use crate::core::utils::{resolved_command, truncate}; -use anyhow::{Context, Result}; +use anyhow::Result; use serde::Deserialize; use std::collections::HashMap; @@ -80,9 +79,7 @@ pub(crate) fn detect_major_version() -> u32 { } } -pub fn run(args: &[String], verbose: u8) -> Result<()> { - let timer = tracking::TimedExecution::start(); - +pub fn run(args: &[String], verbose: u8) -> Result { let version = detect_major_version(); let mut cmd = resolved_command("golangci-lint"); @@ -117,49 +114,25 @@ pub fn run(args: &[String], verbose: u8) -> Result<()> { } } - let output = cmd.output().context( - "Failed to run golangci-lint. Is it installed? Try: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest", + let exit_code = crate::core::runner::run_filtered( + cmd, + "golangci-lint", + &args.join(" "), + |stdout| { + // v2 outputs JSON on first line + trailing text; v1 outputs just JSON + let json_output = if version >= 2 { + stdout.lines().next().unwrap_or("") + } else { + stdout + }; + filter_golangci_json(json_output, version) + }, + crate::core::runner::RunOptions::stdout_only(), )?; - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - let raw = format!("{}\n{}", stdout, stderr); - - // v2 outputs JSON on first line + trailing text; v1 outputs just JSON - let json_output = if version >= 2 { - stdout.lines().next().unwrap_or("") - } else { - &*stdout - }; - - let filtered = filter_golangci_json(json_output, version); - - println!("{}", filtered); - - // Always forward stderr (config errors, missing linters, etc.) - if !stderr.trim().is_empty() { - eprintln!("{}", stderr.trim()); - } - - timer.track( - &format!("golangci-lint {}", args.join(" ")), - &format!("rtk golangci-lint {}", args.join(" ")), - &raw, - &filtered, - ); - - // golangci-lint: exit 0 = clean, exit 1 = lint issues, exit 2+ = config/build error - // None = killed by signal (OOM, SIGKILL) — always fatal - match output.status.code() { - Some(0) | Some(1) => Ok(()), - Some(code) => { - std::process::exit(code); - } - None => { - eprintln!("golangci-lint: killed by signal"); - std::process::exit(130); - } - } + // golangci-lint: exit 0 = clean, exit 1 = lint issues found (not an error), + // exit 2+ = config/build error, None = killed by signal (OOM, SIGKILL) + Ok(if exit_code == 1 { 0 } else { exit_code }) } /// Filter golangci-lint JSON output - group by linter and file diff --git a/src/cmds/js/lint_cmd.rs b/src/cmds/js/lint_cmd.rs index e7a88e890e..f407927d8c 100644 --- a/src/cmds/js/lint_cmd.rs +++ b/src/cmds/js/lint_cmd.rs @@ -85,7 +85,7 @@ fn detect_linter(args: &[String]) -> (&str, bool) { } } -pub fn run(args: &[String], verbose: u8) -> Result<()> { +pub fn run(args: &[String], verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); let skip = strip_pm_prefix(args); @@ -181,7 +181,7 @@ pub fn run(args: &[String], verbose: u8) -> Result<()> { stderr.lines().take(5).collect::>().join("\n") ); } - return Ok(()); + return Ok(crate::core::utils::exit_code_from_output(&output, "eslint")); } let stdout = String::from_utf8_lossy(&output.stdout); @@ -222,10 +222,10 @@ pub fn run(args: &[String], verbose: u8) -> Result<()> { ); if !output.status.success() { - std::process::exit(output.status.code().unwrap_or(1)); + return Ok(crate::core::utils::exit_code_from_output(&output, "eslint")); } - Ok(()) + Ok(0) } /// Filter ESLint JSON output - group by rule and file diff --git a/src/cmds/js/next_cmd.rs b/src/cmds/js/next_cmd.rs index 5a7ad353df..1e17d60c5d 100644 --- a/src/cmds/js/next_cmd.rs +++ b/src/cmds/js/next_cmd.rs @@ -1,13 +1,11 @@ //! Filters Next.js build output down to route metrics and bundle sizes. -use crate::core::tracking; +use crate::core::runner; use crate::core::utils::{resolved_command, strip_ansi, tool_exists, truncate}; -use anyhow::{Context, Result}; +use anyhow::Result; use regex::Regex; -pub fn run(args: &[String], verbose: u8) -> Result<()> { - let timer = tracking::TimedExecution::start(); - +pub fn run(args: &[String], verbose: u8) -> Result { // Try next directly first, fallback to npx if not found let next_exists = tool_exists("next"); @@ -30,25 +28,13 @@ pub fn run(args: &[String], verbose: u8) -> Result<()> { eprintln!("Running: {} build", tool); } - let output = cmd - .output() - .context("Failed to run next build (try: npm install -g next)")?; - 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_next_build(&raw); - - println!("{}", filtered); - - timer.track("next build", "rtk next build", &raw, &filtered); - - // Preserve exit code for CI/CD - if !output.status.success() { - std::process::exit(output.status.code().unwrap_or(1)); - } - - Ok(()) + runner::run_filtered( + cmd, + "next build", + &args.join(" "), + |raw| filter_next_build(raw), + runner::RunOptions::default(), + ) } /// Filter Next.js build output - extract routes, bundles, warnings diff --git a/src/cmds/js/npm_cmd.rs b/src/cmds/js/npm_cmd.rs index 7c86fe7761..6f310a32c5 100644 --- a/src/cmds/js/npm_cmd.rs +++ b/src/cmds/js/npm_cmd.rs @@ -1,8 +1,8 @@ //! Filters npm output and auto-injects the "run" subcommand when appropriate. -use crate::core::tracking; +use crate::core::runner; use crate::core::utils::resolved_command; -use anyhow::{Context, Result}; +use anyhow::Result; /// Known npm subcommands that should NOT get "run" injected. /// Shared between production code and tests to avoid drift. @@ -73,9 +73,7 @@ const NPM_SUBCOMMANDS: &[&str] = &[ "restart", ]; -pub fn run(args: &[String], verbose: u8, skip_env: bool) -> Result<()> { - let timer = tracking::TimedExecution::start(); - +pub fn run(args: &[String], verbose: u8, skip_env: bool) -> Result { let mut cmd = resolved_command("npm"); // Determine if this is "npm run