diff --git a/.claude/rules/cli-testing.md b/.claude/rules/cli-testing.md index 58fdad60e2..d30f580583 100644 --- a/.claude/rules/cli-testing.md +++ b/.claude/rules/cli-testing.md @@ -2,69 +2,72 @@ Comprehensive testing rules for RTK CLI tool development. -## Snapshot Testing (๐Ÿ”ด Critical) +## Unit Testing (๐Ÿ”ด Critical) **Priority**: ๐Ÿ”ด **Triggers**: All filter changes, output format modifications -Use `insta` crate for output validation. This is the **primary testing strategy** for RTK filters. +Use plain `#[cfg(test)] mod tests` block colocated in the same file as the filter, +using `assert_eq!`/`assert!` directly against expected output. -### Basic Snapshot Test +### Basic Unit Test ```rust -use insta::assert_snapshot; - -#[test] -fn test_git_log_output() { - let input = include_str!("../tests/fixtures/git_log_raw.txt"); - let output = filter_git_log(input); +#[cfg(test)] +mod tests { + use super::*; - // Snapshot test - will fail if output changes - assert_snapshot!(output); + #[test] + fn test_git_log_output() { + let input = "abc1234 fix: handle empty commit\ndef5678 feat: add filter\n"; + let output = filter_git_log(input); + assert_eq!(output, "abc1234 fix: handle empty commit\ndef5678 feat: add filter"); + } } ``` -### Workflow +### Fixture strategy + +Two patterns coexist, pick based on what the filter needs: -1. **Write test**: Add `assert_snapshot!(output);` in test -2. **Run tests**: `cargo test` (creates new snapshots on first run) -3. **Review snapshots**: `cargo insta review` (interactive review) -4. **Accept changes**: `cargo insta accept` (if output is correct) +1. **Inline literal strings** (most common for `src/cmds/**` unit tests) โ€” build a small + representative string directly in the test body. Used throughout `src/cmds/git/git.rs`, + `src/cmds/git/gh_cmd.rs`, etc. Good for quick coverage of a specific format/edge case. +2. **Real captured fixtures via `include_str!`** โ€” used when the raw output is large or + format-sensitive enough that inline strings would be unreadable or drift from reality. + `src/cmds/jvm/mvn_cmd.rs` is the reference example (23+ `include_str!` fixtures). Fixtures + for these live in `tests/fixtures/` (e.g. `tests/fixtures/mvn_test_pass_slice_raw.txt`, + `tests/fixtures/gradlew_build_raw.txt`, `tests/fixtures/glab_mr_list_raw.json`). ### When to Use -- **Every new filter**: All filters must have snapshot test -- **Output format changes**: When modifying filter logic -- **Regression detection**: Catch unintended changes +- **Every new filter**: cover the common case and at least one edge case (empty input, error output). +- **Output format changes**: update the relevant `assert_eq!` expectations when filter logic changes. +- **Regression detection**: prefer a real fixture (`include_str!`) over an inline string once + output size/format makes hand-written strings brittle or unrepresentative. ### Example Workflow ```bash -# 1. Create fixture from real command -git log -20 > tests/fixtures/git_log_raw.txt +# 1. Capture real output for a fixture-backed test (only needed for the include_str! pattern) +mvn test > tests/fixtures/mvn_test_example_raw.txt -# 2. Write test with assert_snapshot! -cat > src/cmds/git/git.rs <<'EOF' +# 2. Write the test in the module under test +cat >> src/cmds/jvm/mvn_cmd.rs <<'EOF' #[cfg(test)] mod tests { - use insta::assert_snapshot; + use super::*; #[test] - fn test_git_log_format() { - let input = include_str!("../tests/fixtures/git_log_raw.txt"); - let output = filter_git_log(input); - assert_snapshot!(output); + fn test_mvn_test_example() { + let input = include_str!("../../../tests/fixtures/mvn_test_example_raw.txt"); + let output = filter_mvn_test(input); + assert!(output.contains("FAILED") || output.contains("PASSED")); } } EOF -# 3. Run test (creates snapshot) -cargo test test_git_log_format - -# 4. Review snapshot -cargo insta review -# Press 'a' to accept, 'r' to reject - -# 5. Snapshot saved in src/cmds/git/snapshots/git__tests__*.snap +# 3. Run the test +cargo test test_mvn_test_example ``` ## Token Accuracy Testing (๐Ÿ”ด Critical) @@ -84,7 +87,7 @@ mod tests { #[test] fn test_git_log_savings() { - let input = include_str!("../tests/fixtures/git_log_raw.txt"); + let input = "..."; // inline string or include_str! fixture let output = filter_git_log(input); let input_tokens = count_tokens(input); @@ -116,15 +119,13 @@ pnpm list > tests/fixtures/pnpm_list_raw.txt # let input = include_str!("../tests/fixtures/git_log_raw.txt"); ``` -### Savings Targets by Filter +### Savings Target -| Filter | Expected Savings | Rationale | -|--------|------------------|-----------| -| `git log` | 80%+ | Condense commits to hash + message | -| `cargo test` | 90%+ | Show failures only | -| `gh pr view` | 87%+ | Remove ASCII art, verbose metadata | -| `pnpm list` | 70%+ | Compact dependency tree | -| `docker ps` | 60%+ | Essential fields only | +There is a single enforced floor, not a per-filter table: **โ‰ฅ60% savings is the release +blocker** (see CLAUDE.md's "Pre-commit Gate" / performance targets). Individual filters often +exceed this by a wide margin, but don't assert specific per-command percentages (e.g. "87% for +`gh pr view`") unless you've verified the actual number against that filter's own fixtures โ€” +asserted thresholds vary per filter and doc tables listing invented numbers rot immediately. **Release blocker**: If savings drop below 60% for any filter, investigate and fix before merge. @@ -161,18 +162,13 @@ fn test_shell_escaping() { ### Testing Platforms -**macOS (primary)**: +**Linux/macOS (primary)**: ```bash cargo test # Local testing ``` -**Linux (via Docker)**: -```bash -docker run --rm -v $(pwd):/rtk -w /rtk rust:latest cargo test -``` - **Windows (via CI)**: -Trust GitHub Actions CI/CD pipeline or test manually if Windows machine available. +Trust GitHub Actions CI/CD pipeline or test manually if a Windows machine is available. ### Shell Differences @@ -186,7 +182,13 @@ Trust GitHub Actions CI/CD pipeline or test manually if Windows machine availabl **Priority**: ๐ŸŸก **Triggers**: New filter, command routing changes, release preparation -Integration tests execute real commands via RTK to verify end-to-end behavior. +Integration tests live as top-level files in `tests/` (not colocated with `src/`), e.g. +`tests/grep_context_test.rs`, `tests/grep_faithful_format_test.rs`, +`tests/guard_integration_test.rs`, `tests/search_compress_test.rs`, +`tests/search_error_test.rs`, `tests/search_faithful_test.rs`. These exercise cross-cutting +behavior (search/grep compression, guard rails, faithful formatting) rather than a single +filter module, and several of them draw on `tests/fixtures/` (real captured aws/glab/gradlew/ +mvn/phpstan/dotnet output) alongside their own inline cases. ### Real Command Execution @@ -218,18 +220,21 @@ fn test_real_git_log() { # 1. Install RTK locally cargo install --path . -# 2. Run integration tests +# 2. Run all tests, including top-level tests/*.rs integration tests +cargo test --all + +# 3. Run ignored (real-process) integration tests cargo test --ignored -# 3. Run specific test +# 4. Run specific test cargo test --ignored test_real_git_log ``` ### When to Run -- **Before release**: Always run integration tests -- **After filter changes**: Verify filter works with real command -- **After hook changes**: Verify Claude Code integration works +- **Before release**: Always run integration tests. +- **After filter changes**: Verify the filter works with real command output. +- **After hook changes**: Verify Claude Code integration works. ## Performance Testing (๐ŸŸก Important) @@ -303,71 +308,72 @@ rtk/ โ”‚ โ”‚ โ”œโ”€โ”€ git/ โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ git.rs # Filter implementation โ”‚ โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ #[cfg(test)] mod tests { ... } -โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ snapshots/ # Insta snapshots for git module -โ”‚ โ”‚ โ”œโ”€โ”€ js/ # JS/TS ecosystem filters -โ”‚ โ”‚ โ”œโ”€โ”€ python/ # Python ecosystem filters +โ”‚ โ”‚ โ”œโ”€โ”€ jvm/ # gradlew, mvn โ€” reference example for include_str! fixtures +โ”‚ โ”‚ โ”œโ”€โ”€ php/ # php, artisan, phpunit, phpstan, pest, paratest, ecs, pint โ”‚ โ”‚ โ””โ”€โ”€ ... โ”‚ โ”œโ”€โ”€ core/ # Shared infrastructure โ”‚ โ”œโ”€โ”€ hooks/ # Hook system โ”‚ โ””โ”€โ”€ analytics/ # Token savings analytics โ”œโ”€โ”€ tests/ -โ”‚ โ”œโ”€โ”€ common/ -โ”‚ โ”‚ โ””โ”€โ”€ mod.rs # Shared test utilities (count_tokens) -โ”‚ โ”œโ”€โ”€ fixtures/ # Real command output -โ”‚ โ”‚ โ”œโ”€โ”€ git_log_raw.txt -โ”‚ โ”‚ โ”œโ”€โ”€ cargo_test_raw.txt -โ”‚ โ”‚ โ”œโ”€โ”€ gh_pr_view_raw.txt -โ”‚ โ”‚ โ””โ”€โ”€ dotnet/ # Dotnet-specific fixtures -โ”‚ โ””โ”€โ”€ integration_test.rs # Integration tests (#[ignore]) +โ”‚ โ”œโ”€โ”€ fixtures/ # Real captured command output +โ”‚ โ”‚ โ”œโ”€โ”€ mvn_test_pass_slice_raw.txt +โ”‚ โ”‚ โ”œโ”€โ”€ gradlew_build_raw.txt +โ”‚ โ”‚ โ”œโ”€โ”€ glab_mr_list_raw.json +โ”‚ โ”‚ โ””โ”€โ”€ ... +โ”‚ โ”œโ”€โ”€ grep_context_test.rs # Top-level integration tests +โ”‚ โ”œโ”€โ”€ grep_faithful_format_test.rs +โ”‚ โ”œโ”€โ”€ guard_integration_test.rs +โ”‚ โ”œโ”€โ”€ search_compress_test.rs +โ”‚ โ”œโ”€โ”€ search_error_test.rs +โ”‚ โ””โ”€โ”€ search_faithful_test.rs ``` **Best practices**: -- **Unit tests**: Embedded in module (`#[cfg(test)] mod tests`) -- **Fixtures**: Real command output in `tests/fixtures/` -- **Snapshots**: Auto-generated in `src/cmds//snapshots/` (by insta) -- **Shared utils**: `tests/common/mod.rs` (count_tokens, helpers) -- **Integration**: `tests/` with `#[ignore]` attribute +- **Unit tests**: Embedded in module (`#[cfg(test)] mod tests`), colocated with the filter. +- **Fixtures**: Prefer inline strings for small/quick cases; use `include_str!` from + `tests/fixtures/` (real command output) once a case gets large or format-sensitive โ€” see + `src/cmds/jvm/mvn_cmd.rs` for the pattern. +- **`count_tokens` helper**: currently duplicated per test module โ€” don't assume a shared + `tests/common/mod.rs` exists. +- **Integration**: top-level `tests/*.rs` files, some with `#[ignore]`-tagged real-process tests. ## Testing Checklist When adding/modifying a filter: ### Implementation Phase -- [ ] Create fixture from real command output -- [ ] Add snapshot test with `assert_snapshot!()` -- [ ] Add token accuracy test (verify โ‰ฅ60% savings) +- [ ] Write a unit test in the filter's own `#[cfg(test)] mod tests` block (inline string, or + `include_str!` fixture for larger/real output) +- [ ] Add a token accuracy test (verify โ‰ฅ60% savings) using a locally-defined `count_tokens` - [ ] Test cross-platform shell escaping (if applicable) ### Quality Checks - [ ] Run `cargo test --all` (all tests pass) -- [ ] Run `cargo insta review` (review snapshots) - [ ] Run `cargo test --ignored` (integration tests pass) - [ ] Benchmark startup time with `hyperfine` (<10ms) ### Before Merge - [ ] All tests passing (`cargo test --all`) -- [ ] Snapshots reviewed and accepted (`cargo insta accept`) - [ ] Token savings โ‰ฅ60% verified -- [ ] Cross-platform tests passed (macOS + Linux) +- [ ] Cross-platform tests passed (Linux + macOS) - [ ] Performance benchmarks passed (<10ms startup) ### Before Release - [ ] Integration tests passed (`cargo test --ignored`) - [ ] Performance regression check (hyperfine comparison) - [ ] Memory usage verified (<5MB with `time -l`) -- [ ] Cross-platform CI passed (macOS + Linux + Windows) +- [ ] Cross-platform CI passed (Linux + macOS + Windows) ## Common Testing Patterns -### Pattern: Snapshot + Token Accuracy +### Pattern: Inline Fixture + Token Accuracy -**Use case**: Testing filter output format and savings +**Use case**: Testing filter output format and savings for a small/synthetic case ```rust #[cfg(test)] mod tests { use super::*; - use insta::assert_snapshot; fn count_tokens(text: &str) -> usize { text.split_whitespace().count() @@ -375,22 +381,36 @@ mod tests { #[test] fn test_output_format() { - let input = include_str!("../tests/fixtures/cmd_raw.txt"); + let input = "raw command output here"; let output = filter_cmd(input); - assert_snapshot!(output); + assert_eq!(output, "expected filtered output"); } #[test] fn test_token_savings() { - let input = include_str!("../tests/fixtures/cmd_raw.txt"); + let input = "raw command output here"; let output = filter_cmd(input); let savings = 100.0 - (count_tokens(&output) as f64 / count_tokens(input) as f64 * 100.0); - assert!(savings >= 60.0, "Expected โ‰ฅ60% savings, got {:.1}%", savings); + assert!(savings >= 60.0, "Expected >=60% savings, got {:.1}%", savings); } } ``` +### Pattern: `include_str!` Fixture (real captured output) + +**Use case**: Filter output is large enough or format-sensitive enough that hand-written +strings would drift from reality โ€” see `src/cmds/jvm/mvn_cmd.rs`. + +```rust +#[test] +fn test_mvn_test_pass() { + let input = include_str!("../../../tests/fixtures/mvn_test_pass_slice_raw.txt"); + let output = filter_mvn_test(input); + assert!(output.contains("BUILD SUCCESS")); +} +``` + ### Pattern: Edge Case Testing **Use case**: Testing filter robustness @@ -460,12 +480,11 @@ let output = filter_git_log(input); // Synthetic data doesn't reflect real command output ``` -โœ… **DO** use real command fixtures +โœ… **DO** assert directly on expected output ```rust // โœ… RIGHT -let input = include_str!("../tests/fixtures/git_log_raw.txt"); let output = filter_git_log(input); -// Real output from `git log -20` +assert_eq!(output, "expected output"); ``` โŒ **DON'T** skip cross-platform tests diff --git a/.claude/rules/search-strategy.md b/.claude/rules/search-strategy.md index 0b4504df7f..4d0a64ec98 100644 --- a/.claude/rules/search-strategy.md +++ b/.claude/rules/search-strategy.md @@ -24,14 +24,23 @@ src/ โ”‚ โ”œโ”€โ”€ filter.rs โ† Language-aware code filtering engine โ”‚ โ”œโ”€โ”€ toml_filter.rs โ† TOML DSL filter engine โ”‚ โ”œโ”€โ”€ display_helpers.rs โ† Terminal formatting helpers -โ”‚ โ””โ”€โ”€ telemetry.rs โ† Analytics ping +โ”‚ โ”œโ”€โ”€ telemetry.rs โ† Analytics ping +โ”‚ โ”œโ”€โ”€ telemetry_cmd.rs โ† rtk telemetry command +โ”‚ โ”œโ”€โ”€ args_utils.rs โ† Shared CLI arg parsing helpers +โ”‚ โ”œโ”€โ”€ constants.rs โ† Shared constants +โ”‚ โ”œโ”€โ”€ guard.rs โ† Guard-rail checks +โ”‚ โ”œโ”€โ”€ runner.rs โ† Command execution runner +โ”‚ โ””โ”€โ”€ stream.rs โ† Streaming output handling โ”œโ”€โ”€ hooks/ โ† Hook system โ”‚ โ”œโ”€โ”€ init.rs โ† rtk init command โ”‚ โ”œโ”€โ”€ rewrite_cmd.rs โ† rtk rewrite command โ”‚ โ”œโ”€โ”€ hook_cmd.rs โ† Gemini/Copilot hook processors โ”‚ โ”œโ”€โ”€ hook_check.rs โ† Hook status detection +โ”‚ โ”œโ”€โ”€ hook_audit_cmd.rs โ† rtk hook audit command โ”‚ โ”œโ”€โ”€ verify_cmd.rs โ† rtk verify command โ”‚ โ”œโ”€โ”€ trust.rs โ† Project trust/untrust +โ”‚ โ”œโ”€โ”€ permissions.rs โ† Hook permission handling +โ”‚ โ”œโ”€โ”€ constants.rs โ† Shared hook constants โ”‚ โ””โ”€โ”€ integrity.rs โ† SHA-256 hook verification โ”œโ”€โ”€ analytics/ โ† Token savings analytics โ”‚ โ”œโ”€โ”€ gain.rs โ† rtk gain command @@ -47,11 +56,13 @@ src/ โ”‚ โ”œโ”€โ”€ dotnet/ โ† dotnet, binlog, trx, format_report โ”‚ โ”œโ”€โ”€ cloud/ โ† aws, container (docker/kubectl), curl, wget, psql โ”‚ โ”œโ”€โ”€ system/ โ† ls, tree, read, grep, find, wc, env, json, log, deps, summary, format, local_llm -โ”‚ โ””โ”€โ”€ ruby/ โ† rake, rspec, rubocop +โ”‚ โ”œโ”€โ”€ ruby/ โ† rake, rspec, rubocop +โ”‚ โ”œโ”€โ”€ jvm/ โ† gradlew, mvn +โ”‚ โ””โ”€โ”€ php/ โ† php, artisan, phpunit, phpstan, pest, paratest, ecs, pint โ”œโ”€โ”€ discover/ โ† Claude Code history analysis โ”œโ”€โ”€ learn/ โ† CLI correction detection โ”œโ”€โ”€ parser/ โ† Parser infrastructure -โ””โ”€โ”€ filters/ โ† 60 TOML filter configs +โ””โ”€โ”€ filters/ โ† 63 TOML filter configs ``` ## Common Search Patterns diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index cf6901c540..9db4887f2f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,6 +1,6 @@ # Copilot Instructions for rtk -**rtk (Rust Token Killer)** is a CLI proxy that filters and compresses command outputs before they reach an LLM context, saving 60-90% of tokens. It wraps common tools (`git`, `cargo`, `grep`, `pnpm`, `go`, etc.) and outputs condensed summaries instead of raw output. +**rtk (Rust Token Killer)** is a CLI proxy that filters and compresses command outputs before they reach an LLM context, cutting 60-90% of bash output. It wraps common tools (`git`, `cargo`, `grep`, `pnpm`, `go`, etc.) and outputs condensed summaries instead of raw output. Percentages measure bash output, not billed tokens; RTK ships no tokenizer (`src/core/tracking.rs` estimates `bytes / 4`). ## Using rtk in this session diff --git a/.github/workflows/next-release.yml b/.github/workflows/next-release.yml index e7535f8642..756c7c4279 100644 --- a/.github/workflows/next-release.yml +++ b/.github/workflows/next-release.yml @@ -1,7 +1,7 @@ name: Update Next Release PR on: - pull_request: + pull_request_target: types: [closed] branches: [develop] diff --git a/CHANGELOG.md b/CHANGELOG.md index 2933768b44..614bc23930 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -524,6 +524,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Bug Fixes * **cargo:** preserve compile diagnostics when `cargo test` fails before any test suites run + ## [0.31.0](https://github.com/rtk-ai/rtk/compare/v0.30.1...v0.31.0) (2026-03-19) diff --git a/CLAUDE.md b/CLAUDE.md index 9e89fff388..e17043d092 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -**rtk (Rust Token Killer)** is a high-performance CLI proxy that minimizes LLM token consumption by filtering and compressing command outputs. It achieves 60-90% token savings on common development operations through smart filtering, grouping, truncation, and deduplication. +**rtk (Rust Token Killer)** is a high-performance CLI proxy that minimizes LLM token consumption by filtering and compressing command outputs. It reduces bash output by 60-90% on common development operations through smart filtering, grouping, truncation, and deduplication. All percentages in this repo measure bash output, not your bill. RTK ships no tokenizer (`src/core/tracking.rs` estimates tokens as `bytes / 4`), so the ratios are reliable but the absolute token counts are approximate. This is a fork with critical fixes for git argument parsing and modern JavaScript stack support (pnpm, vitest, Next.js, TypeScript, Playwright, Prisma). @@ -75,7 +75,7 @@ For the full architecture, component details, and module development patterns, s Module responsibilities are documented in each folder's `README.md` and each file's `//!` doc header. Browse `src/cmds/*/` to discover available filters. -Supported ecosystems: git/gh/gt, cargo, go/golangci-lint, npm/pnpm/npx, ruff/pytest/pip/mypy, rspec/rubocop/rake, dotnet, playwright/vitest/jest, docker/kubectl/aws. +Supported ecosystems: git/gh/gt, cargo, go/golangci-lint, npm/pnpm/npx, ruff/pytest/pip/mypy, rspec/rubocop/rake, dotnet, playwright/vitest/jest, docker/kubectl/aws, gradlew/mvn, php/artisan/phpunit/phpstan/pest. ### Proxy Mode @@ -95,7 +95,7 @@ rtk proxy npm install express # Raw npm output (no filtering) rtk proxy curl https://api.example.com/data # Any command works ``` -All proxy commands appear in `rtk gain --history` with 0% savings (input = output). +All proxy commands appear in `rtk gain --history` with 0% bash output reduction (input = output). ## Coding Rules @@ -108,7 +108,7 @@ Rust patterns, error handling, and anti-patterns are defined in `.claude/rules/r - **No async**: single-threaded by design (startup <10ms) - **Exit code propagation**: `std::process::exit(code)` on child failure -Testing strategy and performance targets are defined in `.claude/rules/cli-testing.md` (auto-loaded). Key targets: <10ms startup, <5MB memory, 60-90% token savings. +Testing strategy and performance targets are defined in `.claude/rules/cli-testing.md` (auto-loaded). Key targets: <10ms startup, <5MB memory, 60-90% reduction in bash output bytes. For contribution workflow and design philosophy, see [CONTRIBUTING.md](CONTRIBUTING.md). For the step-by-step filter implementation checklist, see [src/cmds/README.md](src/cmds/README.md#adding-a-new-command-filter). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b10b9e710b..714ec800e0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,7 +13,9 @@ ## What is rtk? -**rtk (Rust Token Killer)** is a coding agent proxy that cuts noise from command outputs. It filters and compresses CLI output before it reaches your LLM context, saving 60-90% of tokens on common operations. The vision is to make AI-assisted development faster and cheaper by eliminating unnecessary token consumption. +**rtk (Rust Token Killer)** is a coding agent proxy that cuts noise from command outputs. It filters and compresses CLI output before it reaches your LLM context, reducing bash output by 60-90% on common operations. The vision is to make AI-assisted development faster and cheaper by eliminating unnecessary token consumption. + +Every percentage in this repo measures **bash output**, not your bill: those bytes are one contributor to input tokens, and input tokens are only part of a cost that also counts output tokens. See [How RTK Savings Work](docs/guide/resources/savings-explained.md) before quoting any figure. --- @@ -38,7 +40,7 @@ When a user or LLM explicitly requests detailed output via flags (e.g., `git log Filters should be flag-aware: default output (no flags) gets aggressively compressed, but verbose/detailed flags should pass through more content. When in doubt, preserve correctness. -> Example: `rtk cargo test` shows failures only (90% savings). But `rtk cargo test -- --nocapture` preserves all output because the user explicitly asked for it. +> Example: `rtk cargo test` shows failures only (90% less bash output). But `rtk cargo test -- --nocapture` preserves all output because the user explicitly asked for it. ### Transparency @@ -71,7 +73,7 @@ If you want to submit a new core feature, this is an important point to watch. ### In Scope -Commands that produce **text output** (typically 100+ tokens) and can be compressed **60%+** without losing essential information for the LLM. +Commands that produce **text output** (typically 100+ tokens) whose bytes can be compressed **20%+** without losing essential information for the LLM. See [Correctness VS Token Savings](#correctness-vs-token-savings). - Test runners (vitest, pytest, cargo test, go test) - Linters and type checkers (eslint, ruff, tsc, mypy) @@ -96,7 +98,7 @@ When implementing a new filter/cmds, be aware of the [Design Philosophy](#design | Use **TOML filter** when | Use **Rust module** when | |--------------------------|--------------------------| | Output is plain text with predictable line structure | Output is structured (JSON, NDJSON) | -| Regex line filtering achieves 60%+ savings | Needs state machine parsing (e.g., pytest phases) | +| Regex line filtering cuts 60%+ of the output bytes | Needs state machine parsing (e.g., pytest phases) | | No need to inject CLI flags | Needs to inject flags like `--format json` | | No cross-command routing | Routes to other commands (lint โ†’ ruff/mypy) | | Examples: brew, df, shellcheck, rsync, ping | Examples: vitest, pytest, golangci-lint, gh | @@ -207,7 +209,7 @@ Documentation updates are required for new filters, new features, and changes th ### Contributor License Agreement (CLA) -All contributions require signing our [Contributor License Agreement (CLA)](CLA.md) before being merged. +All contributions require signing our **Contributor License Agreement (CLA)** before being merged. By signing, you certify that: - You have authored 100% of the contribution, or have the necessary rights to submit it. @@ -247,8 +249,8 @@ For how to write tests (fixtures, snapshots, token savings verification), see [d | Type | Where | Run With | |------|-------|----------| | **Unit tests** | `#[cfg(test)] mod tests` in each module | `cargo test` | -| **Snapshot tests** | `assert_snapshot!()` via `insta` crate | `cargo test` + `cargo insta review` | -| **Smoke tests** | `scripts/test-all.sh` (69 assertions) | `bash scripts/test-all.sh` | +| **Snapshot tests** | `#[cfg(test)]` create snapshots for filters modules | `cargo test` | +| **Smoke tests** | `scripts/test-all.sh` | `bash scripts/test-all.sh` | | **Integration tests** | `#[ignore]` tests requiring installed binary | `cargo test --ignored` | ### Pre-Commit Gate (mandatory) @@ -262,8 +264,8 @@ cargo fmt --all --check && cargo clippy --all-targets && cargo test ### PR Testing Checklist - [ ] Unit tests added/updated for changed code -- [ ] Snapshot tests reviewed (`cargo insta review`) -- [ ] Token savings >=60% verified +- [ ] Snapshot tests for filters +- [ ] >=20% reduction in bash output verified (measured with RTK's token estimator, not a real tokenizer) - [ ] Any truncated list has a recovery hint (`force_tee_tail_hint` or `force_tee_hint`) and uses a `CAP_*` from `src/core/truncate.rs` - [ ] Edge cases covered - [ ] `cargo fmt --all --check && cargo clippy --all-targets && cargo test` passes diff --git a/INSTALL.md b/INSTALL.md index fb231500da..ea79607d9a 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -6,7 +6,7 @@ 1. โœ… **Rust Token Killer** (this project) - LLM token optimizer - Repos: `rtk-ai/rtk` - - Has `rtk gain` command for token savings stats + - Has `rtk gain` command showing the savings dashboard 2. โŒ **Rust Type Kit** (reachingforthejack/rtk) - DIFFERENT PROJECT - Rust codebase query tool and type generator @@ -21,7 +21,7 @@ rtk --version # CRITICAL: Verify it's the Token Killer (not Type Kit) -rtk gain # Should show token savings stats, NOT "command not found" +rtk gain # Should show the savings dashboard, NOT "command not found" # Check installation path which rtk @@ -49,7 +49,7 @@ curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/master/install.sh | sh After installation, **verify you have the correct rtk**: ```bash -rtk gain # Must show token savings stats (not "command not found") +rtk gain # Must show the savings dashboard (not "command not found") ``` ### Alternative: Manual Installation @@ -62,7 +62,7 @@ cargo install --git https://github.com/rtk-ai/rtk cargo install rtk # ALWAYS VERIFY after installation -rtk gain # MUST show token savings, not "command not found" +rtk gain # MUST show the savings dashboard, not "command not found" ``` โš ๏ธ **WARNING**: `cargo install rtk` from crates.io might install the wrong package. Always verify with `rtk gain`. @@ -107,7 +107,7 @@ rtk init -g --no-patch # Print manual instructions instead rtk init --show # Check hook is installed and executable ``` -**Token savings**: ~99.5% reduction (2000 tokens โ†’ 10 tokens in context) +**Context cost**: the hook adds a 10-line `RTK.md` to your context instead of a full command reference, and rewrites commands transparently at no per-command context cost. **What is settings.json?** Claude Code's hook registry. RTK adds a PreToolUse hook that rewrites commands transparently. Without this, Claude won't invoke the hook automatically. @@ -146,7 +146,7 @@ cd /path/to/your/project rtk init # Creates ./CLAUDE.md with full RTK instructions (137 lines) ``` -**Token savings**: Instructions loaded only for this project +**Context cost**: instructions loaded only for this project ### Upgrading from Previous Version @@ -180,7 +180,7 @@ rtk init --show ```bash # 1. Install RTK cargo install --git https://github.com/rtk-ai/rtk -rtk gain # Verify (must show token stats) +rtk gain # Verify (must show the savings dashboard) # 2. Setup with prompts rtk init -g @@ -294,9 +294,11 @@ rtk git commit -m "msg" # โ†’ "ok โœ“ abc1234" rtk git push # โ†’ "ok โœ“ main" ``` +> Percentages below are **reductions in bash output**, not reductions in your bill. + ### Pnpm (fork only) ```bash -rtk pnpm list # Dependency tree (-70% tokens) +rtk pnpm list # Dependency tree (-70%) rtk pnpm outdated # Available updates (-80-90%) rtk pnpm install # Silent installation ``` @@ -316,25 +318,26 @@ rtk test # Generic test wrapper - failures only (-90%) ### Statistics ```bash -rtk gain # Token savings +rtk gain # Savings dashboard rtk gain --graph # With ASCII graph rtk gain --history # With command history ``` -## Validated Token Savings +## What RTK Filters + +RTK compresses the output of a shell command before your agent reads it. What that looks like in practice: + +| Operation | What RTK does to the output | +|-----------|-----------------------------| +| `vitest` / `jest` | Failures only; passing suites collapse to a count | +| `git status` | Compact stat format, grouped by state | +| `pnpm list` | Compact dependency tree | +| `pnpm outdated` | Package, current and target version only | +| `cargo test` | Failures only, with the assertion and location | -### Production T3 Stack Project -| Operation | Standard | RTK | Reduction | -|-----------|----------|-----|-----------| -| `vitest` | 102,199 chars | 377 chars | **-99.6%** | -| `git status` | 529 chars | 217 chars | **-59%** | -| `pnpm list` | ~8,000 tokens | ~2,400 | **-70%** | -| `pnpm outdated` | ~12,000 tokens | ~1,200-2,400 | **-80-90%** | +The percentages shown next to commands above are **reductions in bash output bytes**. That is the part RTK controls โ€” it is not the same as reducing your bill by the same amount, because bash output is only one contributor to input tokens, and input tokens are only part of a bill that also counts output tokens. -### Typical Claude Code Session (30 min) -- **Without RTK**: ~150,000 tokens -- **With RTK**: ~45,000 tokens -- **Savings**: **70% reduction** +See [How RTK Savings Work](docs/guide/resources/savings-explained.md) for the full explanation, including why the token counts RTK reports are estimates. ## Troubleshooting diff --git a/README.md b/README.md index 1d81e2bb75..e019e80e82 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@

- High-performance CLI proxy that reduces LLM token consumption by 60-90% + High-performance CLI proxy that cuts up to 90% of the bash output your agent reads

@@ -36,25 +36,34 @@ rtk filters and compresses command outputs before they reach your LLM context. Single Rust binary, 100+ supported commands, <10ms overhead. -## Token Savings (30-min Claude Code Session) - -| Operation | Frequency | Standard | rtk | Savings | -|-----------|-----------|----------|-----|---------| -| `ls` / `tree` | 10x | 2,000 | 400 | -80% | -| `cat` / `read` | 20x | 40,000 | 12,000 | -70% | -| `grep` / `rg` | 8x | 16,000 | 3,200 | -80% | -| `git status` | 10x | 3,000 | 600 | -80% | -| `git diff` | 5x | 10,000 | 2,500 | -75% | -| `git log` | 5x | 2,500 | 500 | -80% | -| `git add/commit/push` | 8x | 1,600 | 120 | -92% | -| `cargo test` / `npm test` | 5x | 25,000 | 2,500 | -90% | -| `ruff check` | 3x | 3,000 | 600 | -80% | -| `pytest` | 4x | 8,000 | 800 | -90% | -| `go test` | 3x | 6,000 | 600 | -90% | -| `docker ps` | 3x | 900 | 180 | -80% | -| **Total** | | **~118,000** | **~23,900** | **-80%** | - -> Estimates based on medium-sized TypeScript/Rust projects. Actual savings vary by project size. +## What RTK Does + +RTK intercepts shell commands and compresses their output before your agent reads it. + +| Operation | What RTK does to the output | +|-----------|-----------------------------| +| `ls` / `tree` | Tree format with file counts instead of one line per entry | +| `cat` / `read` | Smart file reading: signatures and structure over full bodies | +| `grep` / `rg` | Truncates long lines, groups matches by file | +| `git status` | Compact stat format, grouped by state | +| `git diff` | Reduced context, headers stripped | +| `git log` | Hash, author and subject only | +| `git add/commit/push` | Confirmation line instead of full progress output | +| `cargo test` / `npm test` | Failures only, passing tests collapsed to a count | +| `ruff check` | Grouped by rule and file | +| `pytest` | Failures only, traceback trimmed | +| `go test` | NDJSON parsed, failures only | +| `docker ps` | Essential fields only | + +## How Savings Work + +RTK cuts **up to 90% of the bash output** your agent reads. That is what RTK measures, and it is not the same as cutting your bill by 90%. + +Bash output is **one contributor to input tokens**, alongside your prompt, the system prompt and conversation history. Input tokens are in turn **only part of the bill**, which also counts output tokens. The reduction dilutes at every step. + +The token counts RTK reports are estimated as `bytes / 4` โ€” RTK ships no tokenizer, so the **percentages are reliable but the absolute token numbers are approximate**. + +> Full explanation: [How RTK Savings Work](docs/guide/resources/savings-explained.md) ## Installation @@ -88,13 +97,13 @@ Download from [releases](https://github.com/rtk-ai/rtk/releases): - Linux: `rtk-x86_64-unknown-linux-musl.tar.gz` / `rtk-aarch64-unknown-linux-gnu.tar.gz` - Windows: `rtk-x86_64-pc-windows-msvc.zip` -> **Windows users**: Extract the zip and place `rtk.exe` somewhere in your PATH (e.g. `C:\Users\\.local\bin`). Run RTK from **Command Prompt**, **PowerShell**, or **Windows Terminal** โ€” do not double-click the `.exe` (it will flash and close). For the best experience, use [WSL](https://learn.microsoft.com/en-us/windows/wsl/install) where the full hook system works natively. See [Windows setup](#windows) below for details. +> **Windows users**: Extract the zip and place `rtk.exe` somewhere in your PATH (e.g. `C:\Users\\.local\bin`). Run RTK from **Command Prompt**, **PowerShell**, or **Windows Terminal** โ€” do not double-click the `.exe` (it will flash and close). The full hook system works natively on Windows (and in [WSL](https://learn.microsoft.com/en-us/windows/wsl/install)). See [Windows setup](#windows) below for details. ### Verify Installation ```bash rtk --version # Should show "rtk 0.28.2" -rtk gain # Should show token savings stats +rtk gain # Should show the savings dashboard ``` > **Name collision warning**: Another project named "rtk" (Rust Type Kit) exists on crates.io. If `rtk gain` fails, you have the wrong package. Use `cargo install --git` above instead. @@ -111,8 +120,10 @@ rtk init -g --agent windsurf # Windsurf rtk init --agent cline # Cline / Roo Code rtk init --agent kilocode # Kilo Code rtk init --agent antigravity # Google Antigravity +rtk init --agent kimi # Kimi AI rtk init -g --agent pi # Pi rtk init --agent hermes # Hermes +rtk init -g --agent droid # Factory Droid # 2. Restart your AI tool, then test git status # Automatically rewritten to rtk git status @@ -129,7 +140,7 @@ Hook-based agents rewrite Bash commands (e.g., `git status` -> `rtk git status`) Claude --git status--> shell --> git Claude --git status--> RTK --> git ^ | ^ | | - | ~2,000 tokens (raw) | | ~200 tokens | filter | + | full raw output | | compact output | filter | +-----------------------------------+ +------- (filtered) ---+----------+ ``` @@ -142,9 +153,11 @@ Four strategies applied per command type: ## Commands +> Percentages below are **reductions in bash output**, not reductions in your bill. See [How Savings Work](#how-savings-work). + ### Files ```bash -rtk ls . # Token-optimized directory tree +rtk ls . # Compact directory tree rtk read file.rs # Smart file reading rtk read file.rs -l aggressive # Signatures only (strips bodies) rtk smart file.rs # 2-line heuristic code summary @@ -198,11 +211,15 @@ rtk cargo clippy # Cargo clippy (-80%) rtk ruff check # Python linting (JSON, -80%) rtk golangci-lint run # Go linting (JSON, -85%) rtk rubocop # Ruby linting (JSON, -60%+) +rtk sbt test # ScalaTest output (-90%) +rtk sbt compile # Compilation errors only (-75%) +rtk sbt run # Strip SBT preamble noise ``` ### Package Managers ```bash rtk pnpm list # Compact dependency tree +rtk uv run pytest # Preserve uv env, keep program output rtk pip list # Python packages (auto-detect uv) rtk pip outdated # Outdated packages rtk bundle install # Ruby gems (strip Using lines) @@ -273,7 +290,7 @@ rtk session # Show RTK adoption across recent sessions ## Global Flags ```bash --u, --ultra-compact # ASCII icons, inline format (extra token savings) +-u, --ultra-compact # ASCII icons, inline format (further output reduction) -v, --verbose # Increase verbosity (-v, -vv, -vvv) ``` @@ -281,7 +298,7 @@ rtk session # Show RTK adoption across recent sessions **Directory listing:** ``` -# ls -la (45 lines, ~800 tokens) # rtk ls (12 lines, ~150 tokens) +# ls -la (45 lines) # rtk ls (12 lines) drwxr-xr-x 15 user staff 480 ... my-project/ -rw-r--r-- 1 user staff 1234 ... +-- src/ (8 files) ... | +-- main.rs @@ -290,7 +307,7 @@ drwxr-xr-x 15 user staff 480 ... my-project/ **Git operations:** ``` -# git push (15 lines, ~200 tokens) # rtk git push (1 line, ~10 tokens) +# git push (15 lines) # rtk git push (1 line) Enumerating objects: 5, done. ok main Counting objects: 100% (5/5), done. Delta compression using up to 8 threads @@ -310,7 +327,7 @@ test utils::test_format ... ok test_overflow: panic at utils.rs:18 The most effective way to use rtk. The hook transparently intercepts Bash commands and rewrites them to rtk equivalents before execution. -**Result**: 100% rtk adoption across all conversations and subagents, zero token overhead. +**Result**: 100% rtk adoption across all conversations and subagents, with no per-command context overhead. **Scope note:** this only applies to Bash tool calls. Claude Code built-in tools such as `Read`, `Grep`, and `Glob` bypass the hook, so use shell commands or explicit `rtk` commands when you want RTK filtering there. @@ -328,48 +345,47 @@ After install, **restart Claude Code**. ## Windows -RTK works on Windows with some limitations. The auto-rewrite hook (`rtk-rewrite.sh`) requires a Unix shell, so on native Windows RTK falls back to **CLAUDE.md injection mode** โ€” your AI assistant receives RTK instructions but commands are not rewritten automatically. - -### Recommended: WSL (full support) +RTK works fully on native Windows. Since **v0.37.2** the auto-rewrite hook runs as a **native binary command** (`rtk hook claude`) โ€” no Unix shell, bash, or jq required โ€” so commands are rewritten transparently on Command Prompt, PowerShell, and Windows Terminal, just like on Linux and macOS. -For the best experience, use [WSL](https://learn.microsoft.com/en-us/windows/wsl/install) (Windows Subsystem for Linux). Inside WSL, RTK works exactly like Linux โ€” full hook support, auto-rewrite, everything: +### Native Windows -```bash -# Inside WSL -curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh +```powershell +# 1. Download and extract rtk-x86_64-pc-windows-msvc.zip from releases +# 2. Add rtk.exe to your PATH (e.g. C:\Users\\.local\bin) +# 3. Initialize โ€” installs the native binary hook rtk init -g ``` -### Native Windows (limited support) +**Upgrading from an older install?** If you set RTK up before v0.37.2 you may still have the legacy `rtk-rewrite.sh` shell hook (which does need a Unix shell). Re-run `rtk init -g` to migrate to the native binary hook. -On native Windows (cmd.exe / PowerShell), RTK filters work but the hook does not auto-rewrite commands: +**Prerequisites**: some filters shell out to [ripgrep](https://github.com/BurntSushi/ripgrep) (`rg`). Install it and keep it on your PATH (e.g. `winget install BurntSushi.ripgrep.MSVC`) to avoid `Binary 'rg' not found on PATH` warnings. -```powershell -# 1. Download and extract rtk-x86_64-pc-windows-msvc.zip from releases -# 2. Add rtk.exe to your PATH -# 3. Initialize (falls back to CLAUDE.md injection) +**Important**: Do not double-click `rtk.exe` โ€” it is a CLI tool that prints usage and exits immediately. Always run it from a terminal (Command Prompt, PowerShell, or Windows Terminal). + +### WSL + +[WSL](https://learn.microsoft.com/en-us/windows/wsl/install) also works and behaves exactly like Linux: + +```bash +# Inside WSL +curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh rtk init -g -# 4. Use rtk explicitly -rtk cargo test -rtk git status ``` -**Important**: Do not double-click `rtk.exe` โ€” it is a CLI tool that prints usage and exits immediately. Always run it from a terminal (Command Prompt, PowerShell, or Windows Terminal). - -| Feature | WSL | Native Windows | -|---------|-----|----------------| +| Feature | Native Windows | WSL | +|---------|----------------|-----| | Filters (cargo, git, etc.) | Full | Full | -| Auto-rewrite hook | Yes | No (CLAUDE.md fallback) | -| `rtk init -g` | Hook mode | CLAUDE.md mode | +| Auto-rewrite hook | Yes (native binary) | Yes | +| `rtk init -g` | Hook mode | Hook mode | | `rtk gain` / analytics | Full | Full | ## Supported AI Tools -RTK supports 14 AI coding tools. Each integration rewrites shell commands to `rtk` equivalents for 60-90% token savings where the agent supports command interception. +RTK supports 15 AI coding tools. Each integration rewrites shell commands to `rtk` equivalents, reducing the bash output the agent reads where the agent supports command interception. | Tool | Install | Method | |------|---------|--------| -| **Claude Code** | `rtk init -g` | PreToolUse hook (bash) | +| **Claude Code** | `rtk init -g` | PreToolUse hook (native binary) | | **GitHub Copilot (VS Code)** | `rtk init -g --copilot` | PreToolUse hook โ€” transparent rewrite | | **GitHub Copilot CLI** | `rtk init -g --copilot` | PreToolUse deny-with-suggestion (CLI limitation) | | **Cursor** | `rtk init -g --agent cursor` | preToolUse hook (hooks.json) | @@ -384,6 +400,8 @@ RTK supports 14 AI coding tools. Each integration rewrites shell commands to `rt | **Mistral Vibe** | Planned ([#800](https://github.com/rtk-ai/rtk/issues/800)) | Blocked on upstream | | **Kilo Code** | `rtk init --agent kilocode` | .kilocode/rules/rtk-rules.md (project-scoped) | | **Google Antigravity** | `rtk init --agent antigravity` | .agents/rules/antigravity-rtk-rules.md (project-scoped) | +| **Kimi AI** | `rtk init --agent kimi` | AGENTS.md (project-scoped) | +| **Factory Droid** | `rtk init -g --agent droid` (or per-project) | PreToolUse hook in `~/.factory/hooks.json` (matcher `Execute`) | For per-agent setup details, override controls, and graceful degradation, see the [Supported Agents guide](https://www.rtk-ai.app/guide/getting-started/supported-agents). The Hermes plugin source and tests live in `hooks/hermes/`; installed Hermes runtime files still live under `~/.hermes/plugins/rtk-rewrite/`. @@ -435,14 +453,14 @@ RTK can collect **anonymous, aggregate usage metrics** once per day. Telemetry i |----------|------|-----| | Identity | Salted device hash (SHA-256, not reversible) | Count unique installations without tracking individuals | | Environment | RTK version, OS, architecture, install method | Know which platforms to support and test | -| Usage volume | Command count (24h), total commands, tokens saved (24h/30d/total) | Measure adoption and value delivered | -| Quality | Top 5 passthrough commands (0% savings), parse failure count, commands with <30% savings | Identify missing filters and weak ones to improve | +| Usage volume | Command count (24h), total commands, estimated tokens saved (24h/30d/total) | Measure adoption and value delivered | +| Quality | Top 5 passthrough commands (0% reduction), parse failure count, commands with <30% reduction | Identify missing filters and weak ones to improve | | Ecosystem | Command category distribution (e.g. git 45%, cargo 20%, js 15%) | Prioritize filter development for popular ecosystems | | Retention | Days since first use, active days in last 30 | Understand engagement and detect churn | | Adoption | AI agent hook type (claude/gemini/codex), custom TOML filter count | Track integration coverage and DSL adoption | | Configuration | Whether config.toml exists, number of excluded commands, project count | Understand user maturity and customization patterns | | Features | Usage counts for meta-commands (gain, discover, proxy, verify) | Know which RTK features are valued vs unused | -| Economics | Estimated USD savings (based on API token pricing) | Quantify the value RTK provides to users | +| Economics | Estimated USD value, derived from the estimated tokens saved and a fixed internal constant | Quantify the value RTK provides to users | All data is **aggregate counts or anonymized command names** (first 3 words, no arguments). Top commands report only tool names (e.g. "git", "cargo"), never full command lines. @@ -489,6 +507,10 @@ export RTK_TELEMETRY_DISABLED=1 # Blocks telemetry regardless of consent [GitHub](https://github.com/FlorianBruniaux) ยท [LinkedIn](https://www.linkedin.com/in/florian-bruniaux-43408b83/) - **Adrien Eppling** โ€” Core contributor [GitHub](https://github.com/aeppling) ยท [LinkedIn](https://www.linkedin.com/in/adrien-eppling/) +- **Nicolas Le Cam** โ€” Core contributor + [Github](https://github.com/kush) ยท [LinkedIn](https://www.linkedin.com/in/nicolas-le-cam-386387160/) +- **Takayuki Maeda** โ€” Core contributor + [GitHub](https://github.com/TaKO8Ki) ยท [LinkedIn](https://www.linkedin.com/in/tako8ki/) ## Contributing diff --git a/README_es.md b/README_es.md index 10a6a53a5b..02795e7a4b 100644 --- a/README_es.md +++ b/README_es.md @@ -3,7 +3,7 @@

- Proxy CLI de alto rendimiento que reduce el consumo de tokens LLM en un 60-90% + Proxy CLI de alto rendimiento que elimina hasta el 90% de la salida bash que lee tu agente

@@ -35,16 +35,34 @@ rtk filtra y comprime las salidas de comandos antes de que lleguen al contexto de tu LLM. Binario Rust unico, cero dependencias, <10ms de overhead. -## Ahorro de tokens (sesion de 30 min en Claude Code) +## Que hace RTK -| Operacion | Frecuencia | Estandar | rtk | Ahorro | -|-----------|------------|----------|-----|--------| -| `ls` / `tree` | 10x | 2,000 | 400 | -80% | -| `cat` / `read` | 20x | 40,000 | 12,000 | -70% | -| `grep` / `rg` | 8x | 16,000 | 3,200 | -80% | -| `git status` | 10x | 3,000 | 600 | -80% | -| `cargo test` / `npm test` | 5x | 25,000 | 2,500 | -90% | -| **Total** | | **~118,000** | **~23,900** | **-80%** | +RTK intercepta comandos de shell y comprime su salida antes de que tu agente la lea. + +| Operacion | Que hace RTK con la salida | +|-----------|----------------------------| +| `ls` / `tree` | Formato de arbol con conteo de archivos en lugar de una linea por entrada | +| `cat` / `read` | Lectura inteligente: firmas y estructura en vez de cuerpos completos | +| `grep` / `rg` | Trunca lineas largas, agrupa coincidencias por archivo | +| `git status` | Formato stat compacto, agrupado por estado | +| `git diff` | Contexto reducido, cabeceras eliminadas | +| `git log` | Solo hash, autor y asunto | +| `git add/commit/push` | Linea de confirmacion en lugar de la salida de progreso completa | +| `cargo test` / `npm test` | Solo fallos, los tests que pasan se reducen a un contador | +| `ruff check` | Agrupado por regla y archivo | +| `pytest` | Solo fallos, traceback recortado | +| `go test` | NDJSON parseado, solo fallos | +| `docker ps` | Solo campos esenciales | + +## Como funciona el ahorro + +RTK elimina **hasta el 90% de la salida bash** que lee tu agente. Eso es lo que RTK mide, y no es lo mismo que reducir tu factura en un 90%. + +La salida bash es **uno de los factores que alimentan los tokens de entrada**, junto con tu prompt, el prompt del sistema y el historial de conversacion. Los tokens de entrada son a su vez **solo una parte de la factura**, que tambien cuenta los tokens de salida. La reduccion se diluye en cada paso. + +Los recuentos de tokens que reporta RTK se estiman como `bytes / 4`: RTK no incluye ningun tokenizador, por lo que los **porcentajes son fiables pero las cifras absolutas de tokens son aproximadas**. + +> Explicacion completa: [Como funciona el ahorro en RTK](docs/guide/resources/savings-explained.md) ## Instalacion @@ -103,6 +121,8 @@ Cuatro estrategias: ## Comandos +> Los porcentajes de abajo son **reducciones de bytes de salida bash**, medidas con el estimador `bytes / 4` de RTK. Ver [Como funciona el ahorro](#como-funciona-el-ahorro). + ### Archivos ```bash rtk ls . # Arbol de directorios optimizado diff --git a/README_fr.md b/README_fr.md index 2de284100f..4f665ff9b6 100644 --- a/README_fr.md +++ b/README_fr.md @@ -3,7 +3,7 @@

- Proxy CLI haute performance qui reduit la consommation de tokens LLM de 60-90% + Proxy CLI haute performance qui elimine jusqu'a 90% de la sortie bash lue par votre agent

@@ -35,21 +35,34 @@ rtk filtre et compresse les sorties de commandes avant qu'elles n'atteignent le contexte de votre LLM. Binaire Rust unique, zero dependance, <10ms d'overhead. -## Economies de tokens (session Claude Code de 30 min) +## Ce que fait RTK -| Operation | Frequence | Standard | rtk | Economies | -|-----------|-----------|----------|-----|-----------| -| `ls` / `tree` | 10x | 2 000 | 400 | -80% | -| `cat` / `read` | 20x | 40 000 | 12 000 | -70% | -| `grep` / `rg` | 8x | 16 000 | 3 200 | -80% | -| `git status` | 10x | 3 000 | 600 | -80% | -| `git diff` | 5x | 10 000 | 2 500 | -75% | -| `git log` | 5x | 2 500 | 500 | -80% | -| `git add/commit/push` | 8x | 1 600 | 120 | -92% | -| `cargo test` / `npm test` | 5x | 25 000 | 2 500 | -90% | -| **Total** | | **~118 000** | **~23 900** | **-80%** | +RTK intercepte les commandes shell et compresse leur sortie avant que votre agent ne la lise. -> Estimations basees sur des projets TypeScript/Rust de taille moyenne. +| Operation | Ce que RTK fait de la sortie | +|-----------|------------------------------| +| `ls` / `tree` | Format arborescent avec compteurs de fichiers au lieu d'une ligne par entree | +| `cat` / `read` | Lecture intelligente : signatures et structure plutot que corps complets | +| `grep` / `rg` | Tronque les lignes longues, regroupe les correspondances par fichier | +| `git status` | Format stat compact, regroupe par etat | +| `git diff` | Contexte reduit, en-tetes supprimes | +| `git log` | Hash, auteur et sujet uniquement | +| `git add/commit/push` | Ligne de confirmation au lieu de la sortie de progression complete | +| `cargo test` / `npm test` | Echecs uniquement, tests reussis reduits a un compteur | +| `ruff check` | Regroupe par regle et par fichier | +| `pytest` | Echecs uniquement, traceback raccourci | +| `go test` | NDJSON parse, echecs uniquement | +| `docker ps` | Champs essentiels uniquement | + +## Comment fonctionnent les economies + +RTK elimine **jusqu'a 90% de la sortie bash** que votre agent lit. C'est cela que RTK mesure, et ce n'est pas la meme chose que reduire votre facture de 90%. + +La sortie bash est **un contributeur parmi d'autres aux tokens d'entree**, aux cotes de votre prompt, du prompt systeme et de l'historique de conversation. Les tokens d'entree ne sont eux-memes **qu'une partie de la facture**, qui compte aussi les tokens de sortie. La reduction se dilue a chaque etape. + +Les nombres de tokens rapportes par RTK sont estimes avec `octets / 4` : RTK n'embarque aucun tokenizer, donc les **pourcentages sont fiables mais les valeurs absolues en tokens restent approximatives**. + +> Explication complete : [Comment fonctionnent les economies RTK](docs/guide/resources/savings-explained.md) ## Installation @@ -113,6 +126,8 @@ Quatre strategies appliquees par type de commande : ## Commandes +> Les pourcentages ci-dessous sont des **reductions d'octets de sortie bash**, mesurees avec l'estimateur `octets / 4` de RTK. Voir [Comment fonctionnent les economies](#comment-fonctionnent-les-economies). + ### Fichiers ```bash rtk ls . # Arbre de repertoires optimise diff --git a/README_ja.md b/README_ja.md index ce946d6288..fb9c78da84 100644 --- a/README_ja.md +++ b/README_ja.md @@ -3,7 +3,7 @@

- LLM ใƒˆใƒผใ‚ฏใƒณๆถˆ่ฒปใ‚’ 60-90% ๅ‰Šๆธ›ใ™ใ‚‹้ซ˜ๆ€ง่ƒฝ CLI ใƒ—ใƒญใ‚ญใ‚ท + ใ‚จใƒผใ‚ธใ‚งใƒณใƒˆใŒ่ชญใ‚€ bash ๅ‡บๅŠ›ใ‚’ๆœ€ๅคง 90% ๅ‰Šๆธ›ใ™ใ‚‹้ซ˜ๆ€ง่ƒฝ CLI ใƒ—ใƒญใ‚ญใ‚ท

@@ -35,16 +35,34 @@ rtk ใฏใ‚ณใƒžใƒณใƒ‰ๅ‡บๅŠ›ใ‚’ LLM ใ‚ณใƒณใƒ†ใ‚ญใ‚นใƒˆใซๅฑŠใๅ‰ใซใƒ•ใ‚ฃใƒซใ‚ฟใƒชใƒณใ‚ฐใƒปๅœง็ธฎใ—ใพใ™ใ€‚ๅ˜ไธ€ใฎ Rust ใƒใ‚คใƒŠใƒชใ€ไพๅญ˜้–ขไฟ‚ใ‚ผใƒญใ€ใ‚ชใƒผใƒใƒผใƒ˜ใƒƒใƒ‰ 10ms ๆœชๆบ€ใ€‚ -## ใƒˆใƒผใ‚ฏใƒณ็ฏ€็ด„๏ผˆ30ๅˆ†ใฎ Claude Code ใ‚ปใƒƒใ‚ทใƒงใƒณ๏ผ‰ +## RTK ใŒใ™ใ‚‹ใ“ใจ -| ๆ“ไฝœ | ้ ปๅบฆ | ๆจ™ๆบ– | rtk | ็ฏ€็ด„ | -|------|------|------|-----|------| -| `ls` / `tree` | 10x | 2,000 | 400 | -80% | -| `cat` / `read` | 20x | 40,000 | 12,000 | -70% | -| `grep` / `rg` | 8x | 16,000 | 3,200 | -80% | -| `git status` | 10x | 3,000 | 600 | -80% | -| `cargo test` / `npm test` | 5x | 25,000 | 2,500 | -90% | -| **ๅˆ่จˆ** | | **~118,000** | **~23,900** | **-80%** | +RTK ใฏใ‚ทใ‚งใƒซใ‚ณใƒžใƒณใƒ‰ใ‚’ๆจชๅ–ใ‚Šใ—ใ€ใ‚จใƒผใ‚ธใ‚งใƒณใƒˆใŒ่ชญใ‚€ๅ‰ใซใใฎๅ‡บๅŠ›ใ‚’ๅœง็ธฎใ—ใพใ™ใ€‚ + +| ๆ“ไฝœ | RTK ใŒๅ‡บๅŠ›ใซๅฏพใ—ใฆ่กŒใ†ใ“ใจ | +|------|----------------------------| +| `ls` / `tree` | 1 ใ‚จใƒณใƒˆใƒช 1 ่กŒใงใฏใชใใ€ใƒ•ใ‚กใ‚คใƒซๆ•ฐไป˜ใใฎใƒ„ใƒชใƒผๅฝขๅผ | +| `cat` / `read` | ใ‚นใƒžใƒผใƒˆใชใƒ•ใ‚กใ‚คใƒซ่ชญใฟๅ–ใ‚Š๏ผšๆœฌๆ–‡ๅ…จไฝ“ใงใฏใชใใ‚ทใ‚ฐใƒใƒใƒฃใจๆง‹้€  | +| `grep` / `rg` | ้•ทใ„่กŒใ‚’ๅˆ‡ใ‚Š่ฉฐใ‚ใ€ใƒžใƒƒใƒใ‚’ใƒ•ใ‚กใ‚คใƒซๅ˜ไฝใงใ‚ฐใƒซใƒผใƒ—ๅŒ– | +| `git status` | ใ‚ณใƒณใƒ‘ใ‚ฏใƒˆใช stat ๅฝขๅผใ€็Šถๆ…‹ใ”ใจใซใ‚ฐใƒซใƒผใƒ—ๅŒ– | +| `git diff` | ใ‚ณใƒณใƒ†ใ‚ญใ‚นใƒˆใ‚’ๅ‰Šๆธ›ใ€ใƒ˜ใƒƒใƒ€ใƒผใ‚’้™คๅŽป | +| `git log` | ใƒใƒƒใ‚ทใƒฅใ€ไฝœ่€…ใ€ไปถๅใฎใฟ | +| `git add/commit/push` | ้€ฒๆ—ๅ‡บๅŠ›ๅ…จไฝ“ใฎไปฃใ‚ใ‚Šใซ็ขบ่ช่กŒ 1 ่กŒ | +| `cargo test` / `npm test` | ๅคฑๆ•—ใฎใฟใ€ๆˆๅŠŸใ—ใŸใƒ†ใ‚นใƒˆใฏไปถๆ•ฐใซ้›†็ด„ | +| `ruff check` | ใƒซใƒผใƒซใจใƒ•ใ‚กใ‚คใƒซใ”ใจใซใ‚ฐใƒซใƒผใƒ—ๅŒ– | +| `pytest` | ๅคฑๆ•—ใฎใฟใ€ใƒˆใƒฌใƒผใ‚นใƒใƒƒใ‚ฏใ‚’็Ÿญ็ธฎ | +| `go test` | NDJSON ใ‚’ใƒ‘ใƒผใ‚นใ—ใ€ๅคฑๆ•—ใฎใฟ | +| `docker ps` | ๅฟ…้ ˆใƒ•ใ‚ฃใƒผใƒซใƒ‰ใฎใฟ | + +## ็ฏ€็ด„ใฎไป•็ต„ใฟ + +RTK ใฏใ‚จใƒผใ‚ธใ‚งใƒณใƒˆใŒ่ชญใ‚€ **bash ๅ‡บๅŠ›ใ‚’ๆœ€ๅคง 90% ๅ‰Šๆธ›**ใ—ใพใ™ใ€‚ใ“ใ‚ŒใŒ RTK ใฎๆธฌๅฎšๅฏพ่ฑกใงใ‚ใ‚Šใ€่ซ‹ๆฑ‚้กใŒ 90% ๆธ›ใ‚‹ใ“ใจใจๅŒใ˜ใงใฏใ‚ใ‚Šใพใ›ใ‚“ใ€‚ + +bash ๅ‡บๅŠ›ใฏใ€ใ‚ใชใŸใฎใƒ—ใƒญใƒณใƒ—ใƒˆใ€ใ‚ทใ‚นใƒ†ใƒ ใƒ—ใƒญใƒณใƒ—ใƒˆใ€ไผš่ฉฑๅฑฅๆญดใจไธฆใถ**ๅ…ฅๅŠ›ใƒˆใƒผใ‚ฏใƒณใฎๆง‹ๆˆ่ฆ็ด ใฎใฒใจใค**ใซใ™ใŽใพใ›ใ‚“ใ€‚ใใ—ใฆๅ…ฅๅŠ›ใƒˆใƒผใ‚ฏใƒณ่‡ชไฝ“ใ‚‚ใ€ๅ‡บๅŠ›ใƒˆใƒผใ‚ฏใƒณใ‚’ๅซใ‚€**่ซ‹ๆฑ‚้กใฎไธ€้ƒจ**ใงใ—ใ‹ใ‚ใ‚Šใพใ›ใ‚“ใ€‚ๅ‰Šๆธ›ๅŠนๆžœใฏๅ„ๆฎต้šŽใง่–„ใพใ‚Šใพใ™ใ€‚ + +RTK ใŒๅ ฑๅ‘Šใ™ใ‚‹ใƒˆใƒผใ‚ฏใƒณๆ•ฐใฏ `ใƒใ‚คใƒˆๆ•ฐ / 4` ใง่ฆ‹็ฉใ‚‚ใ‚‰ใ‚Œใฆใ„ใพใ™ใ€‚RTK ใฏใƒˆใƒผใ‚ฏใƒŠใ‚คใ‚ถใƒผใ‚’ๅŒๆขฑใ—ใฆใ„ใชใ„ใŸใ‚ใ€**ๅ‰ฒๅˆใฏไฟก้ ผใงใใพใ™ใŒใ€ใƒˆใƒผใ‚ฏใƒณใฎ็ตถๅฏพๅ€คใฏใ‚ใใพใงๆฆ‚็ฎ—**ใงใ™ใ€‚ + +> ่ฉณใ—ใ„่งฃ่ชฌ๏ผš[RTK ใฎ็ฏ€็ด„ใฎไป•็ต„ใฟ](docs/guide/resources/savings-explained.md) ## ใ‚คใƒณใ‚นใƒˆใƒผใƒซ @@ -103,6 +121,8 @@ git status # ่‡ชๅ‹•็š„ใซ rtk git status ใซๆ›ธใๆ›ใˆ ## ใ‚ณใƒžใƒณใƒ‰ +> ไปฅไธ‹ใฎๅ‰ฒๅˆใฏ **bash ๅ‡บๅŠ›ใƒใ‚คใƒˆๆ•ฐใฎๅ‰Šๆธ›็އ**ใงใ‚ใ‚Šใ€RTK ใฎ `ใƒใ‚คใƒˆๆ•ฐ / 4` ๆŽจๅฎšๅ™จใงๆธฌๅฎšใ—ใฆใ„ใพใ™ใ€‚[็ฏ€็ด„ใฎไป•็ต„ใฟ](#็ฏ€็ด„ใฎไป•็ต„ใฟ)ใ‚’ๅ‚็…งใ—ใฆใใ ใ•ใ„ใ€‚ + ### ใƒ•ใ‚กใ‚คใƒซ ```bash rtk ls . # ๆœ€้ฉๅŒ–ใ•ใ‚ŒใŸใƒ‡ใ‚ฃใƒฌใ‚ฏใƒˆใƒชใƒ„ใƒชใƒผ diff --git a/README_ko.md b/README_ko.md index c969e0ed24..94fafe04dd 100644 --- a/README_ko.md +++ b/README_ko.md @@ -3,7 +3,7 @@

- LLM ํ† ํฐ ์†Œ๋น„๋ฅผ 60-90% ์ค„์ด๋Š” ๊ณ ์„ฑ๋Šฅ CLI ํ”„๋ก์‹œ + ์—์ด์ „ํŠธ๊ฐ€ ์ฝ๋Š” bash ์ถœ๋ ฅ์„ ์ตœ๋Œ€ 90% ์ค„์ด๋Š” ๊ณ ์„ฑ๋Šฅ CLI ํ”„๋ก์‹œ

@@ -35,16 +35,34 @@ rtk๋Š” ๋ช…๋ น ์ถœ๋ ฅ์ด LLM ์ปจํ…์ŠคํŠธ์— ๋„๋‹ฌํ•˜๊ธฐ ์ „์— ํ•„ํ„ฐ๋งํ•˜๊ณ  ์••์ถ•ํ•ฉ๋‹ˆ๋‹ค. ๋‹จ์ผ Rust ๋ฐ”์ด๋„ˆ๋ฆฌ, ์˜์กด์„ฑ ์—†์Œ, 10ms ๋ฏธ๋งŒ์˜ ์˜ค๋ฒ„ํ—ค๋“œ. -## ํ† ํฐ ์ ˆ์•ฝ (30๋ถ„ Claude Code ์„ธ์…˜) +## RTK๊ฐ€ ํ•˜๋Š” ์ผ -| ์ž‘์—… | ๋นˆ๋„ | ํ‘œ์ค€ | rtk | ์ ˆ์•ฝ | -|------|------|------|-----|------| -| `ls` / `tree` | 10x | 2,000 | 400 | -80% | -| `cat` / `read` | 20x | 40,000 | 12,000 | -70% | -| `grep` / `rg` | 8x | 16,000 | 3,200 | -80% | -| `git status` | 10x | 3,000 | 600 | -80% | -| `cargo test` / `npm test` | 5x | 25,000 | 2,500 | -90% | -| **ํ•ฉ๊ณ„** | | **~118,000** | **~23,900** | **-80%** | +RTK๋Š” ์…ธ ๋ช…๋ น์„ ๊ฐ€๋กœ์ฑ„ ์—์ด์ „ํŠธ๊ฐ€ ์ฝ๊ธฐ ์ „์— ์ถœ๋ ฅ์„ ์••์ถ•ํ•ฉ๋‹ˆ๋‹ค. + +| ์ž‘์—… | RTK๊ฐ€ ์ถœ๋ ฅ์— ํ•˜๋Š” ์ผ | +|------|----------------------| +| `ls` / `tree` | ํ•ญ๋ชฉ๋‹น ํ•œ ์ค„ ๋Œ€์‹  ํŒŒ์ผ ๊ฐœ์ˆ˜๊ฐ€ ํฌํ•จ๋œ ํŠธ๋ฆฌ ํ˜•์‹ | +| `cat` / `read` | ์Šค๋งˆํŠธ ํŒŒ์ผ ์ฝ๊ธฐ: ์ „์ฒด ๋ณธ๋ฌธ ๋Œ€์‹  ์‹œ๊ทธ๋‹ˆ์ฒ˜์™€ ๊ตฌ์กฐ | +| `grep` / `rg` | ๊ธด ์ค„์„ ์ž˜๋ผ๋‚ด๊ณ  ๋งค์น˜๋ฅผ ํŒŒ์ผ๋ณ„๋กœ ๊ทธ๋ฃนํ™” | +| `git status` | ์ปดํŒฉํŠธํ•œ stat ํ˜•์‹, ์ƒํƒœ๋ณ„ ๊ทธ๋ฃนํ™” | +| `git diff` | ์ปจํ…์ŠคํŠธ ์ถ•์†Œ, ํ—ค๋” ์ œ๊ฑฐ | +| `git log` | ํ•ด์‹œ, ์ž‘์„ฑ์ž, ์ œ๋ชฉ๋งŒ | +| `git add/commit/push` | ์ „์ฒด ์ง„ํ–‰ ์ถœ๋ ฅ ๋Œ€์‹  ํ™•์ธ ํ•œ ์ค„ | +| `cargo test` / `npm test` | ์‹คํŒจ๋งŒ ํ‘œ์‹œ, ํ†ต๊ณผํ•œ ํ…Œ์ŠคํŠธ๋Š” ๊ฐœ์ˆ˜๋กœ ์ถ•์•ฝ | +| `ruff check` | ๊ทœ์น™๊ณผ ํŒŒ์ผ๋ณ„๋กœ ๊ทธ๋ฃนํ™” | +| `pytest` | ์‹คํŒจ๋งŒ ํ‘œ์‹œ, ํŠธ๋ ˆ์ด์Šค๋ฐฑ ์ถ•์•ฝ | +| `go test` | NDJSON ํŒŒ์‹ฑ, ์‹คํŒจ๋งŒ ํ‘œ์‹œ | +| `docker ps` | ํ•ต์‹ฌ ํ•„๋“œ๋งŒ | + +## ์ ˆ์•ฝ์ด ๊ณ„์‚ฐ๋˜๋Š” ๋ฐฉ์‹ + +RTK๋Š” ์—์ด์ „ํŠธ๊ฐ€ ์ฝ๋Š” **bash ์ถœ๋ ฅ์„ ์ตœ๋Œ€ 90%** ์ค„์ž…๋‹ˆ๋‹ค. ์ด๊ฒƒ์ด RTK๊ฐ€ ์ธก์ •ํ•˜๋Š” ๊ฐ’์ด๋ฉฐ, ์š”๊ธˆ์ด 90% ์ค„์–ด๋“œ๋Š” ๊ฒƒ๊ณผ๋Š” ๋‹ค๋ฆ…๋‹ˆ๋‹ค. + +bash ์ถœ๋ ฅ์€ ํ”„๋กฌํ”„ํŠธ, ์‹œ์Šคํ…œ ํ”„๋กฌํ”„ํŠธ, ๋Œ€ํ™” ๊ธฐ๋ก๊ณผ ํ•จ๊ป˜ **์ž…๋ ฅ ํ† ํฐ์„ ๊ตฌ์„ฑํ•˜๋Š” ์š”์†Œ ์ค‘ ํ•˜๋‚˜**์ž…๋‹ˆ๋‹ค. ๊ทธ๋ฆฌ๊ณ  ์ž…๋ ฅ ํ† ํฐ ์—ญ์‹œ ์ถœ๋ ฅ ํ† ํฐ๊นŒ์ง€ ํฌํ•จํ•˜๋Š” **์š”๊ธˆ์˜ ์ผ๋ถ€์ผ ๋ฟ**์ž…๋‹ˆ๋‹ค. ๊ฐ์†Œ ํšจ๊ณผ๋Š” ๊ฐ ๋‹จ๊ณ„์—์„œ ํฌ์„๋ฉ๋‹ˆ๋‹ค. + +RTK๊ฐ€ ๋ณด๊ณ ํ•˜๋Š” ํ† ํฐ ์ˆ˜๋Š” `๋ฐ”์ดํŠธ / 4`๋กœ ์ถ”์ •๋ฉ๋‹ˆ๋‹ค. RTK์—๋Š” ํ† ํฌ๋‚˜์ด์ €๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ์ง€ ์•Š์œผ๋ฏ€๋กœ **๋น„์œจ์€ ์‹ ๋ขฐํ•  ์ˆ˜ ์žˆ์ง€๋งŒ ํ† ํฐ ์ ˆ๋Œ€๊ฐ’์€ ๊ทผ์‚ฌ์น˜**์ž…๋‹ˆ๋‹ค. + +> ์ „์ฒด ์„ค๋ช…: [RTK์˜ ์ ˆ์•ฝ์ด ๊ณ„์‚ฐ๋˜๋Š” ๋ฐฉ์‹](docs/guide/resources/savings-explained.md) ## ์„ค์น˜ @@ -103,6 +121,8 @@ git status # ์ž๋™์œผ๋กœ rtk git status๋กœ ์žฌ์ž‘์„ฑ ## ๋ช…๋ น์–ด +> ์•„๋ž˜ ๋ฐฑ๋ถ„์œจ์€ RTK์˜ `๋ฐ”์ดํŠธ / 4` ์ถ”์ •๊ธฐ๋กœ ์ธก์ •ํ•œ **bash ์ถœ๋ ฅ ๋ฐ”์ดํŠธ ๊ฐ์†Œ์œจ**์ž…๋‹ˆ๋‹ค. [์ ˆ์•ฝ์ด ๊ณ„์‚ฐ๋˜๋Š” ๋ฐฉ์‹](#์ ˆ์•ฝ์ด-๊ณ„์‚ฐ๋˜๋Š”-๋ฐฉ์‹)์„ ์ฐธ์กฐํ•˜์„ธ์š”. + ### ํŒŒ์ผ ```bash rtk ls . # ์ตœ์ ํ™”๋œ ๋””๋ ‰ํ† ๋ฆฌ ํŠธ๋ฆฌ diff --git a/README_pt.md b/README_pt.md index 767cd49dc0..b749c8e5f0 100644 --- a/README_pt.md +++ b/README_pt.md @@ -3,7 +3,7 @@

- Proxy CLI de alta performance que reduz o consumo de tokens LLM em 60-90% + Proxy CLI de alta performance que corta atรฉ 90% da saรญda bash que seu agente lรช

@@ -36,16 +36,34 @@ rtk filtra e comprime saรญdas de comandos antes de chegarem ao contexto do seu LLM. Binรกrio Rust รบnico, zero dependรชncias, overhead inferior a 10ms. -## Economia de tokens (sessรฃo de 30 min no Claude Code) +## O que o RTK faz -| Operaรงรฃo | Frequรชncia | Padrรฃo | rtk | Economia | -|-----------|------------|----------|-----|--------| -| `ls` / `tree` | 10x | 2,000 | 400 | -80% | -| `cat` / `read` | 20x | 40,000 | 12,000 | -70% | -| `grep` / `rg` | 8x | 16,000 | 3,200 | -80% | -| `git status` | 10x | 3,000 | 600 | -80% | -| `cargo test` / `npm test` | 5x | 25,000 | 2,500 | -90% | -| **Total** | | **~118,000** | **~23,900** | **-80%** | +O RTK intercepta comandos de shell e comprime a saรญda antes que seu agente a leia. + +| Operaรงรฃo | O que o RTK faz com a saรญda | +|-----------|-----------------------------| +| `ls` / `tree` | Formato de รกrvore com contagem de arquivos em vez de uma linha por entrada | +| `cat` / `read` | Leitura inteligente: assinaturas e estrutura em vez de corpos completos | +| `grep` / `rg` | Trunca linhas longas, agrupa correspondรชncias por arquivo | +| `git status` | Formato stat compacto, agrupado por estado | +| `git diff` | Contexto reduzido, cabeรงalhos removidos | +| `git log` | Apenas hash, autor e assunto | +| `git add/commit/push` | Linha de confirmaรงรฃo em vez da saรญda de progresso completa | +| `cargo test` / `npm test` | Apenas falhas, testes aprovados reduzidos a um contador | +| `ruff check` | Agrupado por regra e arquivo | +| `pytest` | Apenas falhas, traceback encurtado | +| `go test` | NDJSON parseado, apenas falhas | +| `docker ps` | Apenas campos essenciais | + +## Como funciona a economia + +O RTK corta **atรฉ 90% da saรญda bash** que seu agente lรช. ร‰ isso que o RTK mede, e nรฃo รฉ a mesma coisa que reduzir sua fatura em 90%. + +A saรญda bash รฉ **um dos contribuintes para os tokens de entrada**, ao lado do seu prompt, do prompt de sistema e do histรณrico da conversa. Os tokens de entrada sรฃo, por sua vez, **apenas parte da fatura**, que tambรฉm conta os tokens de saรญda. A reduรงรฃo se dilui a cada etapa. + +As contagens de tokens que o RTK reporta sรฃo estimadas como `bytes / 4`: o RTK nรฃo embarca nenhum tokenizador, portanto os **percentuais sรฃo confiรกveis, mas os nรบmeros absolutos de tokens sรฃo aproximados**. + +> Explicaรงรฃo completa: [Como funciona a economia do RTK](docs/guide/resources/savings-explained.md) ## Instalacao @@ -104,6 +122,8 @@ Quatro estratรฉgias: ## Comandos +> Os percentuais abaixo sรฃo **reduรงรตes de bytes da saรญda bash**, medidas com o estimador `bytes / 4` do RTK. Veja [Como funciona a economia](#como-funciona-a-economia). + ### Arquivos ```bash rtk ls . # รrvore de diretรณrios otimizada diff --git a/README_zh.md b/README_zh.md index ef8c53b108..53556399a3 100644 --- a/README_zh.md +++ b/README_zh.md @@ -3,7 +3,7 @@

- ้ซ˜ๆ€ง่ƒฝ CLI ไปฃ็†๏ผŒๅฐ† LLM token ๆถˆ่€—้™ไฝŽ 60-90% + ้ซ˜ๆ€ง่ƒฝ CLI ไปฃ็†๏ผŒไธบไฝ ็š„ๆ™บ่ƒฝไฝ“ๅ‰Šๅ‡ๅคš่พพ 90% ็š„ bash ่พ“ๅ‡บ

@@ -35,17 +35,34 @@ rtk ๅœจๅ‘ฝไปค่พ“ๅ‡บๅˆฐ่พพ LLM ไธŠไธ‹ๆ–‡ไน‹ๅ‰่ฟ›่กŒ่ฟ‡ๆปคๅ’ŒๅŽ‹็ผฉใ€‚ๅ•ไธ€ Rust ไบŒ่ฟ›ๅˆถๆ–‡ไปถ๏ผŒ้›ถไพ่ต–๏ผŒ<10ms ๅผ€้”€ใ€‚ -## Token ่Š‚็œ๏ผˆ30 ๅˆ†้’Ÿ Claude Code ไผš่ฏ๏ผ‰ +## RTK ๅšไป€ไนˆ -| ๆ“ไฝœ | ้ข‘็އ | ๆ ‡ๅ‡† | rtk | ่Š‚็œ | -|------|------|------|-----|------| -| `ls` / `tree` | 10x | 2,000 | 400 | -80% | -| `cat` / `read` | 20x | 40,000 | 12,000 | -70% | -| `grep` / `rg` | 8x | 16,000 | 3,200 | -80% | -| `git status` | 10x | 3,000 | 600 | -80% | -| `git diff` | 5x | 10,000 | 2,500 | -75% | -| `cargo test` / `npm test` | 5x | 25,000 | 2,500 | -90% | -| **ๆ€ป่ฎก** | | **~118,000** | **~23,900** | **-80%** | +RTK ๆ‹ฆๆˆช shell ๅ‘ฝไปค๏ผŒๅœจไฝ ็š„ๆ™บ่ƒฝไฝ“่ฏปๅ–ไน‹ๅ‰ๅŽ‹็ผฉๅ…ถ่พ“ๅ‡บใ€‚ + +| ๆ“ไฝœ | RTK ๅฏน่พ“ๅ‡บๅšไบ†ไป€ไนˆ | +|------|--------------------| +| `ls` / `tree` | ็”จๅธฆๆ–‡ไปถ่ฎกๆ•ฐ็š„ๆ ‘ๅฝขๆ ผๅผไปฃๆ›ฟๆฏไธชๆก็›ฎไธ€่กŒ | +| `cat` / `read` | ๆ™บ่ƒฝๆ–‡ไปถ่ฏปๅ–๏ผšไฟ็•™็ญพๅๅ’Œ็ป“ๆž„๏ผŒ่€Œ้žๅฎŒๆ•ดๅ‡ฝๆ•ฐไฝ“ | +| `grep` / `rg` | ๆˆชๆ–ญ่ถ…้•ฟ่กŒ๏ผŒๆŒ‰ๆ–‡ไปถๅˆ†็ป„ๅŒน้…็ป“ๆžœ | +| `git status` | ็ดงๅ‡‘็š„ stat ๆ ผๅผ๏ผŒๆŒ‰็Šถๆ€ๅˆ†็ป„ | +| `git diff` | ๅ‡ๅฐ‘ไธŠไธ‹ๆ–‡๏ผŒๅŽปๆމๅคด้ƒจไฟกๆฏ | +| `git log` | ไป…ไฟ็•™ๅ“ˆๅธŒใ€ไฝœ่€…ๅ’Œๆ ‡้ข˜ | +| `git add/commit/push` | ็”จไธ€่กŒ็กฎ่ฎคไปฃๆ›ฟๅฎŒๆ•ด็š„่ฟ›ๅบฆ่พ“ๅ‡บ | +| `cargo test` / `npm test` | ไป…ๆ˜พ็คบๅคฑ่ดฅ๏ผŒ้€š่ฟ‡็š„ๆต‹่ฏ•ๆŠ˜ๅ ไธบ่ฎกๆ•ฐ | +| `ruff check` | ๆŒ‰่ง„ๅˆ™ๅ’Œๆ–‡ไปถๅˆ†็ป„ | +| `pytest` | ไป…ๆ˜พ็คบๅคฑ่ดฅ๏ผŒ็ฒพ็ฎ€ traceback | +| `go test` | ่งฃๆž NDJSON๏ผŒไป…ๆ˜พ็คบๅคฑ่ดฅ | +| `docker ps` | ไป…ไฟ็•™ๅ…ณ้”ฎๅญ—ๆฎต | + +## ่Š‚็œๆ˜ฏๅฆ‚ไฝ•่ฎก็ฎ—็š„ + +RTK ไธบไฝ ็š„ๆ™บ่ƒฝไฝ“ๅ‰Šๅ‡**ๅคš่พพ 90% ็š„ bash ่พ“ๅ‡บ**ใ€‚่ฟ™ๆญฃๆ˜ฏ RTK ๆ‰€ๆต‹้‡็š„ๆŒ‡ๆ ‡๏ผŒๅฎƒไธŽใ€Œ่ดฆๅ•้™ไฝŽ 90%ใ€ไธๆ˜ฏไธ€ๅ›žไบ‹ใ€‚ + +bash ่พ“ๅ‡บๅชๆ˜ฏ**่พ“ๅ…ฅ token ็š„ๆฅๆบไน‹ไธ€**๏ผŒๆญคๅค–่ฟ˜ๆœ‰ไฝ ็š„ๆ็คบ่ฏใ€็ณป็ปŸๆ็คบ่ฏๅ’Œๅฏน่ฏๅކๅฒใ€‚่€Œ่พ“ๅ…ฅ token ๆœฌ่บซไนŸ**ๅชๆ˜ฏ่ดฆๅ•็š„ไธ€้ƒจๅˆ†**๏ผŒ่ดฆๅ•่ฟ˜ๅŒ…ๅซ่พ“ๅ‡บ tokenใ€‚ๅ‰Šๅ‡ๆ•ˆๆžœๅœจๆฏไธ€ๆญฅ้ƒฝไผš่ขซ็จ€้‡Šใ€‚ + +RTK ๆŠฅๅ‘Š็š„ token ๆ•ฐ้‡ๆŒ‰ `ๅญ—่Š‚ๆ•ฐ / 4` ไผฐ็ฎ—๏ผšRTK ไธๅ†…็ฝฎๅˆ†่ฏๅ™จ๏ผŒๅ› ๆญค**็™พๅˆ†ๆฏ”ๆ˜ฏๅฏ้ ็š„๏ผŒไฝ† token ็ปๅฏนๆ•ฐๅ€ผๅชๆ˜ฏ่ฟ‘ไผผๅ€ผ**ใ€‚ + +> ๅฎŒๆ•ด่ฏดๆ˜Ž๏ผš[RTK ็š„่Š‚็œๆ˜ฏๅฆ‚ไฝ•่ฎก็ฎ—็š„](docs/guide/resources/savings-explained.md) ## ๅฎ‰่ฃ… @@ -104,6 +121,8 @@ git status # ่‡ชๅŠจ้‡ๅ†™ไธบ rtk git status ## ๅ‘ฝไปค +> ไธ‹ๅˆ—็™พๅˆ†ๆฏ”ๆ˜ฏ **bash ่พ“ๅ‡บๅญ—่Š‚ๆ•ฐ็š„ๅ‰Šๅ‡ๆฏ”ไพ‹**๏ผŒ็”ฑ RTK ็š„ `ๅญ—่Š‚ๆ•ฐ / 4` ไผฐ็ฎ—ๅ™จๆต‹ๅพ—ใ€‚ๅ‚่ง[่Š‚็œๆ˜ฏๅฆ‚ไฝ•่ฎก็ฎ—็š„](#่Š‚็œๆ˜ฏๅฆ‚ไฝ•่ฎก็ฎ—็š„)ใ€‚ + ### ๆ–‡ไปถ ```bash rtk ls . # ไผ˜ๅŒ–็š„็›ฎๅฝ•ๆ ‘ diff --git a/docs/TELEMETRY.md b/docs/TELEMETRY.md index 1bded71631..a324ae2c24 100644 --- a/docs/TELEMETRY.md +++ b/docs/TELEMETRY.md @@ -83,7 +83,7 @@ This data directly drives our roadmap. For example, if telemetry shows that 40% | Field | Example | Purpose | |-------|---------|---------| | `tokens_saved_30d` | `12000000` | 30-day token savings for trend analysis | -| `estimated_savings_usd_30d` | `36.0` | Estimated dollar value saved (at ~$3/Mtok input pricing, Claude Sonnet) | +| `estimated_savings_usd_30d` | โ€” | A USD value derived from the estimated tokens saved and a fixed internal constant. It is not a measured cost and does not reflect any provider's pricing | ### Adoption diff --git a/docs/contributing/CODING_PRACTICES.md b/docs/contributing/CODING_PRACTICES.md index bc0975541a..893f54465b 100644 --- a/docs/contributing/CODING_PRACTICES.md +++ b/docs/contributing/CODING_PRACTICES.md @@ -138,7 +138,7 @@ For the full error-handling architecture (propagation chain, exit code preservat See [`CONTRIBUTING.md` โ€” Testing](../../CONTRIBUTING.md#testing) for the full strategy. In short, for a new filter you typically want: - **Unit + snapshot tests** in the same file, using the `insta` crate. -- **A token-savings assertion** verifying the filter hits the โ‰ฅ60% target on a real fixture. +- **A savings assertion** verifying the filter hits the โ‰ฅ20% target on a real fixture. The target is a reduction in bash output, measured with RTK's token estimator rather than a real tokenizer. Minimal example: @@ -162,7 +162,7 @@ mod tests { let input = include_str!("../../../tests/fixtures/git_log_raw.txt"); let output = filter_git_log(input); let savings = 100.0 - (count_tokens(&output) as f64 / count_tokens(input) as f64 * 100.0); - assert!(savings >= 60.0, "expected โ‰ฅ60% savings, got {:.1}%", savings); + assert!(savings >= 20.0, "expected โ‰ฅ20% output reduction, got {:.1}%", savings); } } ``` diff --git a/docs/contributing/TECHNICAL.md b/docs/contributing/TECHNICAL.md index ddadf8d73c..d9c8f993c0 100644 --- a/docs/contributing/TECHNICAL.md +++ b/docs/contributing/TECHNICAL.md @@ -12,7 +12,9 @@ LLM-powered coding agents (Claude Code, Copilot, Cursor, etc.) consume tokens for every CLI command output they process. Most command outputs contain boilerplate, progress bars, ANSI escape codes, and verbose formatting that wastes tokens without providing actionable information. -RTK sits between the agent and the CLI, filtering outputs to keep only what matters. This achieves 60-90% token savings per command, reducing costs and increasing effective context window utilization. RTK is a single Rust binary with no runtime dependencies beyond the compiled binary itself, adding less than 10ms overhead per command. +RTK sits between the agent and the CLI, filtering outputs to keep only what matters. This cuts 60-90% of the bash output per command, reducing costs and increasing effective context window utilization. RTK is a single Rust binary with no runtime dependencies beyond the compiled binary itself, adding less than 10ms overhead per command. + +Every percentage below measures **bash output**, which is one contributor to input tokens, themselves only part of a bill that also counts output tokens. RTK ships no tokenizer (`src/core/tracking.rs` estimates `bytes / 4`), so the ratios are reliable but the absolute token counts are approximate. --- @@ -141,8 +143,9 @@ rewrite_compound(cmd, excluded) [src/discover/registry.rs] | | Step 2 โ€” Split on operators, rewrite each segment | Operator (&&, ||, ;) โ†’ rewrite both sides - | Pipe (|) โ†’ rewrite left side only, keep right side raw - | exception: find/fd before pipe โ†’ skip rewrite + | Pipe (|) โ†’ keep producers/intermediate stages raw + | rewrite only a pipeline-safe final stage + | Stderr pipe (|&) โ†’ keep the complete pipeline raw | Shellism (&) โ†’ rewrite both sides (background) | | Calls rewrite_segment() per segment: @@ -205,7 +208,7 @@ LLM Agent executes rewritten command Key design decisions: - **Lexer-based tokenization**: A single-pass state machine (`lexer.rs`) handles all shell constructs (quotes, escapes, redirects, operators). Used for both compound splitting and redirect stripping. - **Segment-level rewriting**: Compound commands are split by operators, each segment rewritten independently. Bash recombines them at execution time. -- **Pipe semantics**: Only the left side of `|` is rewritten. The pipe consumer (grep, head, wc) runs raw. `find`/`fd` before a pipe is never rewritten (output format incompatible with xargs). +- **Pipe semantics**: Producers and intermediate stages of `|` remain raw. Only an argument-safe final stage whose rule has `pipeline_final_safe` may be rewritten; initially this is limited to ordinary `grep` and `rg` invocations. Search pattern-file forms (`-f`/`--file`) defer because they can consume pipeline stdin as configuration. `|&` is recognized separately and its complete pipeline stays raw. - **Double env prefix handling**: `classify_command()` strips env prefixes to match the underlying command against rules. `rewrite_segment()` extracts the same prefix separately to re-prepend it to the rewritten command. - **Fallback contract**: If any segment fails to match, it stays raw. `rewrite_command()` returns `None` only when zero segments were rewritten. @@ -246,7 +249,7 @@ When Clap parsing fails (unknown command): 1. Guard: check if the command is an RTK meta-command (`gain`, `init`, etc.) -- if so, show Clap error 2. Look up TOML DSL filters via `toml_filter::find_matching_filter()` 3. If TOML match: capture stdout, apply filter pipeline, track savings -4. If no match: pure passthrough with `Stdio::inherit`, track as 0% savings +4. If no match: pure passthrough with `Stdio::inherit`, track as 0% output reduction ``` Command received @@ -255,7 +258,7 @@ Command received -> No: run_fallback() -> TOML filter match? -> Yes: Capture stdout, apply filter, track savings - -> No: Passthrough (inherit stdio, track 0% savings) + -> No: Passthrough (inherit stdio, track 0% reduction) ``` > **Details**: [`src/core/README.md`](../src/core/README.md) covers the TOML filter engine, filter pipeline stages, and trust-gated project filters. @@ -329,7 +332,7 @@ RTK supports the following LLM agents through hook integrations: | Claude Code | Shell hook | `PreToolUse` in `settings.json` | Yes (`updatedInput`) | | GitHub Copilot (VS Code) | Rust binary | `rtk hook copilot` reads JSON | Yes (`updatedInput`) | | GitHub Copilot CLI | Rust binary | `rtk hook copilot` reads JSON | No (deny + suggestion) | -| Cursor | Shell hook | `preToolUse` hook | Yes (`updated_input`) | +| Cursor | Rust binary | `rtk hook cursor` reads JSON | Yes (`updated_input`) | | Gemini CLI | Rust binary | `rtk hook gemini` reads JSON | Yes (`hookSpecificOutput`) | | Cline/Roo Code | Rules file | Prompt-level guidance | N/A (prompt) | | Windsurf | Rules file | Prompt-level guidance | N/A (prompt) | @@ -344,7 +347,7 @@ RTK supports the following LLM agents through hook integrations: ### Rust Filters (cmds/**) -Compiled filter modules for complex transformations with 60-95% token savings. +Compiled filter modules for complex transformations, cutting 60-95% of the bash output. > **Details**: [`src/cmds/README.md`](../src/cmds/README.md) and each ecosystem subdirectory README. @@ -363,7 +366,7 @@ Declarative filters with an 8-stage pipeline: strip ANSI, regex replace, match o | Startup time | < 10ms | `hyperfine 'rtk git status' 'git status'` | | Memory usage | < 5MB resident | `/usr/bin/time -v rtk git status` | | Binary size | < 5MB stripped | `ls -lh target/release/rtk` | -| Token savings | 60-90% per filter | Snapshot + token count tests | +| Bash output reduction | โ‰ฅ20% per filter (floor) | Snapshot + token count tests | Achieved through: - Zero async overhead (single-threaded, no tokio) @@ -395,14 +398,14 @@ fn test_my_filter() { } ``` -**3. Verify token savings** (60% minimum required): +**3. Verify the output reduction** (>=20% of the bash output required; `count_tokens` in tests and the `bytes / 4` estimator behind `rtk gain` are both approximations, reliable as ratios): ```rust #[test] fn test_my_filter_savings() { let input = include_str!("../tests/fixtures/my_cmd_raw.txt"); let output = filter_my_cmd(input); let savings = 100.0 - (count_tokens(&output) as f64 / count_tokens(input) as f64 * 100.0); - assert!(savings >= 60.0, "Expected >=60% savings, got {:.1}%", savings); + assert!(savings >= 20.0, "Expected >=20% savings, got {:.1}%", savings); } ``` diff --git a/docs/guide/analytics/discover.md b/docs/guide/analytics/discover.md index 575ca73b2d..05814525e0 100644 --- a/docs/guide/analytics/discover.md +++ b/docs/guide/analytics/discover.md @@ -9,7 +9,7 @@ sidebar: ## rtk discover โ€” find missed savings -`rtk discover` analyzes your Claude Code command history to identify commands that ran without RTK filtering and calculates how many tokens you lost. +`rtk discover` analyzes your Claude Code command history to identify commands that ran without RTK filtering, and estimates how much bash output RTK would have removed from them. ```bash rtk discover # analyze current project history @@ -17,7 +17,7 @@ rtk discover --all # all projects rtk discover --all --since 7 # last 7 days, all projects ``` -**Example output:** +**Example output** (sample numbers, not typical results): ``` Missed savings analysis (last 7 days) @@ -32,6 +32,8 @@ Total missed: 23 ~66,000 tokens Run `rtk init --global` to capture these automatically. ``` +The `~N tokens` figures are **estimated bash output bytes divided by 4**, not tokens billed by your provider. RTK ships no real tokenizer, and bash output is only one contributor to input tokens. Read them as an order of magnitude of the output volume RTK could have compressed. See [How RTK Savings Work](../resources/savings-explained.md). + If commands appear in the missed list after installing RTK, it usually means the hook isn't active for that agent. See [Troubleshooting](../resources/troubleshooting.md) โ€” "Agent not using RTK". ## rtk session โ€” adoption tracking diff --git a/docs/guide/analytics/gain.md b/docs/guide/analytics/gain.md index 706508fcea..46305dbc48 100644 --- a/docs/guide/analytics/gain.md +++ b/docs/guide/analytics/gain.md @@ -1,13 +1,15 @@ --- title: Token Savings Analytics -description: Measure and analyze your RTK token savings with rtk gain +description: Measure and analyze the bash output reduction RTK achieves with rtk gain sidebar: order: 1 --- # Token Savings Analytics -`rtk gain` shows how many tokens RTK has saved across all your commands, with daily, weekly, and monthly breakdowns. +`rtk gain` shows how much bash output RTK has removed across all your commands, with daily, weekly, and monthly breakdowns. + +What `rtk gain` measures is the reduction in **bash output bytes**, converted to estimated tokens. Bash output is one contributor to input tokens, alongside your prompt, the system prompt and conversation history, and input tokens are in turn only part of the bill, which also counts output tokens. See [How RTK Savings Work](../resources/savings-explained.md) for the full picture. ## Quick reference @@ -38,6 +40,8 @@ rtk gain --all --format csv > savings.csv rtk gain --daily ``` +**Example output** (illustrative numbers from one machine, not typical results โ€” yours depend entirely on which commands you run): + ``` ๐Ÿ“… Daily Breakdown (3 days) โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• @@ -51,10 +55,10 @@ TOTAL 196 1.3M 59.2K 1.2M 95.6% ``` - **Cmds**: RTK commands executed -- **Input**: Estimated tokens from raw command output -- **Output**: Actual tokens after filtering -- **Saved**: Input - Output (tokens that never reached the LLM) -- **Save%**: Saved / Input ร— 100 +- **Input**: Estimated tokens from raw command output (`bytes / 4`) +- **Output**: Estimated tokens after filtering (`bytes / 4`) +- **Saved**: Input - Output, in estimated tokens +- **Save%**: Saved / Input ร— 100 โ€” a **bash output byte ratio**, not a share of your bill ## Weekly and monthly breakdowns @@ -91,8 +95,8 @@ Same columns as daily, aggregated by Sunday-Saturday week or calendar month. ## Typical savings by command -| Command | Typical savings | Mechanism | -|---------|----------------|-----------| +| Command | Bash output reduction | Mechanism | +|---------|----------------------|-----------| | `git status` | 77-93% | Compact stat format | | `eslint` | 84% | Group by rule | | `jest` | 94-99% | Show failures only | @@ -101,9 +105,11 @@ Same columns as daily, aggregated by Sunday-Saturday week or calendar month. | `pnpm list` | 70-90% | Compact dependencies | | `grep` | 70% | Truncate + group | +These percentages measure bash output bytes removed, not cost reduction. + ## How token estimation works -RTK estimates tokens using `text.len() / 4` (4 characters per token average). This is accurate to ยฑ10% compared to actual LLM tokenization โ€” sufficient for trend analysis. +`rtk gain` estimates tokens as `bytes / 4` (`src/core/tracking.rs:1284`). RTK ships no real tokenizer by design: embedding one would cost startup time and would require a tokenizer per model, or a per-session model lookup, which RTK does not implement. The same estimator is applied to raw and filtered output, so the percentage is reliable; the absolute token counts are approximate and will not match your provider's billing. ``` Input Tokens = estimate_tokens(raw_command_output) @@ -172,14 +178,14 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - run: cargo install rtk + - run: cargo install --git https://github.com/rtk-ai/rtk --branch master rtk - run: rtk gain --weekly --format json > stats/week-$(date +%Y-%W).json - run: git add stats/ && git commit -m "Weekly rtk stats" && git push ``` ## Quota estimate -`--quota` estimates how many tokens RTK has saved relative to your monthly subscription budget, so you can see the cost impact of those savings. +`--quota` expresses the estimated tokens saved as a fraction of a monthly subscription budget. Like every other figure in `rtk gain`, it is derived from the `bytes / 4` estimate of bash output, so treat it as an order of magnitude rather than a billing forecast. ```bash rtk gain --quota # uses 20x tier by default diff --git a/docs/guide/getting-started/configuration.md b/docs/guide/getting-started/configuration.md index da76023911..2fedb1fd93 100644 --- a/docs/guide/getting-started/configuration.md +++ b/docs/guide/getting-started/configuration.md @@ -127,6 +127,24 @@ export RTK_TELEMETRY_DISABLED=1 enabled = false ``` -## Per-project filters +## Custom filters -Create `.rtk/filters.toml` in your project root to add custom filters or override built-ins. See [`src/filters/README.md`](https://github.com/rtk-ai/rtk/blob/master/src/filters/README.md) for the full TOML DSL reference. +Add your own filters (or override built-ins) in either location: + +- **Project-local** โ€” `.rtk/filters.toml` in your project root (committed with the repo) +- **User-global** โ€” `~/.config/rtk/filters.toml` (applies to every project) + +See [`src/filters/README.md`](https://github.com/rtk-ai/rtk/blob/master/src/filters/README.md) for the full TOML DSL reference. + +### Trusting custom filters + +Because a filter can rewrite what your AI assistant sees, custom filter files are **not applied until you trust them**. An untrusted (or edited) filter file is skipped silently on the command path. You review and manage trust with explicit commands: + +```bash +rtk trust # shows each filter and asks to confirm (--yes to skip the prompt) +rtk untrust # revokes trust +``` + +`rtk init` also detects existing filters and lets you enable them โ€” interactively, or non-interactively with `--trust-filters` / `--no-trust-filters`. Trust is tied to the file's contents (SHA-256), so editing a trusted file requires re-running `rtk trust`. + +> **Upgrading:** earlier versions applied `~/.config/rtk/filters.toml` without trust. After upgrading, the user-global file is gated like project filters โ€” if you already relied on a global filter, run `rtk trust` once to re-enable it. diff --git a/docs/guide/getting-started/installation.md b/docs/guide/getting-started/installation.md index a07c025b7b..4cc0280941 100644 --- a/docs/guide/getting-started/installation.md +++ b/docs/guide/getting-started/installation.md @@ -14,13 +14,13 @@ Two unrelated projects share the name `rtk`. Make sure you install the right one - **Rust Token Killer** (`rtk-ai/rtk`) โ€” this project, a token-saving CLI proxy - **Rust Type Kit** (`reachingforthejack/rtk`) โ€” a different tool for generating Rust types -The easiest way to verify you have the correct one: run `rtk gain`. It should display token savings stats. If it returns "command not found", you either have the wrong package or RTK is not installed. +The easiest way to verify you have the correct one: run `rtk gain`. It should display the savings dashboard. If it returns "command not found", you either have the wrong package or RTK is not installed. ## Check before installing ```bash rtk --version # should print: rtk x.y.z -rtk gain # should show token savings stats +rtk gain # should show the savings dashboard ``` If both commands work, RTK is already installed. Skip to [Project initialization](#project-initialization). @@ -44,7 +44,7 @@ brew install rtk-ai/tap/rtk ::: ```bash -cargo install --git https://github.com/rtk-ai/rtk rtk +cargo install --git https://github.com/rtk-ai/rtk --branch master rtk ``` ## Pre-built binaries (Windows, Linux, macOS) @@ -61,7 +61,7 @@ Download from [GitHub releases](https://github.com/rtk-ai/rtk/releases): ```bash rtk --version # rtk x.y.z -rtk gain # token savings dashboard +rtk gain # savings dashboard ``` If `rtk gain` fails but `rtk --version` succeeds, you installed Rust Type Kit by mistake. Uninstall it first: diff --git a/docs/guide/getting-started/supported-agents.md b/docs/guide/getting-started/supported-agents.md index d5a0ad87c7..adb0e8bd17 100644 --- a/docs/guide/getting-started/supported-agents.md +++ b/docs/guide/getting-started/supported-agents.md @@ -1,6 +1,6 @@ --- title: Supported Agents -description: How to integrate RTK with Claude Code, Cursor, Copilot, Cline, Windsurf, Codex, OpenCode, Hermes, Kilo Code, and Antigravity +description: How to integrate RTK with Claude Code, Cursor, Copilot, Cline, Windsurf, Codex, OpenCode, Hermes, Kilo Code, Antigravity, and Factory Droid sidebar: order: 3 --- @@ -11,7 +11,7 @@ RTK supports all major AI coding agents across 3 integration tiers. Mistral Vibe ## How it works -Each agent integration intercepts CLI commands before execution and rewrites them to their RTK equivalent. The agent runs `rtk cargo test` instead of `cargo test`, sees filtered output, and uses up to 90% fewer tokens โ€” without any change to your workflow. +Each agent integration intercepts CLI commands before execution and rewrites them to their RTK equivalent. The agent runs `rtk cargo test` instead of `cargo test`, sees filtered output, and reads up to 90% fewer bash output bytes โ€” without any change to your workflow. All rewrite logic lives in the RTK binary (`rtk rewrite`). Agent hooks are thin delegates that parse the agent-specific JSON format and call `rtk rewrite` for the actual decision. @@ -21,7 +21,7 @@ Agent runs "cargo test" -> Calls rtk rewrite "cargo test" -> Returns "rtk cargo test" -> Agent executes filtered command - -> LLM sees 90% fewer tokens + -> LLM reads up to 90% fewer bash output bytes ``` ## Supported agents @@ -37,6 +37,7 @@ Agent runs "cargo test" | OpenClaw | TypeScript plugin (`before_tool_call`) | Yes | | Pi | TypeScript extension (`tool_call` event) | Yes | | Hermes | Python plugin (`terminal` command mutation) | Yes | +| Factory Droid | Shell hook (`PreToolUse`, matcher `Execute`) | Yes | | Cline / Roo Code | Rules file (prompt-level) | N/A | | Windsurf | Rules file (prompt-level) | N/A | | Codex CLI | AGENTS.md instructions | N/A | @@ -137,6 +138,26 @@ Creates `~/.hermes/plugins/rtk-rewrite/` and enables it through `plugins.enabled The plugin fails open. If `rtk` is missing at load time, the hook is not registered. If `rtk rewrite` errors, the tool is not `terminal`, the payload has no string `command`, or the plugin raises an exception, Hermes runs the original command unchanged. The same `rtk rewrite` limitations apply: already-prefixed `rtk` commands, compound shell commands, heredocs, and commands without filters are not rewritten. +### Factory Droid + +```bash +rtk init -g --agent droid # user-scoped (~/.factory/hooks.json) +rtk init --agent droid # project-scoped (.factory/hooks.json, commit to share) +``` + +Installs a `PreToolUse` hook (matcher `Execute`) into Droid's canonical `hooks.json` โ€” falling back to the `hooks` key of `settings.json` only when that file already carries live `PreToolUse` hooks. Respects `$FACTORY_HOME_OVERRIDE`. + +RTK honors Droid's own permission lists, never another agent's settings. Commands matching an explicit `commandDenylist` or `commandBlocklist` entry โ€” read from all four settings scopes (`~/.factory/settings.json`, `~/.factory/settings.local.json`, `.factory/settings.json`, `.factory/settings.local.json`) โ€” are left untouched so Droid's native confirmation or block fires on the original command. Every other command is rewritten via `updatedInput` with **no** permission decision: Droid's native flow (allowlist, autonomy level, other hooks) decides on the rewritten command. To auto-run rewritten read-only commands, add `rtk`-prefixed entries (e.g. `rtk git status`) to your `commandAllowlist`. + +Uninstall: + +```bash +rtk init --uninstall -g --agent droid +rtk init --uninstall --agent droid +``` + +Removes only RTK's hook entry; other hooks and settings are untouched. + ### Cline / Roo Code ```bash diff --git a/docs/guide/index.md b/docs/guide/index.md index 44e82095d7..77f1b32433 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -1,6 +1,6 @@ --- title: RTK Documentation -description: RTK (Rust Token Killer) โ€” reduce LLM token consumption by 60-90% on common dev commands, with zero workflow changes +description: RTK (Rust Token Killer) โ€” cut up to 90% of the bash output your agent reads on common dev commands, with zero workflow changes sidebar: order: 1 --- @@ -9,7 +9,7 @@ sidebar: RTK is a CLI proxy that sits between your AI assistant and your development tools. It filters command output before it reaches the LLM, keeping only what matters and discarding boilerplate, progress bars, and noise. -**Result:** 60-90% fewer tokens consumed per command, without changing how you work. You run `git status` as usual โ€” RTK's hook intercepts it, filters the output, and the LLM sees a compact 3-line summary instead of 40 lines. +**Result:** up to 90% fewer bash output bytes reaching the LLM per command, without changing how you work. You run `git status` as usual โ€” RTK's hook intercepts it, filters the output, and the LLM sees a compact 3-line summary instead of 40 lines. ## How it works @@ -21,16 +21,23 @@ Your AI assistant runs: git status rtk git status (transparent rewrite) โ†“ Raw output: 40 lines โ†’ Filtered: 3 lines - ~800 tokens โ†’ ~60 tokens (92% saved) โ†“ LLM sees the compact output ``` Zero config changes to your workflow. The hook handles everything automatically. +## What the savings mean + +RTK reduces **bash output bytes** โ€” the output a shell command sends back before your agent reads it. That is not the same as reducing your bill by the same amount. + +Bash output is one contributor to input tokens, alongside your prompt, the system prompt and conversation history. Input tokens are in turn only part of the bill, which also counts output tokens. The reduction dilutes at every step. + +See [How RTK Savings Work](./resources/savings-explained.md) for the full picture, including why the token counts RTK reports are estimates. + ## What RTK optimizes -Dozens of commands across all major ecosystems โ€” Git, Cargo/Rust, JavaScript, Python, Go, Ruby, .NET, Docker/Kubernetes, and more. See [What RTK Optimizes](./resources/what-rtk-covers.md) for the full list with savings percentages. +Dozens of commands across all major ecosystems โ€” Git, Cargo/Rust, JavaScript, Python, Go, Ruby, .NET, Docker/Kubernetes, and more. See [What RTK Optimizes](./resources/what-rtk-covers.md) for the full list with per-command bash output reduction. ## Get started diff --git a/docs/guide/resources/savings-explained.md b/docs/guide/resources/savings-explained.md new file mode 100644 index 0000000000..6973674cb0 --- /dev/null +++ b/docs/guide/resources/savings-explained.md @@ -0,0 +1,87 @@ +--- +title: How RTK Savings Work +description: What RTK actually reduces, how bash output savings translate into cost, and why the token counts are estimates +sidebar: + order: 2 +--- + +# How RTK Savings Work + +RTK cuts **up to 90% of the bash output** your agent reads. This page explains what that number measures, what it does not measure, and how it reaches your bill. + +## What RTK filters + +RTK sits between your agent and the CLI. When the agent runs a shell command, RTK executes it, compresses the output, and returns the compressed version. + +``` +agent runs a shell command + | + v + RTK filters the output + | + v + agent reads the result +``` + +The only thing RTK changes is **the bytes a shell command sends back**. Everything RTK reports as "savings" is measured on those bytes. + +## The savings chain + +``` +Cost +โ”œโ”€ Input tokens +โ”‚ โ”œโ”€ Bash output <- the only part RTK filters +โ”‚ โ”œโ”€ Your prompt +โ”‚ โ”œโ”€ System prompt +โ”‚ โ””โ”€ Conversation history +โ””โ”€ Output tokens <- what the model writes +``` + +Those bytes are **one contributor to input tokens**, alongside your prompt, the system prompt, and conversation history. Input tokens are in turn **only part of the bill**, which also counts output tokens. + +So the reduction dilutes at every step: a large cut in bash output produces a smaller cut in input tokens, and a smaller one again in cost. A command showing 90% fewer output bytes does not make your session 90% cheaper. + +This is why RTK reports bash output reduction rather than a cost figure. Bash output is the part RTK controls; the rest depends on your prompt, your model, how much the agent writes back, and how much of the conversation is replayed on each call. + +## Why the token counts are estimates + +`rtk gain` estimates tokens as `bytes / 4`: + +```rust +// src/core/tracking.rs +pub fn estimate_tokens(text: &str) -> usize { + // ~4 chars per token on average + (text.len() as f64 / 4.0).ceil() as usize +} +``` + +RTK ships **no real tokenizer** by design. Embedding one would cost startup time, and it would require a tokenizer per model, or a per-session model lookup, which RTK does not implement. + +The consequence is worth understanding: + +- **The percentage is reliable.** The same estimator is applied to the raw output and the filtered output, so the ratio between them holds regardless of the estimator's absolute accuracy. +- **The absolute token counts are approximate.** They will not match your provider's billing. Treat `Input tokens: 45,230` as an order of magnitude, not an invoice line. + +If you need exact counts, run the raw and filtered output through your model's own tokenizer. + +## How to read `rtk gain` + +| Column | What it actually is | +|--------|---------------------| +| Input | Estimated tokens from raw command output, `bytes / 4` | +| Output | Estimated tokens after filtering, `bytes / 4` | +| Saved | Input minus Output, in estimated tokens | +| Save% | Reduction in bash output bytes | + +`Save%` is the meaningful number. It is a byte ratio, and it is accurate as a ratio. + +## What RTK does not reduce + +- **Output tokens.** RTK never touches what the model writes. +- **Your prompt, the system prompt, or conversation history.** These are input tokens RTK has no visibility into. +- **Commands with no matching filter.** These pass through untouched and are tracked at 0% savings. See `rtk gain --history`. + +## See also + +- [What RTK Optimizes](what-rtk-covers.md) โ€” per-command bash output reduction +- [Token Savings Analytics](../analytics/gain.md) โ€” reading the `rtk gain` dashboard diff --git a/docs/guide/resources/telemetry.md b/docs/guide/resources/telemetry.md index b77a0afc78..df1efac512 100644 --- a/docs/guide/resources/telemetry.md +++ b/docs/guide/resources/telemetry.md @@ -2,7 +2,7 @@ title: Telemetry & Privacy description: What RTK collects, how to opt out, and your GDPR rights sidebar: - order: 3 + order: 4 --- # Telemetry & Privacy @@ -21,7 +21,7 @@ Without telemetry, we have no visibility into: - Which commands are used most and need the best filters - Which filters are underperforming and need improvement - Which ecosystems to prioritize for new filter development -- How much value RTK delivers to users (token savings in $ terms) +- How much bash output RTK removes before it reaches the model - Whether users stay engaged over time or churn after trying RTK This data directly drives our roadmap. For example, if telemetry shows that 40% of users run Python commands but only 10% of our filters cover Python, we know where to invest next. @@ -65,9 +65,9 @@ This data directly drives our roadmap. For example, if telemetry shows that 40% | Field | Example | Purpose | |-------|---------|---------| -| `passthrough_top` | `["git:15", "npm:8"]` | Top 5 commands with 0% savings โ€” these need filters | +| `passthrough_top` | `["git:15", "npm:8"]` | Top 5 commands with 0% bash output reduction โ€” these need filters | | `parse_failures_24h` | `3` | Filter fragility โ€” high count means filters are breaking | -| `low_savings_commands` | `["rtk docker ps:25%"]` | Commands averaging <30% savings โ€” filters to improve | +| `low_savings_commands` | `["rtk :25%"]` | Commands averaging <30% bash output reduction โ€” filters to improve. The example is a placeholder, not a measured value | | `avg_savings_per_command` | `68.5` | Unweighted average (vs global which is volume-biased) | ### Ecosystem distribution @@ -88,7 +88,7 @@ This data directly drives our roadmap. For example, if telemetry shows that 40% | Field | Example | Purpose | |-------|---------|---------| | `tokens_saved_30d` | `12000000` | 30-day token savings for trend analysis | -| `estimated_savings_usd_30d` | `36.0` | Estimated dollar value saved (at ~$3/Mtok input pricing, Claude Sonnet) | +| `estimated_savings_usd_30d` | โ€” | A USD value derived from the estimated tokens saved and a fixed internal constant. It is not a measured cost and does not reflect any provider's pricing | ### Adoption diff --git a/docs/guide/resources/troubleshooting.md b/docs/guide/resources/troubleshooting.md index 51a6fa3be2..c0c0d5c0d5 100644 --- a/docs/guide/resources/troubleshooting.md +++ b/docs/guide/resources/troubleshooting.md @@ -130,7 +130,7 @@ Error: program not found **Fix:** Update to RTK v0.23.1+: ```bash -cargo install --git https://github.com/rtk-ai/rtk +cargo install --git https://github.com/rtk-ai/rtk --branch master rtk --version # should be 0.23.1+ ``` @@ -158,10 +158,10 @@ rtk init --show # should show "OpenCode: plugin installed" If Rust Type Kit is published to crates.io under the name `rtk`, `cargo install rtk` may install the wrong one. -Always use the explicit URL: +Always use the explicit URL, pinned to the release branch: ```bash -cargo install --git https://github.com/rtk-ai/rtk +cargo install --git https://github.com/rtk-ai/rtk --branch master ``` ## Run the diagnostic script diff --git a/docs/guide/resources/what-rtk-covers.md b/docs/guide/resources/what-rtk-covers.md index dd5c39e89d..c9c8105791 100644 --- a/docs/guide/resources/what-rtk-covers.md +++ b/docs/guide/resources/what-rtk-covers.md @@ -1,6 +1,6 @@ --- title: What RTK Optimizes -description: Commands and ecosystems automatically optimized by RTK with typical token savings +description: Commands and ecosystems automatically optimized by RTK, with typical bash output reduction sidebar: order: 1 --- @@ -9,12 +9,16 @@ sidebar: Once RTK is installed with a hook, these commands are automatically intercepted and filtered. You run them normally โ€” the hook rewrites them transparently before execution. -Typical savings: 60-99%. +Typical bash output reduction: 60-99%. + +:::note +Every percentage below measures **bash output bytes removed** โ€” the only thing RTK controls. Those bytes are one contributor to input tokens, and input tokens are only part of a bill that also counts output tokens. See [How RTK Savings Work](./savings-explained.md) before reading these numbers as cost figures. +::: ## Git -| Command | Savings | What changes | -|---------|---------|--------------| +| Command | Bash output reduction | What changes | +|---------|----------------------|--------------| | `git status` | 75-93% | Compact stat format, grouped by state | | `git log` | 80-92% | Hash + author + subject only | | `git diff` | 70% | Context reduced, headers stripped | @@ -23,8 +27,8 @@ Typical savings: 60-99%. ## GitHub CLI -| Command | Savings | What changes | -|---------|---------|--------------| +| Command | Bash output reduction | What changes | +|---------|----------------------|--------------| | `gh pr view` | 87% | Removes ASCII art and verbose metadata | | `gh pr checks` | 79% | Status + name only, failures highlighted | | `gh run list` | 82% | Compact workflow run summary | @@ -32,15 +36,15 @@ Typical savings: 60-99%. ## Graphite (Stacked PRs) -| Command | Savings | What changes | -|---------|---------|--------------| +| Command | Bash output reduction | What changes | +|---------|----------------------|--------------| | `gt log` | 75% | Stack summary only | | `gt status` | 70% | Current branch context | ## Cargo / Rust -| Command | Savings | What changes | -|---------|---------|--------------| +| Command | Bash output reduction | What changes | +|---------|----------------------|--------------| | `cargo test` | 90% | Failures only, passed tests suppressed | | `cargo nextest` | 90% | Same as test | | `cargo build` | 80% | Errors and warnings only | @@ -49,8 +53,8 @@ Typical savings: 60-99%. ## JavaScript / TypeScript -| Command | Savings | What changes | -|---------|---------|--------------| +| Command | Bash output reduction | What changes | +|---------|----------------------|--------------| | `jest` | 94-99% | Failures only | | `vitest` | 94-99% | Failures only | | `tsc` | 75% | Type errors grouped by file | @@ -63,8 +67,8 @@ Typical savings: 60-99%. ## Python -| Command | Savings | What changes | -|---------|---------|--------------| +| Command | Bash output reduction | What changes | +|---------|----------------------|--------------| | `pytest` | 80-90% | Failures only | | `ruff check` | 75% | Violations grouped by file | | `mypy` | 75% | Type errors grouped by file | @@ -72,32 +76,32 @@ Typical savings: 60-99%. ## Go -| Command | Savings | What changes | -|---------|---------|--------------| +| Command | Bash output reduction | What changes | +|---------|----------------------|--------------| | `go test` | 80-90% | Failures only | | `golangci-lint run` | 75% | Violations grouped by file | | `go build` | 75% | Errors only | ## Ruby -| Command | Savings | What changes | -|---------|---------|--------------| +| Command | Bash output reduction | What changes | +|---------|----------------------|--------------| | `rspec` | 80-90% | Failures only | | `rubocop` | 75% | Offenses grouped by file | | `rake` | 70% | Task output, build errors highlighted | ## .NET -| Command | Savings | What changes | -|---------|---------|--------------| +| Command | Bash output reduction | What changes | +|---------|----------------------|--------------| | `dotnet build` | 80% | Errors and warnings only | | `dotnet test` | 85-90% | Failures only | | `dotnet format` | 75% | Changed files only | ## Docker / Kubernetes -| Command | Savings | What changes | -|---------|---------|--------------| +| Command | Bash output reduction | What changes | +|---------|----------------------|--------------| | `docker ps` | 65% | Essential columns (name, image, status, port) | | `docker images` | 60% | Name + tag + size only | | `docker logs` | 70% | Deduplicated, last N lines | @@ -107,8 +111,8 @@ Typical savings: 60-99%. ## Files and Search -| Command | Savings | What changes | -|---------|---------|--------------| +| Command | Bash output reduction | What changes | +|---------|----------------------|--------------| | `ls` | 80% | Tree format with file counts | | `find` | 75% | Tree format | | `grep` | 70% | Truncated lines, grouped by file | @@ -119,15 +123,15 @@ Typical savings: 60-99%. ## Cloud and Data -| Command | Savings | What changes | -|---------|---------|--------------| +| Command | Bash output reduction | What changes | +|---------|----------------------|--------------| | `aws` | 70% | JSON condensed, relevant fields only | | `psql` | 65% | Query results without decoration | | `curl` | 60% | Response body only, headers stripped | ## Global flags -These flags apply to all RTK commands and can push savings even higher: +These flags apply to all RTK commands and can push the bash output reduction even higher: | Flag | Description | |------|-------------| diff --git a/docs/usage/AUDIT_GUIDE.md b/docs/usage/AUDIT_GUIDE.md index b641f24503..b909e6eb94 100644 --- a/docs/usage/AUDIT_GUIDE.md +++ b/docs/usage/AUDIT_GUIDE.md @@ -261,9 +261,7 @@ EOF ### Token Estimation -rtk estimates tokens using `text.len() / 4` (4 characters per token average). - -**Accuracy**: ยฑ10% compared to actual LLM tokenization (sufficient for trends). +`rtk gain` estimates tokens as `bytes / 4` (`src/core/tracking.rs:1284`). RTK ships no real tokenizer by design: embedding one would cost startup time and would require a tokenizer per model, or a per-session model lookup, which RTK does not implement. The same estimator is applied to raw and filtered output, so the percentage is reliable; the absolute token counts are approximate and will not match your provider's billing. ### Savings Calculation @@ -274,10 +272,12 @@ Saved Tokens = Input - Output Savings % = (Saved / Input) ร— 100 ``` +`Savings %` is a bash output byte ratio. Those bytes are one contributor to input tokens, and input tokens are only part of a bill that also counts output tokens. + ### Typical Savings by Command -| Command | Typical Savings | Mechanism | -|---------|----------------|-----------| +| Command | Bash output reduction | Mechanism | +|---------|----------------------|-----------| | `rtk git status` | 77-93% | Compact stat format | | `rtk eslint` | 84% | Group by rule | | `rtk jest` | 94-99% | Show failures only | diff --git a/docs/usage/FEATURES.md b/docs/usage/FEATURES.md index ed5ee0bd75..8afaa62022 100644 --- a/docs/usage/FEATURES.md +++ b/docs/usage/FEATURES.md @@ -1,9 +1,19 @@ # RTK - Documentation fonctionnelle complete -> **rtk (Rust Token Killer)** -- Proxy CLI haute performance qui reduit la consommation de tokens LLM de 60 a 90%. +> **rtk (Rust Token Killer)** -- Proxy CLI haute performance qui reduit jusqu'a 90% de la sortie bash lue par votre agent. Binaire Rust unique, zero dependances externes, overhead < 10ms par commande. +## A propos de la reduction de sortie bash + +Tous les pourcentages notes **Reduction sortie bash** dans ce document mesurent les **octets de sortie bash supprimes** : la sortie qu'une commande shell renvoie avant que l'agent ne la lise. Ce n'est pas equivalent a une reduction de facture du meme ordre. + +La sortie bash n'est qu'une source parmi d'autres pour les tokens d'entree, aux cotes de votre prompt, du prompt systeme et de l'historique de conversation. Les tokens d'entree ne representent eux-memes qu'une partie de la facture, qui compte aussi les tokens de sortie. La reduction se dilue a chaque etape. + +Les compteurs de tokens affiches par RTK sont estimes a `octets / 4` : RTK n'embarque pas de tokenizer, donc **les pourcentages sont fiables mais les nombres absolus de tokens sont approximatifs**. + +Explication complete : [How RTK Savings Work](../guide/resources/savings-explained.md) + --- ## Table des matieres @@ -53,7 +63,7 @@ Ces drapeaux s'appliquent a **toutes** les sous-commandes : | Drapeau | Court | Description | |---------|-------|-------------| | `--verbose` | `-v` | Augmenter la verbosite (-v, -vv, -vvv). Montre les details de filtrage. | -| `--ultra-compact` | `-u` | Mode ultra-compact : icones ASCII, format inline. Economies supplementaires. | +| `--ultra-compact` | `-u` | Mode ultra-compact : icones ASCII, format inline. Reduit encore la sortie bash. | | `--skip-env` | -- | Definit `SKIP_ENV_VALIDATION=1` pour les processus enfants (Next.js, tsc, lint, prisma). | **Exemples :** @@ -80,8 +90,6 @@ rtk ls [args...] Tous les drapeaux natifs de `ls` sont supportes (`-l`, `-a`, `-h`, `-R`, etc.). -**Economies :** ~80% de reduction de tokens - **Avant / Apres :** ``` # ls -la (45 lignes, ~800 tokens) # rtk ls (12 lignes, ~150 tokens) @@ -105,8 +113,6 @@ rtk tree [args...] Supporte tous les drapeaux natifs de `tree` (`-L`, `-d`, `-a`, etc.). -**Economies :** ~80% - --- ### `rtk read` -- Lecture de fichier @@ -129,7 +135,7 @@ rtk read - [options] # Lecture depuis stdin **Niveaux de filtrage :** -| Niveau | Description | Economies | +| Niveau | Description | Reduction sortie bash | |--------|-------------|-----------| | `none` | Aucun filtrage, sortie brute | 0% | | `minimal` | Supprime commentaires et lignes vides excessives | ~30% | @@ -162,8 +168,6 @@ fn main() -> Result<()> { fn main() -> Result<()> { ... } rtk smart [--model heuristic] [--force-download] ``` -**Economies :** ~95% - **Exemple :** ``` $ rtk smart src/tracking.rs @@ -184,8 +188,6 @@ rtk find [args...] Supporte a la fois la syntaxe RTK et la syntaxe native `find` (`-name`, `-type`, etc.). -**Economies :** ~80% - **Avant / Apres :** ``` # find . -name "*.rs" (30 lignes) # rtk find "*.rs" . (8 lignes) @@ -221,8 +223,6 @@ rtk grep [chemin] [options] Les arguments supplementaires sont transmis a `rg` (ripgrep). Les flags qui changent le format de sortie (`-c`, `-l`, `-L`, `-o`, `-Z`) passent directement a `rg`/`grep` sans filtrage RTK. -**Economies :** ~80% - **Avant / Apres :** ``` # rg "fn run" (20 lignes) # rtk grep "fn run" (10 lignes) @@ -246,8 +246,6 @@ rtk diff rtk diff # Stdin comme second fichier ``` -**Economies :** ~60% - --- ### `rtk wc` -- Comptage compact @@ -286,8 +284,6 @@ Toutes les sous-commandes git sont supportees. Les commandes non reconnues sont ### `rtk git status` -- Status compact -**Economies :** ~80% - ```bash rtk git status [args...] # Supporte tous les drapeaux git status ``` @@ -310,8 +306,6 @@ Changes not staged for commit: ? new_file.txt ### `rtk git log` -- Historique compact -**Economies :** ~80% - ```bash rtk git log [args...] # Supporte --oneline, --graph, --all, -n, etc. ``` @@ -331,8 +325,6 @@ Date: Mon Jan 15 10:30:00 2024 789abc Refactor filter engine ### `rtk git diff` -- Diff compact -**Economies :** ~75% - ```bash rtk git diff [args...] # Supporte --stat, --cached, --staged, etc. ``` @@ -354,8 +346,6 @@ index abc123..def456 100644 + let config = Config::load()?; ### `rtk git show` -- Show compact -**Economies :** ~80% - ```bash rtk git show [args...] ``` @@ -366,8 +356,6 @@ Affiche le resume du commit + stat + diff compact. ### `rtk git add` -- Add ultra-compact -**Economies :** ~92% - ```bash rtk git add [args...] # Supporte -A, -p, --all, etc. ``` @@ -378,8 +366,6 @@ rtk git add [args...] # Supporte -A, -p, --all, etc. ### `rtk git commit` -- Commit ultra-compact -**Economies :** ~92% - ```bash rtk git commit -m "message" [args...] # Supporte -a, --amend, --allow-empty, etc. ``` @@ -390,8 +376,6 @@ rtk git commit -m "message" [args...] # Supporte -a, --amend, --allow-empty, ### `rtk git push` -- Push ultra-compact -**Economies :** ~92% - ```bash rtk git push [args...] # Supporte -u, remote, branch, etc. ``` @@ -409,8 +393,6 @@ Delta compression using up to 8 threads ### `rtk git pull` -- Pull ultra-compact -**Economies :** ~92% - ```bash rtk git pull [args...] ``` @@ -480,7 +462,7 @@ rtk gh [args...] **Sous-commandes supportees :** -| Commande | Description | Economies | +| Commande | Description | Reduction sortie bash | |----------|-------------|-----------| | `rtk gh pr list` | Liste des PRs compacte | ~80% | | `rtk gh pr view ` | Details d'une PR + checks | ~87% | @@ -513,8 +495,6 @@ Showing 10 of 15 pull requests in org/repo #42 feat: add vitest (open, 2d) rtk test ``` -**Economies :** ~90% - **Exemple :** ```bash rtk test cargo test @@ -544,8 +524,6 @@ test utils::test_edge_case ... FAILED rtk err ``` -**Economies :** ~80% - **Exemple :** ```bash rtk err npm run build @@ -556,8 +534,6 @@ rtk err cargo build ### `rtk cargo test` -- Tests Rust -**Economies :** ~90% - ```bash rtk cargo test [args...] ``` @@ -578,8 +554,6 @@ Filtre la sortie de `cargo nextest` pour n'afficher que les echecs. ### `rtk jest` / `rtk vitest` -- Tests Jest/Vitest -**Economies :** ~99.5% - ```bash rtk jest [args...] rtk vitest [args...] @@ -589,8 +563,6 @@ rtk vitest [args...] ### `rtk playwright test` -- Tests E2E Playwright -**Economies :** ~94% - ```bash rtk playwright [args...] ``` @@ -599,8 +571,6 @@ rtk playwright [args...] ### `rtk pytest` -- Tests Python -**Economies :** ~90% - ```bash rtk pytest [args...] ``` @@ -609,8 +579,6 @@ rtk pytest [args...] ### `rtk go test` -- Tests Go -**Economies :** ~90% - ```bash rtk go test [args...] ``` @@ -623,8 +591,6 @@ Utilise le streaming JSON NDJSON de Go pour un filtrage precis. ### `rtk cargo build` -- Build Rust -**Economies :** ~80% - ```bash rtk cargo build [args...] ``` @@ -635,8 +601,6 @@ Supprime les lignes "Compiling...", ne conserve que les erreurs et le resultat f ### `rtk cargo check` -- Check Rust -**Economies :** ~80% - ```bash rtk cargo check [args...] ``` @@ -647,8 +611,6 @@ Supprime les lignes "Checking...", ne conserve que les erreurs. ### `rtk cargo clippy` -- Clippy Rust -**Economies :** ~80% - ```bash rtk cargo clippy [args...] ``` @@ -669,8 +631,6 @@ Supprime la compilation des dependances, ne conserve que le resultat d'installat ### `rtk tsc` -- TypeScript Compiler -**Economies :** ~83% - ```bash rtk tsc [args...] ``` @@ -691,8 +651,6 @@ src/utils.ts(5,1): error TS2304: ... src/utils.ts (1 error) ### `rtk lint` -- ESLint / Biome -**Economies :** ~84% - ```bash rtk lint [args...] rtk lint biome [args...] @@ -704,8 +662,6 @@ Regroupe les violations par regle et par fichier. Auto-detecte le linter. ### `rtk prettier` -- Verification du formatage -**Economies :** ~70% - ```bash rtk prettier [args...] # ex: rtk prettier --check . ``` @@ -726,8 +682,6 @@ Auto-detecte le formateur du projet (prettier, black, ruff format) et applique u ### `rtk next build` -- Build Next.js -**Economies :** ~87% - ```bash rtk next [args...] ``` @@ -738,8 +692,6 @@ Sortie compacte avec metriques de routes. ### `rtk ruff` -- Linter/formateur Python -**Economies :** ~80% - ```bash rtk ruff check [args...] rtk ruff format --check [args...] @@ -761,8 +713,6 @@ Regroupe les erreurs de type par fichier. ### `rtk golangci-lint` -- Linter Go -**Economies :** ~85% - ```bash rtk golangci-lint run [args...] ``` @@ -796,7 +746,7 @@ Detecte automatiquement : prettier, black, ruff format, rustfmt. Applique un fil ### `rtk pnpm` -- pnpm -| Commande | Description | Economies | +| Commande | Description | Reduction sortie bash | |----------|-------------|-----------| | `rtk pnpm list [-d N]` | Arbre de dependances compact | ~70% | | `rtk pnpm outdated` | Paquets obsoletes : `pkg: old -> new` | ~80% | @@ -854,8 +804,6 @@ rtk deps [chemin] # Defaut: repertoire courant Auto-detecte : `Cargo.toml`, `package.json`, `pyproject.toml`, `go.mod`, `Gemfile`, etc. -**Economies :** ~70% - --- ### `rtk prisma` -- ORM Prisma @@ -874,7 +822,7 @@ Auto-detecte : `Cargo.toml`, `package.json`, `pyproject.toml`, `go.mod`, `Gemfil ### `rtk docker` -- Docker -| Commande | Description | Economies | +| Commande | Description | Reduction sortie bash | |----------|-------------|-----------| | `rtk docker ps` | Liste compacte des conteneurs | ~80% | | `rtk docker images` | Liste compacte des images | ~80% | @@ -918,8 +866,6 @@ rtk json [--depth N] # Defaut: profondeur 5 rtk json - # Depuis stdin ``` -**Economies :** ~60% - **Avant / Apres :** ``` # cat package.json (50 lignes) # rtk json package.json (10 lignes) @@ -960,8 +906,6 @@ rtk log # Depuis stdin (pipe) Les lignes repetees sont fusionnees : `[ERROR] Connection refused (x42)`. -**Economies :** ~60-80% (selon la repetitivite) - --- ### `rtk curl` -- HTTP avec troncature @@ -1056,11 +1000,11 @@ RTK enregistre chaque execution de commande dans une base SQLite : - **Emplacement :** `~/.local/share/rtk/tracking.db` (Linux), `~/Library/Application Support/rtk/tracking.db` (macOS) - **Retention :** 90 jours automatique -- **Metriques :** tokens entree/sortie, pourcentage d'economies, temps d'execution, projet +- **Metriques :** tokens entree/sortie, pourcentage de reduction de sortie bash, temps d'execution, projet --- -### `rtk gain` -- Statistiques d'economies +### `rtk gain` -- Statistiques de reduction de sortie bash ```bash rtk gain # Resume global @@ -1071,7 +1015,7 @@ rtk gain --daily # Ventilation jour par jour rtk gain --weekly # Ventilation par semaine rtk gain --monthly # Ventilation par mois rtk gain --all # Toutes les ventilations -rtk gain --quota -t pro # Estimation d'economies sur le quota mensuel +rtk gain --quota -t pro # Estimation de la sortie bash economisee sur le quota mensuel rtk gain --failures # Log des echecs de parsing (commandes en fallback) rtk gain --format json # Export JSON (pour dashboards) rtk gain --format csv # Export CSV @@ -1084,7 +1028,7 @@ rtk gain --format csv # Export CSV | `--project` | `-p` | Filtrer par repertoire courant | | `--graph` | `-g` | Graphe ASCII des 30 derniers jours | | `--history` | `-H` | Historique recent des commandes | -| `--quota` | `-q` | Estimation d'economies sur le quota mensuel | +| `--quota` | `-q` | Estimation de la sortie bash economisee sur le quota mensuel | | `--tier` | `-t` | Tier d'abonnement : `pro`, `5x`, `20x` (defaut: `20x`) | | `--daily` | `-d` | Ventilation quotidienne | | `--weekly` | `-w` | Ventilation hebdomadaire | @@ -1152,7 +1096,7 @@ rtk learn --format json # Export JSON ### `rtk cc-economics` -- Analyse economique Claude Code -**Objectif :** Compare les depenses Claude Code (via ccusage) avec les economies RTK. +**Objectif :** Compare les depenses Claude Code (via ccusage) avec la sortie bash economisee par RTK. ```bash rtk cc-economics # Resume @@ -1377,7 +1321,7 @@ FAILED: 2/15 tests RTK peut envoyer un ping anonyme une fois par jour (23h d'intervalle) pour des statistiques d'utilisation. La telemetrie est **desactivee par defaut** et requiert un consentement explicite (RGPD Art. 6, 7). -**Donnees envoyees :** hash de device (SHA-256 d'un sel aleatoire), version, OS, architecture, nombre de commandes/24h, top commandes, pourcentage d'economies. +**Donnees envoyees :** hash de device (SHA-256 d'un sel aleatoire), version, OS, architecture, nombre de commandes/24h, top commandes, pourcentage de reduction de sortie bash. **Responsable du traitement :** `RTK AI Labs`, contact@rtk-ai.app @@ -1398,13 +1342,15 @@ Aucune donnee personnelle, aucun contenu de commande, aucun chemin de fichier n' --- -## Resume des economies par categorie +## Resume de la reduction de sortie bash par categorie + +Octets de sortie bash supprimes (voir [A propos de la reduction de sortie bash](#a-propos-de-la-reduction-de-sortie-bash)). -| Categorie | Commandes | Economies typiques | +| Categorie | Commandes | Reduction sortie bash | |-----------|-----------|-------------------| | **Fichiers** | ls, tree, read, find, grep, diff | 60-80% | | **Git** | status, log, diff, show, add, commit, push, pull | 75-92% | -| **GitHub** | pr, issue, run, api | 26-87% | +| **GitHub** | pr, issue, run, api | 79-87% | | **Tests** | cargo test, vitest, playwright, pytest, go test | 90-99% | | **Build/Lint** | cargo build, tsc, eslint, prettier, next, ruff, clippy | 70-87% | | **Paquets** | pnpm, npm, pip, deps, prisma | 60-80% | diff --git a/docs/usage/TRACKING.md b/docs/usage/TRACKING.md index 97ff6d95a0..825d362fb5 100644 --- a/docs/usage/TRACKING.md +++ b/docs/usage/TRACKING.md @@ -1,6 +1,10 @@ # RTK Tracking API Documentation -Comprehensive documentation for RTK's token savings tracking system. +Comprehensive documentation for RTK's tracking system, which records how much **bash output** each filtered command removed. + +Everything the tracker stores is measured on bash output bytes, converted to estimated tokens. Bash output is one contributor to input tokens, alongside your prompt, the system prompt and conversation history, and input tokens are in turn only part of the bill, which also counts output tokens. `savings_pct` is therefore a **bash output byte ratio** โ€” not a cost figure and not a share of your token bill. + +See [How RTK Savings Work](../guide/resources/savings-explained.md) for the full breakdown. ## Table of Contents @@ -14,9 +18,9 @@ Comprehensive documentation for RTK's token savings tracking system. ## Overview -RTK's tracking system records every command execution to provide analytics on token savings. The system: +RTK's tracking system records every command execution to provide analytics on bash output reduction. The system: - Stores command history in SQLite (~/.local/share/rtk/tracking.db) -- Tracks input/output tokens, savings percentage, and execution time +- Tracks estimated input/output tokens, bash output reduction percentage, and execution time - Automatically cleans up records older than 90 days - Provides aggregation APIs (daily/weekly/monthly) - Exports to JSON/CSV for external integrations @@ -75,8 +79,8 @@ impl Tracker { &self, original_cmd: &str, // Standard command (e.g., "ls -la") rtk_cmd: &str, // RTK command (e.g., "rtk ls") - input_tokens: usize, // Estimated input tokens - output_tokens: usize, // Actual output tokens + input_tokens: usize, // Estimated tokens from raw output (bytes / 4) + output_tokens: usize, // Estimated tokens after filtering (bytes / 4) exec_time_ms: u64, // Execution time in milliseconds ) -> Result<()>; @@ -106,8 +110,8 @@ pub struct GainSummary { pub total_commands: usize, // Total commands recorded pub total_input: usize, // Total input tokens pub total_output: usize, // Total output tokens - pub total_saved: usize, // Total tokens saved - pub avg_savings_pct: f64, // Average savings percentage + pub total_saved: usize, // Total estimated tokens saved + pub avg_savings_pct: f64, // Average bash output reduction, in percent pub total_time_ms: u64, // Total execution time (ms) pub avg_time_ms: u64, // Average execution time (ms) pub by_command: Vec<(String, usize, usize, f64, u64)>, // Top 10 commands @@ -127,7 +131,7 @@ pub struct DayStats { pub input_tokens: usize, // Total input tokens pub output_tokens: usize, // Total output tokens pub saved_tokens: usize, // Total tokens saved - pub savings_pct: f64, // Savings percentage + pub savings_pct: f64, // Bash output reduction, in percent pub total_time_ms: u64, // Total execution time (ms) pub avg_time_ms: u64, // Average execution time (ms) } @@ -178,8 +182,8 @@ Individual command record from history. pub struct CommandRecord { pub timestamp: DateTime, // UTC timestamp pub rtk_cmd: String, // RTK command used - pub saved_tokens: usize, // Tokens saved - pub savings_pct: f64, // Savings percentage + pub saved_tokens: usize, // Estimated tokens saved + pub savings_pct: f64, // Bash output reduction, in percent } ``` @@ -206,6 +210,8 @@ impl TimedExecution { ### Utility Functions +`rtk gain` estimates tokens as `bytes / 4` (`src/core/tracking.rs:1284`). RTK ships no real tokenizer by design: embedding one would cost startup time and would require a tokenizer per model, or a per-session model lookup, which RTK does not implement. The same estimator is applied to raw and filtered output, so the percentage is reliable; the absolute token counts are approximate and will not match your provider's billing. + ```rust /// Estimate token count (~4 chars = 1 token) pub fn estimate_tokens(text: &str) -> usize; @@ -369,7 +375,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Install RTK - run: cargo install --git https://github.com/rtk-ai/rtk + run: cargo install --git https://github.com/rtk-ai/rtk --branch master - name: Export weekly stats run: | @@ -491,10 +497,10 @@ CREATE TABLE commands ( timestamp TEXT NOT NULL, -- RFC3339 UTC timestamp original_cmd TEXT NOT NULL, -- Original command (e.g., "ls -la") rtk_cmd TEXT NOT NULL, -- RTK command (e.g., "rtk ls") - input_tokens INTEGER NOT NULL, -- Estimated input tokens - output_tokens INTEGER NOT NULL, -- Actual output tokens + input_tokens INTEGER NOT NULL, -- Estimated tokens from raw output (bytes / 4) + output_tokens INTEGER NOT NULL, -- Estimated tokens after filtering (bytes / 4) saved_tokens INTEGER NOT NULL, -- input_tokens - output_tokens - savings_pct REAL NOT NULL, -- (saved/input) * 100 + savings_pct REAL NOT NULL, -- (saved/input) * 100, a bash output byte ratio exec_time_ms INTEGER DEFAULT 0 -- Execution time in milliseconds ); diff --git a/hooks/README.md b/hooks/README.md index 55b2149ddf..a79f64e17d 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -12,7 +12,7 @@ Relationship to `src/hooks/`: that component **creates** these files; this direc ## Purpose -LLM agent integrations that intercept CLI commands and route them through RTK for token optimization. Each hook transparently rewrites raw commands (e.g., `git status`) to their RTK equivalents (e.g., `rtk git status`), delivering 60-90% token savings without requiring the agent or user to change their workflow. +LLM agent integrations that intercept CLI commands and route them through RTK for token optimization. Each hook transparently rewrites raw commands (e.g., `git status`) to their RTK equivalents (e.g., `rtk git status`), cutting up to 90% of the bash output that reaches the LLM context without requiring the agent or user to change their workflow. ## How It Works @@ -24,7 +24,7 @@ Agent runs command (e.g., "cargo test --nocapture") -> Registry matches pattern, returns "rtk cargo test --nocapture" -> Hook sends response in agent-specific JSON format -> Agent executes "rtk cargo test --nocapture" instead - -> Filtered output reaches LLM (~90% fewer tokens) + -> Filtered output reaches LLM (up to 90% fewer bash output bytes) ``` All rewrite logic lives in the Rust binary (`src/discover/registry.rs`). Hook scripts are **thin delegates** that handle agent-specific JSON formats and call `rtk rewrite` for the actual decision. This ensures a single source of truth for all 70+ rewrite patterns. @@ -50,7 +50,7 @@ Each agent subdirectory has its own README with hook-specific details: | Claude Code | Shell hook (`PreToolUse`) | Transparent rewrite | Yes (`updatedInput`) | | VS Code Copilot Chat | Rust binary (`rtk hook copilot`) | Transparent rewrite | Yes (`updatedInput`) | | GitHub Copilot CLI | Rust binary (`rtk hook copilot`) | Deny-with-suggestion | No (agent retries) | -| Cursor | Shell hook (`preToolUse`) | Transparent rewrite | Yes (`updated_input`) | +| Cursor | Rust binary | Transparent rewrite | Yes (`updated_input`) | | Gemini CLI | Rust binary (`rtk hook gemini`) | Transparent rewrite | Yes (`hookSpecificOutput`) | | Cline / Roo Code | Custom instructions (rules file) | Prompt-level guidance | N/A | | Windsurf | Custom instructions (rules file) | Prompt-level guidance | N/A | @@ -197,11 +197,12 @@ The registry (`src/discover/registry.rs`) handles command patterns across these ### Compound Command Handling -The registry handles `&&`, `||`, `;`, `|`, and `&` operators: +The registry handles `&&`, `||`, `;`, `|`, `|&`, and `&` operators: -- **Pipe** (`|`): Only the left side is rewritten (right side consumes output format) +- **Pipe** (`|`): Producers and intermediate stages stay raw; only a pipeline-safe final stage is rewritten +- **Stderr pipe** (`|&`): The complete pipeline stays raw - **And/Or/Semicolon** (`&&`, `||`, `;`): Both sides rewritten independently -- **find/fd in pipes**: Never rewritten (output format incompatible with xargs/wc/grep) +- **Pipeline-safe rules**: Initially limited to argument-safe `grep`, `rg`, and `wc` invocations; search pattern-file forms defer Example: `cargo fmt --all && cargo test` becomes `rtk cargo fmt --all && rtk cargo test` diff --git a/hooks/antigravity/rules.md b/hooks/antigravity/rules.md index 8ee44bcd40..bb5d20b1ad 100644 --- a/hooks/antigravity/rules.md +++ b/hooks/antigravity/rules.md @@ -29,4 +29,4 @@ rtk proxy # Run raw (no filtering, for debugging) ## Why -RTK filters and compresses command output before it reaches the LLM context, saving 60-90% tokens on common operations. Always use `rtk ` instead of raw commands. +RTK filters and compresses command output before it reaches the LLM context, cutting up to 90% of the bash output on common operations. Always use `rtk ` instead of raw commands. diff --git a/hooks/claude/rtk-awareness.md b/hooks/claude/rtk-awareness.md index 0eaf3d52a2..f68d972a2e 100644 --- a/hooks/claude/rtk-awareness.md +++ b/hooks/claude/rtk-awareness.md @@ -1,6 +1,6 @@ # RTK - Rust Token Killer -**Usage**: Token-optimized CLI proxy (60-90% savings on dev operations) +**Usage**: Token-optimized CLI proxy (cuts up to 90% of bash output) ## Meta Commands (always use rtk directly) diff --git a/hooks/cline/rules.md b/hooks/cline/rules.md index 7d6c8aff5f..d6bd61bd4b 100644 --- a/hooks/cline/rules.md +++ b/hooks/cline/rules.md @@ -29,4 +29,4 @@ rtk proxy # Run raw (no filtering, for debugging) ## Why -RTK filters and compresses command output before it reaches the LLM context, saving 60-90% tokens on common operations. Always use `rtk ` instead of raw commands. +RTK filters and compresses command output before it reaches the LLM context, cutting up to 90% of the bash output on common operations. Always use `rtk ` instead of raw commands. diff --git a/hooks/copilot/rtk-awareness.md b/hooks/copilot/rtk-awareness.md index 185f460c5f..c1ad1e9017 100644 --- a/hooks/copilot/rtk-awareness.md +++ b/hooks/copilot/rtk-awareness.md @@ -1,6 +1,6 @@ # RTK โ€” Copilot Integration (VS Code Copilot Chat + Copilot CLI) -**Usage**: Token-optimized CLI proxy (60-90% savings on dev operations) +**Usage**: Token-optimized CLI proxy (cuts up to 90% of bash output) ## What's automatic diff --git a/hooks/cursor/rtk-rewrite.sh b/hooks/cursor/rtk-rewrite.sh index 4b80b260cd..018f8c61a8 100644 --- a/hooks/cursor/rtk-rewrite.sh +++ b/hooks/cursor/rtk-rewrite.sh @@ -39,16 +39,29 @@ if [ -z "$CMD" ]; then fi # Delegate all rewrite logic to the Rust binary. -# rtk rewrite exits 1 when there's no rewrite โ€” hook passes through silently. -REWRITTEN=$(rtk rewrite "$CMD" 2>/dev/null) || { echo '{}'; exit 0; } +# Exit codes: 0 = allow rewrite, 1 = no rewrite (passthrough), +# 2 = deny, 3 = ask. +REWRITTEN=$(rtk rewrite "$CMD" 2>/dev/null) +RC=$? +if [ "$RC" -ne 0 ] && [ "$RC" -ne 3 ]; then + echo '{}' + exit 0 +fi # No change โ€” nothing to do. -if [ "$CMD" = "$REWRITTEN" ]; then +if [ -z "$REWRITTEN" ] || [ "$CMD" = "$REWRITTEN" ]; then echo '{}' exit 0 fi -jq -n --arg cmd "$REWRITTEN" '{ - "permission": "allow", +# RC 3 = ask (not enforced by Cursor yet, but future-proof). +PERMISSION="allow" +if [ "$RC" -eq 3 ]; then + PERMISSION="ask" +fi + +jq -n --arg cmd "$REWRITTEN" --arg perm "$PERMISSION" '{ + "continue": true, + "permission": $perm, "updated_input": { "command": $cmd } }' diff --git a/hooks/kilocode/rules.md b/hooks/kilocode/rules.md index 53d7645961..51d141a22f 100644 --- a/hooks/kilocode/rules.md +++ b/hooks/kilocode/rules.md @@ -29,4 +29,4 @@ rtk proxy # Run raw (no filtering, for debugging) ## Why -RTK filters and compresses command output before it reaches the LLM context, saving 60-90% tokens on common operations. Always use `rtk ` instead of raw commands. +RTK filters and compresses command output before it reaches the LLM context, cutting up to 90% of the bash output on common operations. Always use `rtk ` instead of raw commands. diff --git a/hooks/pi/README.md b/hooks/pi/README.md index 1f0c8e00a6..0094227129 100644 --- a/hooks/pi/README.md +++ b/hooks/pi/README.md @@ -5,7 +5,7 @@ ## Design Intent RTK's Pi extension is a **rewrite-only token optimizer**. It mutates bash commands to their -`rtk`-prefixed equivalents, saving 60โ€“90% context tokens. +`rtk`-prefixed equivalents, cutting up to 90% of the bash output that reaches the context. **Permission gating is intentionally out of scope.** RTK does not block, confirm, or audit commands โ€” that concern belongs to a dedicated permission extension (e.g. one that gates diff --git a/hooks/windsurf/rules.md b/hooks/windsurf/rules.md index 8491f5a78a..b707b7acae 100644 --- a/hooks/windsurf/rules.md +++ b/hooks/windsurf/rules.md @@ -29,4 +29,4 @@ rtk proxy # Run raw (no filtering, for debugging) ## Why -RTK filters and compresses command output before it reaches the LLM context, saving 60-90% tokens on common operations. Always use `rtk ` instead of raw commands. +RTK filters and compresses command output before it reaches the LLM context, cutting up to 90% of the bash output on common operations. Always use `rtk ` instead of raw commands. diff --git a/openclaw/README.md b/openclaw/README.md index 480ec856fc..8ed741653a 100644 --- a/openclaw/README.md +++ b/openclaw/README.md @@ -1,6 +1,6 @@ # RTK Plugin for OpenClaw -Transparently rewrites shell commands executed via OpenClaw's `exec` tool to their RTK equivalents, achieving 60-90% LLM token savings. +Transparently rewrites shell commands executed via OpenClaw's `exec` tool to their RTK equivalents, cutting up to 90% of the bash output that reaches the LLM context. This is the OpenClaw equivalent of the Claude Code hooks in `hooks/rtk-rewrite.sh`. @@ -73,7 +73,7 @@ Handled by `rtk rewrite` guards: ## Measured savings -| Command | Token savings | +| Command | Output reduction | |---------|--------------| | `git log --stat` | 87% | | `ls -la` | 78% | diff --git a/openclaw/index.ts b/openclaw/index.ts index 4babe14d8b..cca77ac438 100644 --- a/openclaw/index.ts +++ b/openclaw/index.ts @@ -2,11 +2,19 @@ * RTK Rewrite Plugin for OpenClaw * * Transparently rewrites exec tool commands to RTK equivalents - * before execution, achieving 60-90% LLM token savings. + * before execution, cutting up to 90% of the bash output that reaches the LLM context. * * All rewrite logic lives in `rtk rewrite` (src/discover/registry.rs). * This plugin is a thin delegate โ€” to add or change rules, edit the * Rust registry, not this file. + * + * Exit code protocol for `rtk rewrite`: + * 0 + stdout Allow โ€” rewrite found, explicitly allowed โ†’ auto-apply + * 1 No RTK equivalent โ†’ pass through unchanged + * 2 Deny rule matched โ†’ block the call + * 3 + stdout Ask rule matched (or default) โ†’ rewrite, require approval + * + * See: src/hooks/rewrite_cmd.rs */ import { execFileSync } from "node:child_process"; @@ -24,7 +32,20 @@ function checkRtk(): boolean { return rtkAvailable; } -function tryRewrite(command: string): string | null { +/** + * Delegate to `rtk rewrite` and interpret the exit code. + * + * Returns a tuple `[rewritten, verdict?]`: + * [string] โ€” rewrite, auto-apply (exit 0) + * [string, "ask"] โ€” rewrite, require user approval (exit 3) + * [null, "deny"] โ€” command matched a deny rule (exit 2) + * [null] โ€” no rewrite / passthrough (exit 1 or no change) + */ +type RewriteVerdict = "ask" | "deny"; + +function tryRewrite( + command: string +): [string | null, RewriteVerdict?] { try { const result = execFileSync("rtk", ["rewrite", command], { encoding: "utf-8", @@ -32,9 +53,22 @@ function tryRewrite(command: string): string | null { }) .toString() .trim(); - return result && result !== command ? result : null; - } catch { - return null; + // Exit 0 โ€” Allow: rewrite and auto-apply + return [result && result !== command ? result : null]; + } catch (e: any) { + // Exit 3 โ€” Ask: rewrite available but user must approve + if (e?.status === 3 && e.stdout) { + const result = e.stdout.toString().trim(); + if (result && result !== command) return [result, "ask"]; + // Exit 3 but no usable stdout โ€” treat as passthrough + return [null]; + } + // Exit 2 โ€” Deny: command matched a deny rule, block the call + if (e?.status === 2) { + return [null, "deny"]; + } + // Exit 1 or unknown โ€” no rewrite, pass through + return [null]; } } @@ -58,14 +92,61 @@ export default function register(api: any) { const command = event.params?.command; if (typeof command !== "string") return; - const rewritten = tryRewrite(command); + const [rewritten, verdict] = tryRewrite(command); + + // Deny rule matched โ€” block the call entirely + if (verdict === "deny") { + if (verbose) { + console.log(`[rtk] DENY: ${command}`); + } + return { + block: true, + blockReason: "RTK deny rule matched", + }; + } + if (!rewritten) return; if (verbose) { - console.log(`[rtk] ${command} -> ${rewritten}`); + console.log( + `[rtk] ${command} -> ${rewritten}${verdict === "ask" ? " (approval required)" : ""}` + ); + } + + const result: { + params: Record; + requireApproval?: { + title: string; + description: string; + severity: "info"; + timeoutBehavior: "deny"; + allowedDecisions: Array<"allow-once" | "deny">; + onResolution?: (decision: string) => void; + }; + } = { + params: { ...event.params, command: rewritten }, + }; + + // Exit 3 โ€” Ask: rewrite but require user approval + if (verdict === "ask") { + result.requireApproval = { + title: "RTK rewrite suggestion", + description: `Rewrite: \`${command}\` โ†’ \`${rewritten}\``, + severity: "info", + timeoutBehavior: "deny", + // "allow-always" omitted: OpenClaw does not auto-persist approval + // for plugin hooks โ€” see: + // https://docs.openclaw.ai/plugins/plugin-permission-requests#troubleshooting + allowedDecisions: ["allow-once", "deny"], + onResolution: (decision: string) => { + if (verbose) { + console.log(`[rtk] approval ${decision}: ${command} -> ${rewritten}`); + } + }, + }; } - return { params: { ...event.params, command: rewritten } }; + return result; }, { priority: 10 } ); diff --git a/openclaw/openclaw.plugin.json b/openclaw/openclaw.plugin.json index e08d11ba1c..faeb7f0213 100644 --- a/openclaw/openclaw.plugin.json +++ b/openclaw/openclaw.plugin.json @@ -2,7 +2,7 @@ "id": "rtk-rewrite", "name": "RTK Token Optimizer", "version": "1.0.0", - "description": "Transparently rewrites shell commands to their RTK equivalents for 60-90% LLM token savings", + "description": "Transparently rewrites shell commands to their RTK equivalents, cutting up to 90% of the bash output reaching the LLM context", "homepage": "https://github.com/rtk-ai/rtk", "license": "Apache-2.0", "configSchema": { diff --git a/openclaw/package.json b/openclaw/package.json index 3b9791faf6..47baaf8faf 100644 --- a/openclaw/package.json +++ b/openclaw/package.json @@ -1,7 +1,7 @@ { "name": "@rtk-ai/rtk-rewrite", "version": "1.0.0", - "description": "RTK plugin for OpenClaw โ€” rewrites shell commands for 60-90% LLM token savings", + "description": "RTK plugin for OpenClaw โ€” rewrites shell commands, cutting up to 90% of the bash output reaching the LLM context", "main": "index.ts", "license": "Apache-2.0", "repository": { diff --git a/scripts/benchmark.sh b/scripts/benchmark.sh index 18f3d437c9..dbacaa9ed3 100755 --- a/scripts/benchmark.sh +++ b/scripts/benchmark.sh @@ -346,8 +346,8 @@ bench "wc" "wc Cargo.toml src/main.rs" "$RTK wc Cargo.toml src/main.rs" # =================== section "curl" if command -v curl &> /dev/null; then - bench "curl json" "curl -s https://httpbin.org/json" "$RTK curl https://httpbin.org/json" - bench "curl text" "curl -s https://httpbin.org/robots.txt" "$RTK curl https://httpbin.org/robots.txt" + bench "curl json" "curl -s https://mockhttp.org/json/1" "$RTK curl https://mockhttp.org/json/1" + bench "curl text" "curl -s https://mockhttp.org/robots.txt" "$RTK curl https://mockhttp.org/robots.txt" fi # =================== @@ -355,8 +355,8 @@ fi # =================== if command -v wget &> /dev/null; then section "wget" - bench "wget" "wget -qO- https://httpbin.org/json" "$RTK wget https://httpbin.org/json" - rm -f json 2>/dev/null + bench "wget" "wget -qO- https://mockhttp.org/json/1" "$RTK wget https://mockhttp.org/json/1" + rm -f 1 2>/dev/null fi # =================== diff --git a/scripts/benchmark/run.ts b/scripts/benchmark/run.ts index 3d964963c3..95fac2e3ae 100644 --- a/scripts/benchmark/run.ts +++ b/scripts/benchmark/run.ts @@ -137,7 +137,7 @@ if (shouldRun(3)) { await testCmd("log:large", `${RTK} log /tmp/large.log`); // Network - await testCmd("net:curl", `${RTK} curl https://httpbin.org/get`, "any"); + await testCmd("net:curl", `${RTK} curl https://mockhttp.org/get`, "any"); // GitHub await testCmd("gh:pr list", `cd /home/ubuntu/rtk && ${RTK} gh pr list`, "any"); diff --git a/scripts/test-all.sh b/scripts/test-all.sh index 08a8df7a59..8be6e8deca 100755 --- a/scripts/test-all.sh +++ b/scripts/test-all.sh @@ -230,8 +230,8 @@ assert_help "rtk cargo" rtk cargo section "Curl (new)" -assert_contains "rtk curl JSON detect" "string" rtk curl https://httpbin.org/json -assert_ok "rtk curl plain text" rtk curl https://httpbin.org/robots.txt +assert_contains "rtk curl JSON detect" "string" rtk curl https://mockhttp.org/json +assert_ok "rtk curl plain text" rtk curl https://mockhttp.org/robots.txt assert_help "rtk curl" rtk curl # โ”€โ”€ 8. Npm / Npx โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -351,7 +351,7 @@ assert_ok "rtk init --show" rtk init --show section "Wget" if command -v wget >/dev/null 2>&1; then - assert_ok "rtk wget stdout" rtk wget https://httpbin.org/robots.txt -O + assert_ok "rtk wget stdout" rtk wget https://mockhttp.org/robots.txt -O else skip_test "rtk wget" "wget not installed" fi diff --git a/src/analytics/README.md b/src/analytics/README.md index 5cea9ce77d..bcf9d3378d 100644 --- a/src/analytics/README.md +++ b/src/analytics/README.md @@ -4,7 +4,7 @@ ## Scope -**Read-only dashboards** over the tracking database. Queries token savings, correlates with external spending data, and surfaces adoption metrics. Never modifies the tracking DB. +**Read-only dashboards** over the tracking database. Queries the recorded bash output reduction, 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. @@ -13,7 +13,7 @@ Does **not** own: recording token savings (that's `core/tracking` called by `cmd Boundary rule: if a new module writes to the DB, it belongs in `core/` or `cmds/`, not here. Tool-specific analytics (like `cc_economics` reading Claude Code data) are fine โ€” the boundary is "read-only presentation", not "tool-agnostic". ## Purpose -Token savings analytics, economic modeling, and adoption metrics. +Bash output reduction analytics, economic modeling, and adoption metrics. The stored percentages measure output bytes; token counts are `bytes / 4` estimates, not billed tokens. These modules read from the SQLite tracking database to produce dashboards, spending estimates, and session-level adoption reports. diff --git a/src/analytics/ccusage.rs b/src/analytics/ccusage.rs index c291615b70..01aa1de67b 100644 --- a/src/analytics/ccusage.rs +++ b/src/analytics/ccusage.rs @@ -53,6 +53,8 @@ struct DailyResponse { #[derive(Debug, Deserialize)] struct DailyEntry { + // Older ccusage emits "date"; current ccusage emits "period". Accept both. + #[serde(alias = "period")] date: String, #[serde(flatten)] metrics: CcusageMetrics, @@ -65,6 +67,8 @@ struct WeeklyResponse { #[derive(Debug, Deserialize)] struct WeeklyEntry { + // Older ccusage emits "week"; current ccusage emits "period". Accept both. + #[serde(alias = "period")] week: String, // ISO week start (Monday) #[serde(flatten)] metrics: CcusageMetrics, @@ -77,6 +81,8 @@ struct MonthlyResponse { #[derive(Debug, Deserialize)] struct MonthlyEntry { + // Older ccusage emits "month"; current ccusage emits "period". Accept both. + #[serde(alias = "period")] month: String, #[serde(flatten)] metrics: CcusageMetrics, @@ -302,6 +308,71 @@ mod tests { assert!(result.is_err()); // Missing required fields like totalTokens } + #[test] + fn test_parse_monthly_period_key() { + // Current ccusage emits "period" instead of "month" for the record key. + let json = r#"{ + "monthly": [ + { + "period": "2026-01", + "inputTokens": 1000, + "outputTokens": 500, + "totalTokens": 1800, + "totalCost": 12.34 + } + ] + }"#; + + let result = parse_json(json, Granularity::Monthly); + assert!(result.is_ok()); + let periods = result.unwrap(); + assert_eq!(periods.len(), 1); + assert_eq!(periods[0].key, "2026-01"); + assert_eq!(periods[0].metrics.total_cost, 12.34); + } + + #[test] + fn test_parse_daily_period_key() { + let json = r#"{ + "daily": [ + { + "period": "2026-01-30", + "inputTokens": 100, + "outputTokens": 50, + "totalTokens": 150, + "totalCost": 0.15 + } + ] + }"#; + + let result = parse_json(json, Granularity::Daily); + assert!(result.is_ok()); + let periods = result.unwrap(); + assert_eq!(periods.len(), 1); + assert_eq!(periods[0].key, "2026-01-30"); + } + + #[test] + fn test_parse_weekly_period_key() { + let json = r#"{ + "weekly": [ + { + "period": "2026-01-20", + "inputTokens": 500, + "outputTokens": 250, + "totalTokens": 900, + "totalCost": 5.67 + } + ] + }"#; + + let result = parse_json(json, Granularity::Weekly); + assert!(result.is_ok()); + let periods = result.unwrap(); + assert_eq!(periods.len(), 1); + assert_eq!(periods[0].key, "2026-01-20"); + } + #[test] fn test_parse_default_cache_fields() { let json = r#"{ diff --git a/src/analytics/gain.rs b/src/analytics/gain.rs index ac61dd9b56..980c04738f 100644 --- a/src/analytics/gain.rs +++ b/src/analytics/gain.rs @@ -2,7 +2,7 @@ use crate::core::display_helpers::{format_duration, print_period_table}; use crate::core::tracking::{DayStats, MonthStats, Tracker, WeekStats}; -use crate::core::utils::format_tokens; +use crate::core::utils::{format_tokens, truncate}; use crate::hooks::hook_check; use anyhow::{Context, Result}; use chrono::Local; @@ -147,6 +147,18 @@ pub fn run( eprintln!(); } + let untrusted_filters = crate::hooks::trust::untrusted_active_filter_count(); + if untrusted_filters > 0 { + eprintln!( + "{}", + format!( + "[rtk] {untrusted_filters} untrusted custom filter(s) not applied โ€” run `rtk trust`" + ) + .yellow() + ); + eprintln!(); + } + if !summary.by_command.is_empty() { // added: styled section header println!("{}", styled("By Command", true)); @@ -246,11 +258,7 @@ pub fn run( println!("โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€"); for rec in recent { let time = rec.timestamp.with_timezone(&Local).format("%m-%d %H:%M"); - let cmd_short = if rec.rtk_cmd.len() > 25 { - format!("{}...", &rec.rtk_cmd[..22]) - } else { - rec.rtk_cmd.clone() - }; + let cmd_short = truncate(&rec.rtk_cmd, 25); // added: tier indicators by savings level let sign = if rec.savings_pct >= 70.0 { "โ–ฒ" @@ -707,11 +715,7 @@ fn show_failures(tracker: &Tracker) -> Result<()> { println!("{}", styled("Top Commands (by frequency)", true)); println!("{}", "โ”€".repeat(60)); for (cmd, count) in &summary.top_commands { - let cmd_display = if cmd.len() > 50 { - format!("{}...", &cmd[..47]) - } else { - cmd.clone() - }; + let cmd_display = truncate(cmd, 50); println!(" {:>4}x {}", count, cmd_display); } println!(); @@ -721,17 +725,11 @@ fn show_failures(tracker: &Tracker) -> Result<()> { println!("{}", styled("Recent Failures (last 10)", true)); println!("{}", "โ”€".repeat(60)); for rec in &summary.recent { - let ts_short = if rec.timestamp.len() >= 16 { - &rec.timestamp[..16] - } else { - &rec.timestamp - }; + // ISSUE #2787: floor to the previous char boundary so the prefix + // never exceeds 16 bytes and never lands mid-character + let ts_short = &rec.timestamp[..rec.timestamp.floor_char_boundary(16)]; let status = if rec.fallback_succeeded { "ok" } else { "FAIL" }; - let cmd_display = if rec.raw_command.len() > 40 { - format!("{}...", &rec.raw_command[..37]) - } else { - rec.raw_command.clone() - }; + let cmd_display = truncate(&rec.raw_command, 40); println!(" {} [{}] {}", ts_short, status, cmd_display); } println!(); diff --git a/src/analytics/session_cmd.rs b/src/analytics/session_cmd.rs index c36a42c25a..21daa53b36 100644 --- a/src/analytics/session_cmd.rs +++ b/src/analytics/session_cmd.rs @@ -110,7 +110,7 @@ pub fn run(_verbose: u8) -> Result<()> { .file_stem() .and_then(|s| s.to_str()) .unwrap_or("unknown"); - let short_id = if id.len() > 8 { &id[..8] } else { id }; + let short_id = &id[..id.floor_char_boundary(8)]; // Extract date from mtime let date = fs::metadata(path) diff --git a/src/cmds/README.md b/src/cmds/README.md index ad3a6ab77a..31a8e339bc 100644 --- a/src/cmds/README.md +++ b/src/cmds/README.md @@ -2,7 +2,7 @@ ## Scope -**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`. +**Command execution and output filtering.** Every module here calls an external CLI tool (`Command::new("some_tool")`), transforms its stdout/stderr to reduce the bytes the agent reads, and records the reduction 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. @@ -299,7 +299,7 @@ Adding a new filter or command requires changes in multiple places. For TOML-vs- - 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`: `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)) +4. **Write tests** โ€” Real fixture, snapshot test, >= 20% reduction in bash output (measured with RTK's token estimator, see [testing rules](../../.claude/rules/cli-testing.md)) 5. **Update docs** โ€” Ecosystem README (CHANGELOG.md is auto-generated by release-please) ### TOML filter (simple line-based filtering) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index 0b99f62d05..9936a061d8 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -2,12 +2,15 @@ use crate::core::args_utils; use crate::core::guard::never_worse; +use crate::core::runner::{self, RunOptions}; use crate::core::stream::{ self, exec_capture, CaptureResult, FilterMode, LineHandler, LineStreamFilter, StdinMode, }; use crate::core::tracking; -use crate::core::truncate::CAP_WARNINGS; -use crate::core::utils::{exit_code_from_output, exit_code_from_status, resolved_command}; +use crate::core::truncate::{CAP_LIST, CAP_WARNINGS}; +use crate::core::utils::{ + exit_code_from_output, exit_code_from_status, join_with_overflow, resolved_command, strip_ansi, +}; use anyhow::{Context, Result}; use std::ffi::OsString; use std::process::Command; @@ -21,6 +24,7 @@ pub enum GitCommand { Show, Add, Commit, + Checkout, Push, Pull, Branch, @@ -93,6 +97,7 @@ pub fn run( GitCommand::Show => run_show(args, max_lines, verbose, global_args), GitCommand::Add => run_add(args, verbose, global_args), GitCommand::Commit => run_commit(args, verbose, global_args), + GitCommand::Checkout => run_checkout(args, verbose, global_args), GitCommand::Push => run_push(args, verbose, global_args), GitCommand::Pull => run_pull(args, verbose, global_args), GitCommand::Branch => run_branch(args, verbose, global_args), @@ -1078,6 +1083,183 @@ fn classify_commit_outcome(success: bool, stdout: &str, exit_code: i32) -> Commi } } +fn run_checkout(args: &[String], verbose: u8, global_args: &[String]) -> Result { + let args = args_utils::restore_double_dash(args); + + if verbose > 0 { + eprintln!("git checkout"); + } + + let mut cmd = git_cmd(global_args); + cmd.arg("checkout"); + for arg in &args { + cmd.arg(arg); + } + + let args_display = args.join(" "); + let args_for_filter = args.clone(); + runner::run_filtered_with_exit( + cmd, + "git checkout", + &args_display, + move |raw, exit_code| format_checkout_output(&args_for_filter, raw, exit_code), + RunOptions::with_tee("git_checkout"), + ) +} + +fn format_checkout_output(args: &[String], raw: &str, exit_code: i32) -> String { + if exit_code == 0 { + format_checkout_success(args, raw) + } else { + filter_checkout_failure(raw) + } +} + +fn format_checkout_success(args: &[String], raw: &str) -> String { + if let Some(restored) = checkout_restored_count(args) { + return format!("ok {} {}", restored, pluralize(restored, "file restored", "files restored")); + } + if let Some(branch) = checkout_reset_branch_arg(args) { + return format!("ok {}", branch); + } + + for line in raw.lines().map(str::trim) { + if let Some(branch) = quoted_suffix(line, "Switched to a new branch ") { + return format!("ok {} (new)", branch); + } + if let Some(branch) = quoted_suffix(line, "Switched to branch ") { + return format!("ok {}", branch); + } + if let Some(branch) = quoted_suffix(line, "Already on ") { + return format!("ok {}", branch); + } + if let Some(rest) = line.strip_prefix("HEAD is now at ") { + let hash = rest.split_whitespace().next().unwrap_or("HEAD"); + return format!("ok HEAD {}", hash); + } + if line.starts_with("Updated ") && line.contains(" path") { + return format!("ok {}", line.to_ascii_lowercase()); + } + } + + if let Some(branch) = checkout_new_branch_arg(args) { + return format!("ok {} (new)", branch); + } + if let Some(branch) = checkout_branch_arg(args) { + return format!("ok {}", branch); + } + + "ok".to_string() +} + +fn checkout_restored_count(args: &[String]) -> Option { + let separator = args.iter().position(|arg| arg == "--")?; + let count = args[separator + 1..] + .iter() + .filter(|arg| !arg.is_empty()) + .count(); + (count > 0).then_some(count) +} + +fn checkout_new_branch_arg(args: &[String]) -> Option<&str> { + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + match arg.as_str() { + "-b" | "--orphan" => return iter.next().map(String::as_str), + "-B" => { + iter.next(); + } + _ => { + if let Some(branch) = arg.strip_prefix("--orphan=") { + return Some(branch); + } + } + } + } + None +} + +fn checkout_reset_branch_arg(args: &[String]) -> Option<&str> { + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + if arg == "-B" { + return iter.next().map(String::as_str); + } + } + None +} + +fn checkout_branch_arg(args: &[String]) -> Option<&str> { + if args.iter().any(|arg| arg == "--") { + return None; + } + + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + match arg.as_str() { + "-b" | "-B" | "--orphan" => { + iter.next(); + } + "-t" | "--track" | "--detach" => {} + _ if arg.starts_with('-') => {} + _ => return Some(arg), + } + } + None +} + +fn quoted_suffix<'a>(line: &'a str, prefix: &str) -> Option<&'a str> { + line.strip_prefix(prefix) + .and_then(|rest| rest.strip_prefix('\'')) + .and_then(|rest| rest.strip_suffix('\'')) +} + +fn pluralize<'a>(count: usize, singular: &'a str, plural: &'a str) -> &'a str { + if count == 1 { singular } else { plural } +} + +fn filter_checkout_failure(raw: &str) -> String { + let mut important = Vec::new(); + let mut in_file_list = false; + + for line in raw.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + let is_header = trimmed.starts_with("error:") + || trimmed.starts_with("fatal:") + || trimmed.starts_with("CONFLICT"); + + if is_header { + in_file_list = + trimmed.contains("following") && trimmed.contains("files") && trimmed.ends_with(':'); + important.push(trimmed.to_string()); + continue; + } + + if in_file_list { + if trimmed.starts_with("Please ") || trimmed.starts_with("Aborting") { + in_file_list = false; + } else if line.starts_with(char::is_whitespace) { + important.push(line.to_string()); + continue; + } + } + + if trimmed.starts_with("Aborting") { + important.push(trimmed.to_string()); + } + } + + if important.is_empty() { + raw.trim().to_string() + } else { + important.join("\n") + } +} + // Git push progress prefixes (stderr) โ€” dropped from the stream. const GIT_PUSH_NOISE_PREFIXES: &[&str] = &[ "Enumerating objects:", @@ -1563,8 +1745,10 @@ fn run_stash( ); } Some("show") => { + let patch_mode = args.iter().any(|a| a == "-p" || a == "--patch"); + let mut cmd = git_cmd(global_args); - cmd.args(["stash", "show", "-p"]); + cmd.args(["stash", "show"]); for arg in args { cmd.arg(arg); } @@ -1578,15 +1762,13 @@ fn run_stash( return Ok(result.exit_code); } - let compacted = compact_diff(&result.stdout, 100); - let compacted = never_worse(&result.stdout, &compacted).to_string(); - println!("{}", compacted); - timer.track( - "git stash show", - "rtk git stash show", - &result.stdout, - &compacted, - ); + let filtered = if patch_mode { + compact_diff(&result.stdout, 100) + } else { + compact_stash_stat(&result.stdout) + }; + let shown = crate::core::runner::emit_guarded(&filtered, None, &result.stdout); + timer.track("git stash show", "rtk git stash show", &result.stdout, &shown); } Some("apply") | Some("branch") | Some("clear") | Some("create") | Some("drop") | Some("export") | Some("import") | Some("pop") | Some("store") => { @@ -1690,6 +1872,79 @@ fn filter_stash_list(output: &str) -> String { result.join("\n") } +fn compact_stash_stat(raw: &str) -> String { + let (files, summary) = parse_stash_stat(raw); + if files.is_empty() { + return raw.trim_end().to_string(); + } + let total = files.len(); + let mut out = join_with_overflow(&files[..total.min(CAP_LIST)], total, CAP_LIST, "files"); + if total > CAP_LIST { + if let Some(hint) = + crate::core::tee::force_tee_tail_hint(&files.join("\n"), "git-stash-show", CAP_LIST + 1) + { + out.push(' '); + out.push_str(&hint); + } + } + if !summary.is_empty() { + out.push('\n'); + out.push_str(&compress_stat_summary(&summary)); + } + out +} + +fn compress_stat_summary(summary: &str) -> String { + summary + .replace("insertions(+)", "+") + .replace("insertion(+)", "+") + .replace("deletions(-)", "-") + .replace("deletion(-)", "-") + .replace("files changed", "changed") + .replace("file changed", "changed") + .replace(",", "") +} + +fn parse_stash_stat(stat: &str) -> (Vec, String) { + let stat = strip_ansi(stat); + let mut files = Vec::new(); + let mut summary = String::new(); + + for line in stat.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + match diffstat_row(line) { + Some(row) => files.push(row), + None => summary = line.to_string(), + } + } + + (files, summary) +} + +fn diffstat_row(line: &str) -> Option { + let bar = line.rfind('|')?; + let path = line[..bar].trim(); + let rhs = line[bar + 1..].trim(); + let is_diffstat_row = rhs.starts_with("Bin") || rhs.starts_with(|c: char| c.is_ascii_digit()); + if path.is_empty() || !is_diffstat_row { + return None; + } + if rhs.starts_with("Bin") { + return Some(format!("{} (binary)", path)); + } + let count = rhs.split_whitespace().next().unwrap_or(""); + let sign = match (rhs.contains('+'), rhs.contains('-')) { + (true, true) => " +-", + (true, false) => " +", + (false, true) => " -", + (false, false) => "", + }; + Some(format!("{} {}{}", path, count, sign)) +} + fn run_worktree(args: &[String], verbose: u8, global_args: &[String]) -> Result { let timer = tracking::TimedExecution::start(); @@ -2096,6 +2351,104 @@ mod tests { assert!(result.contains("stash@{1}: def5678 wip")); } + #[test] + fn test_parse_stash_stat_strips_decorations() { + let raw = " del.md | 2 --\n keep.md | 5 ++++-\n logo.bin | Bin 0 -> 1024 bytes\n \ + new.rs | 40 ++++++++\n 4 files changed, 44 insertions(+), 3 deletions(-)\n"; + let (files, summary) = parse_stash_stat(raw); + assert_eq!( + files, + vec!["del.md 2 -", "keep.md 5 +-", "logo.bin (binary)", "new.rs 40 +"] + ); + assert_eq!(summary, "4 files changed, 44 insertions(+), 3 deletions(-)"); + } + + #[test] + fn test_parse_stash_stat_collapsed_bar() { + let (files, _) = parse_stash_stat(" .claude/CLAUDE.md | 234 +-\n"); + assert_eq!(files, vec![".claude/CLAUDE.md 234 +-"]); + } + + #[test] + fn test_compact_stash_stat_passthrough_numstat() { + let raw = "0\t1\tdel.md\n3\t2\tkeep.md\n1\t0\tn1.rs\n"; + assert_eq!(compact_stash_stat(raw), "0\t1\tdel.md\n3\t2\tkeep.md\n1\t0\tn1.rs"); + } + + #[test] + fn test_compact_stash_stat_passthrough_name_only() { + let raw = "del.md\nkeep.md\nn1.rs\n"; + assert_eq!(compact_stash_stat(raw), "del.md\nkeep.md\nn1.rs"); + } + + #[test] + fn test_compress_stat_summary_variants() { + assert_eq!( + compress_stat_summary("4 files changed, 60 insertions(+), 313 deletions(-)"), + "4 changed 60 + 313 -" + ); + assert_eq!( + compress_stat_summary("1 file changed, 1 insertion(+)"), + "1 changed 1 +" + ); + assert_eq!( + compress_stat_summary("1 file changed, 1 deletion(-)"), + "1 changed 1 -" + ); + assert_eq!( + compress_stat_summary("2 files changed, 4 insertions(+), 1 deletion(-)"), + "2 changed 4 + 1 -" + ); + } + + #[test] + fn test_compact_stash_stat_compresses_summary() { + let raw = " a.txt | 2 ++\n 1 file changed, 2 insertions(+)\n"; + assert_eq!(compact_stash_stat(raw), "a.txt 2 +\n1 changed 2 +"); + } + + #[test] + fn test_parse_stash_stat_last_pipe_is_separator() { + let (files, _) = parse_stash_stat(" weird|name.txt | 3 +++\n"); + assert_eq!(files, vec!["weird|name.txt 3 +"]); + } + + #[test] + fn test_parse_stash_stat_strips_ansi() { + let (files, _) = parse_stash_stat(" a.txt | 2 \x1b[32m++\x1b[m\n"); + assert_eq!(files, vec!["a.txt 2 +"]); + } + + #[test] + fn test_parse_stash_stat_empty() { + let (files, summary) = parse_stash_stat(""); + assert!(files.is_empty()); + assert!(summary.is_empty()); + } + + #[test] + fn test_parse_stash_stat_unicode_and_malformed_never_panic() { + let _ = parse_stash_stat("not a diffstat at all"); + let _ = parse_stash_stat("| | |"); + let (files, _) = parse_stash_stat(" ๆ—ฅๆœฌ่ชž.md | 5 +++--\n"); + assert_eq!(files, vec!["ๆ—ฅๆœฌ่ชž.md 5 +-"]); + } + + #[test] + fn test_parse_stash_stat_savings() { + use crate::core::tracking::estimate_tokens; + let raw = " CONTRIBUTING.md | 305 \ + ----------------------------------------------------------\n \ + README.md | 28 ++++--\n logo.bin | Bin 0 -> 2048 bytes\n \ + newfeature.rs | 40 ++++++++\n \ + 4 files changed, 60 insertions(+), 313 deletions(-)\n"; + let (files, summary) = parse_stash_stat(raw); + let compact = format!("{}\n{}", files.join("\n"), summary); + let savings = + 100.0 - (estimate_tokens(&compact) as f64 / estimate_tokens(raw) as f64 * 100.0); + assert!(savings >= 40.0, "expected >=40% savings, got {:.1}%", savings); + } + #[test] fn test_run_stash_list_propagates_failure() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/src/cmds/mod.rs b/src/cmds/mod.rs index d064182b3d..1198aacf34 100644 --- a/src/cmds/mod.rs +++ b/src/cmds/mod.rs @@ -6,7 +6,9 @@ pub mod git; pub mod go; pub mod js; pub mod jvm; +pub mod php; pub mod python; pub mod ruby; pub mod rust; +pub mod scala; pub mod system; diff --git a/src/cmds/php/README.md b/src/cmds/php/README.md new file mode 100644 index 0000000000..003de14204 --- /dev/null +++ b/src/cmds/php/README.md @@ -0,0 +1,15 @@ +# PHP + +> Part of [`src/cmds/`](../README.md) โ€” see also [docs/contributing/TECHNICAL.md](../../../docs/contributing/TECHNICAL.md) + +## Specifics + +- `php_cmd.rs` summarizes `php -l` syntax checks and routes `php artisan*` to specialized helpers +- `artisan_cmd.rs` cleans Artisan output and applies runner-aware filtering for `php artisan test` +- `phpunit_cmd.rs` strips progress/header noise and keeps failure details + final summary +- `phpstan_cmd.rs` injects JSON output by default and emits compact file/line error summaries +- `ecs_cmd.rs` condenses EasyCodingStandard output while preserving file paths and error lines +- `pest_cmd.rs` runs Pest with compact progress suppression and test-focused output +- `paratest_cmd.rs` runs ParaTest with compact progress suppression and test-focused output +- `test_output.rs` provides shared PHPUnit/Pest/ParaTest output filtering logic +- `utils.rs` resolves local `vendor/bin/*` tools first, then falls back to global binaries diff --git a/src/cmds/php/artisan_cmd.rs b/src/cmds/php/artisan_cmd.rs new file mode 100644 index 0000000000..88543515d1 --- /dev/null +++ b/src/cmds/php/artisan_cmd.rs @@ -0,0 +1,54 @@ +//! Laravel Artisan output cleanup helpers. + +use super::test_output::filter_test_runner_output; +use super::utils::{strip_ansi_and_controls, PhpTestRunner}; +use lazy_static::lazy_static; +use regex::Regex; + +lazy_static! { + static ref BOX_CHARS_RE: Regex = + Regex::new(r"[\u{2500}-\u{257F}\u{2580}-\u{259F}\u{25A0}-\u{25FF}\u{27A0}-\u{27BF}]+") + .unwrap(); + static ref DOTS_RE: Regex = Regex::new(r"\.{3,}").unwrap(); + static ref MULTI_SPACE_RE: Regex = Regex::new(r"[ \t]{2,}").unwrap(); + static ref MULTI_BLANK_RE: Regex = Regex::new(r"\n{3,}").unwrap(); +} + +pub fn filter_artisan_output(output: &str) -> String { + let mut cleaned = strip_ansi_and_controls(output); + cleaned = BOX_CHARS_RE.replace_all(&cleaned, "").to_string(); + cleaned = DOTS_RE.replace_all(&cleaned, "..").to_string(); + cleaned = MULTI_SPACE_RE.replace_all(&cleaned, " ").to_string(); + cleaned = MULTI_BLANK_RE.replace_all(&cleaned, "\n\n").to_string(); + cleaned.trim().to_string() +} + +pub fn filter_artisan_test_output(output: &str, runner: PhpTestRunner) -> String { + match runner { + PhpTestRunner::Pest | PhpTestRunner::Phpunit => filter_test_runner_output(output), + PhpTestRunner::Unknown => filter_artisan_output(output), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_artisan_cleanup() { + let out = + "\u{1b}[32mEnvironment .....\u{1b}[0m\n\u{2502} Laravel Version \u{2502} 13.0.0 \u{2502}\n\n\n"; + let filtered = filter_artisan_output(out); + assert!(!filtered.contains('\u{1b}')); + assert!(!filtered.contains('\u{2502}')); + assert!(filtered.contains("Environment ..")); + } + + #[test] + fn test_artisan_test_prefers_runner_filter() { + let output = "PHPUnit 12.2.0\n....\nOK (4 tests, 4 assertions)\n"; + let filtered = filter_artisan_test_output(output, PhpTestRunner::Phpunit); + assert!(!filtered.contains("PHPUnit 12.2.0")); + assert!(filtered.contains("OK (4 tests, 4 assertions)")); + } +} diff --git a/src/cmds/php/ecs_cmd.rs b/src/cmds/php/ecs_cmd.rs new file mode 100644 index 0000000000..8b02fb71e4 --- /dev/null +++ b/src/cmds/php/ecs_cmd.rs @@ -0,0 +1,81 @@ +//! EasyCodingStandard output filter. + +use super::utils::{php_tool_command, strip_ansi_and_controls}; +use crate::core::runner; +use anyhow::Result; + +pub fn run(args: &[String], verbose: u8) -> Result { + let mut cmd = php_tool_command("ecs"); + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: ecs {}", args.join(" ")); + } + + runner::run_filtered( + cmd, + "ecs", + &args.join(" "), + filter_ecs_output, + runner::RunOptions::default(), + ) +} + +pub(crate) fn filter_ecs_output(output: &str) -> String { + let cleaned = strip_ansi_and_controls(output); + if cleaned.contains("No errors found") { + return "โœ“ ecs: no issues".to_string(); + } + + let mut lines = Vec::new(); + for line in cleaned.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + if trimmed.contains(".php") + || trimmed.contains("ERROR") + || trimmed.contains("FAIL") + || trimmed.contains("Fixed") + || trimmed.contains("checked") + || trimmed.contains("files") + { + lines.push(trimmed.to_string()); + } + } + + if lines.is_empty() { + let fallback = cleaned.trim(); + if fallback.is_empty() { + "ok".to_string() + } else { + fallback.to_string() + } + } else { + lines.join("\n") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ecs_success_output() { + assert_eq!( + filter_ecs_output("[OK] No errors found. Great job!"), + "โœ“ ecs: no issues" + ); + } + + #[test] + fn test_ecs_keeps_file_errors() { + let output = "src/Foo.php\n 10 | ERROR | Something bad\n"; + let filtered = filter_ecs_output(output); + assert!(filtered.contains("src/Foo.php")); + assert!(filtered.contains("ERROR")); + } +} diff --git a/src/cmds/php/mod.rs b/src/cmds/php/mod.rs new file mode 100644 index 0000000000..90c027f68a --- /dev/null +++ b/src/cmds/php/mod.rs @@ -0,0 +1 @@ +automod::dir!(pub "src/cmds/php"); diff --git a/src/cmds/php/paratest_cmd.rs b/src/cmds/php/paratest_cmd.rs new file mode 100644 index 0000000000..a32c614e79 --- /dev/null +++ b/src/cmds/php/paratest_cmd.rs @@ -0,0 +1,108 @@ +//! ParaTest runner filter. + +use super::test_output::filter_test_runner_output; +use super::utils::php_tool_command; +use crate::core::runner; +use anyhow::Result; + +pub fn run(args: &[String], verbose: u8) -> Result { + let mut cmd = php_tool_command("paratest"); + + let has_no_progress = args.iter().any(|a| a == "--no-progress"); + if !has_no_progress { + cmd.arg("--no-progress"); + } + + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: paratest {}", args.join(" ")); + } + + runner::run_filtered( + cmd, + "paratest", + &args.join(" "), + filter_test_runner_output, + runner::RunOptions::default(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_paratest_strips_banner_seed_and_progress() { + // ParaTest prints its own banner and a "Random Seed:" line on top of + // PHPUnit-style dot progress; only the result summary should survive. + let output = "ParaTest v7.3.0 upon PHPUnit 10.5.0 by Sebastian Bergmann and contributors.\n\ + Random Seed: 1234567890\n\ + .......... 10 / 10 (100%)\n\n\ + OK (10 tests, 25 assertions)\n"; + let filtered = filter_test_runner_output(output); + assert!(!filtered.contains("ParaTest v7.3.0"), "got: {}", filtered); + assert!(!filtered.contains("Random Seed:"), "got: {}", filtered); + assert!(!filtered.contains("10 / 10 (100%)"), "got: {}", filtered); + assert!( + filtered.contains("OK (10 tests, 25 assertions)"), + "got: {}", + filtered + ); + } + + #[test] + fn test_paratest_keeps_failures() { + let output = "ParaTest v7.3.0 upon PHPUnit 10.5.0\n\ + ..F.\n\ + There was 1 failure:\n\ + 1) App\\Tests\\UserTest::testEmail\n\ + Failed asserting that false is true.\n"; + let filtered = filter_test_runner_output(output); + assert!(!filtered.contains("ParaTest v7.3.0"), "got: {}", filtered); + assert!( + filtered.contains("App\\Tests\\UserTest::testEmail"), + "got: {}", + filtered + ); + assert!( + filtered.contains("Failed asserting that false is true."), + "got: {}", + filtered + ); + } + + #[test] + fn test_paratest_token_savings() { + use crate::core::utils::count_tokens; + + // A realistic passing run: banner + config + many parallel-worker + // progress lines, collapsing to a one-line summary. + let mut output = String::from( + "ParaTest v7.3.0 upon PHPUnit 10.5.0 by Sebastian Bergmann and contributors.\n\ + Runtime: PHP 8.3.10\n\ + Configuration: /var/www/html/phpunit.xml\n\ + Random Seed: 1234567890\n\n", + ); + for i in 1..=40 { + output.push_str(&format!( + ".......................................... {} / 40 ({}%)\n", + i, + i * 100 / 40 + )); + } + output.push_str("\nOK (400 tests, 1200 assertions)\n"); + + let filtered = filter_test_runner_output(&output); + let savings = + 100.0 - (count_tokens(&filtered) as f64 / count_tokens(&output) as f64 * 100.0); + assert!( + savings >= 60.0, + "expected โ‰ฅ60% savings, got {:.1}%\n{}", + savings, + filtered + ); + } +} diff --git a/src/cmds/php/pest_cmd.rs b/src/cmds/php/pest_cmd.rs new file mode 100644 index 0000000000..559beb1676 --- /dev/null +++ b/src/cmds/php/pest_cmd.rs @@ -0,0 +1,45 @@ +//! Pest test runner filter. + +use super::test_output::filter_test_runner_output; +use super::utils::php_tool_command; +use crate::core::runner; +use anyhow::Result; + +pub fn run(args: &[String], verbose: u8) -> Result { + let mut cmd = php_tool_command("pest"); + + let has_no_progress = args.iter().any(|a| a == "--no-progress"); + if !has_no_progress { + cmd.arg("--no-progress"); + } + + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: pest {}", args.join(" ")); + } + + runner::run_filtered( + cmd, + "pest", + &args.join(" "), + filter_test_runner_output, + runner::RunOptions::default(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pest_filters_progress_noise() { + let output = "Pest 5.0.0\n.....\nPASS Tests\\Unit\\ExampleTest\n"; + let filtered = filter_test_runner_output(output); + assert!(!filtered.contains("Pest 5.0.0")); + assert!(!filtered.contains(".....")); + assert!(filtered.contains("PASS Tests\\Unit\\ExampleTest")); + } +} diff --git a/src/cmds/php/php_cmd.rs b/src/cmds/php/php_cmd.rs new file mode 100644 index 0000000000..3bbca99427 --- /dev/null +++ b/src/cmds/php/php_cmd.rs @@ -0,0 +1,114 @@ +//! PHP command filter: syntax-check summaries and generic cleanup. + +use super::artisan_cmd::{filter_artisan_output, filter_artisan_test_output}; +use super::utils::{detect_php_test_runner, strip_ansi_and_controls, PhpTestRunner}; +use crate::core::runner; +use crate::core::utils::resolved_command; +use anyhow::Result; + +pub fn run(args: &[String], verbose: u8) -> Result { + let mut cmd = resolved_command("php"); + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: php {}", args.join(" ")); + } + + let is_artisan = args.first().map(String::as_str) == Some("artisan"); + let is_artisan_test = is_artisan && args.get(1).map(String::as_str) == Some("test"); + let is_lint = args.iter().any(|a| a == "-l" || a == "--syntax-check"); + let detected_runner = if is_artisan_test { + detect_php_test_runner() + } else { + PhpTestRunner::Unknown + }; + + if verbose > 0 && is_artisan_test { + eprintln!("Detected artisan test runner: {:?}", detected_runner); + } + + runner::run_filtered( + cmd, + "php", + &args.join(" "), + move |raw| { + if is_lint { + return filter_php_lint_output(raw); + } + if is_artisan_test { + return filter_artisan_test_output(raw, detected_runner); + } + if is_artisan { + return filter_artisan_output(raw); + } + filter_php_output(raw) + }, + runner::RunOptions::default(), + ) +} + +fn filter_php_lint_output(output: &str) -> String { + let mut lines = Vec::new(); + + for line in strip_ansi_and_controls(output).lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + if let Some(file) = trimmed.strip_prefix("No syntax errors detected in ") { + lines.push(format!("ok {}", file.trim())); + continue; + } + + if trimmed.starts_with("Errors parsing ") + || trimmed.contains("Parse error") + || trimmed.contains("Fatal error") + || trimmed.contains("syntax error") + { + lines.push(trimmed.to_string()); + } + } + + if lines.is_empty() { + let fallback = output.trim(); + if fallback.is_empty() { + "ok".to_string() + } else { + fallback.to_string() + } + } else { + lines.join("\n") + } +} + +fn filter_php_output(output: &str) -> String { + let cleaned = strip_ansi_and_controls(output); + let trimmed = cleaned.trim(); + if trimmed.is_empty() { + "ok".to_string() + } else { + trimmed.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_php_lint_summary() { + let out = "No syntax errors detected in app/Http/Controller.php\n"; + assert_eq!(filter_php_lint_output(out), "ok app/Http/Controller.php"); + } + + #[test] + fn test_php_lint_error_preserved() { + let out = "Errors parsing app/Foo.php\nParse error: syntax error, unexpected ')' in app/Foo.php on line 10\n"; + let filtered = filter_php_lint_output(out); + assert!(filtered.contains("Errors parsing app/Foo.php")); + assert!(filtered.contains("Parse error")); + } +} diff --git a/src/cmds/php/phpstan_cmd.rs b/src/cmds/php/phpstan_cmd.rs new file mode 100644 index 0000000000..e3c1547bc6 --- /dev/null +++ b/src/cmds/php/phpstan_cmd.rs @@ -0,0 +1,568 @@ +//! PHPStan static analysis filter. +//! +//! Injects `--error-format=json` for structured output, parses errors grouped by +//! file and sorted by error count. Falls back to text parsing when the user +//! specifies a custom format or when injected JSON output fails to parse. + +use super::utils::php_tool_command; +use crate::core::runner; +use crate::core::utils::exit_code_from_status; +use anyhow::{Context, Result}; +use serde::Deserialize; +use std::collections::HashMap; + +// โ”€โ”€ JSON structures matching PHPStan's --error-format=json output โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +#[derive(Deserialize)] +struct PhpstanOutput { + totals: PhpstanTotals, + files: HashMap, + #[serde(default)] + errors: Vec, +} + +#[derive(Deserialize)] +struct PhpstanTotals { + // `errors` counts only non-file-specific (global/config) errors; per-file + // errors live in `file_errors`. Gating "ok" on `errors` alone hides real + // failures, since a normal failing run reports errors=0, file_errors=N. + errors: usize, + file_errors: usize, +} + +#[derive(Deserialize)] +struct PhpstanFile { + errors: usize, + messages: Vec, +} + +#[derive(Deserialize)] +struct PhpstanMessage { + message: String, + line: usize, + #[serde(default)] + #[allow(dead_code)] + ignorable: bool, +} + +// โ”€โ”€ Public entry point โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +pub fn run(args: &[String], verbose: u8) -> Result { + // Composer-aware resolution (COMPOSER_BIN_DIR / config.bin-dir), matching + // the other php tools, with PATH fallback for a global install. + let mut cmd = php_tool_command("phpstan"); + + // Utility commands (--version, list, clear-result-cache, worker, โ€ฆ): real passthrough. + // Only analyse/analyze subcommands get filtered and token-tracked. + let is_analyse = is_analyse_command(args); + + if !is_analyse { + if verbose > 0 { + eprintln!("Running: phpstan {} (passthrough)", args.join(" ")); + } + cmd.args(args); + let status = cmd.status().context("Failed to run phpstan")?; + return Ok(exit_code_from_status(&status, "phpstan")); + } + + // Detect whether the user already chose an output format. When they passed + // `--error-format=json` we must NOT append a second one; only inject json + // when no format is present. + let fmt = detect_error_format(args); + + cmd.args(args); + if matches!(fmt, ErrorFormat::Unspecified) { + cmd.arg("--error-format").arg("json"); + } + + if verbose > 0 { + eprintln!("Running: phpstan {}", args.join(" ")); + } + + let use_text = matches!(fmt, ErrorFormat::Custom); + runner::run_filtered( + cmd, + "phpstan", + &args.join(" "), + move |stdout| { + if use_text { + filter_phpstan_text(stdout) + } else { + filter_phpstan_json(stdout) + } + }, + runner::RunOptions::stdout_only().tee("phpstan"), + ) +} + +/// True when the invocation runs the `analyse`/`analyze` subcommand. The +/// subcommand may appear after global options (`phpstan -c x.neon analyse src/`), +/// so scan every arg rather than just the first. +fn is_analyse_command(args: &[String]) -> bool { + args.iter().any(|a| a == "analyse" || a == "analyze") +} + +/// Which `--error-format` the user requested, if any. +enum ErrorFormat { + /// No format flag โ€” rtk injects `--error-format=json`. + Unspecified, + /// User explicitly asked for json โ€” keep it, don't duplicate the flag. + Json, + /// User asked for a non-json format โ€” leave args alone, use the text filter. + Custom, +} + +/// Parse `--error-format=` / `--error-format ` from the args. +fn detect_error_format(args: &[String]) -> ErrorFormat { + let mut it = args.iter().peekable(); + while let Some(a) = it.next() { + if a == "--error-format" { + return match it.peek().map(|v| v.as_str()) { + Some("json") => ErrorFormat::Json, + _ => ErrorFormat::Custom, + }; + } + if let Some(val) = a.strip_prefix("--error-format=") { + return if val == "json" { + ErrorFormat::Json + } else { + ErrorFormat::Custom + }; + } + } + ErrorFormat::Unspecified +} + +// โ”€โ”€ JSON filtering โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +pub(crate) fn filter_phpstan_json(output: &str) -> String { + if output.trim().is_empty() { + return "PHPStan: No output".to_string(); + } + + let parsed: Result = serde_json::from_str(output); + let phpstan = match parsed { + Ok(p) => p, + Err(e) => { + eprintln!("[rtk] phpstan: JSON parse failed ({})", e); + return crate::core::utils::fallback_tail(output, "phpstan (JSON parse error)", 5); + } + }; + + // No errors case: both file-specific and global error counts must be zero. + if phpstan.totals.file_errors == 0 && phpstan.totals.errors == 0 { + return "phpstan: ok".to_string(); + } + + let mut result = format!( + "phpstan: {} errors in {} files\n", + phpstan.totals.file_errors, + phpstan.files.len() + ); + + // Add global errors first if any + if !phpstan.errors.is_empty() { + result.push_str("\nGlobal errors:\n"); + for error in &phpstan.errors { + result.push_str(&format!(" {}\n", error)); + } + result.push('\n'); + } + + // Build list of files with errors, sorted by error count descending + let mut files_vec: Vec<(&String, &PhpstanFile)> = phpstan.files.iter().collect(); + files_vec.sort_by(|a, b| b.1.errors.cmp(&a.1.errors).then(a.0.cmp(b.0))); + + let max_files = 10; + let max_messages_per_file = 5; + + for (path, file) in files_vec.iter().take(max_files) { + let short = compact_php_path(path); + result.push_str(&format!("\n{} ({} errors)\n", short, file.errors)); + + for message in file.messages.iter().take(max_messages_per_file) { + let first_line = message.message.lines().next().unwrap_or(""); + result.push_str(&format!(" :{} {}\n", message.line, first_line)); + } + + if file.messages.len() > max_messages_per_file { + result.push_str(&format!( + " ... +{} more\n", + file.messages.len() - max_messages_per_file + )); + } + } + + if files_vec.len() > max_files { + result.push_str(&format!( + "\n... +{} more files\n", + files_vec.len() - max_files + )); + } + + result.trim().to_string() +} + +// โ”€โ”€ Text fallback โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +pub(crate) fn filter_phpstan_text(output: &str) -> String { + // The text path matches on substrings ("[OK]", "Found N errors"); strip + // ANSI first so `--ansi` colorized output still matches. + let cleaned = super::utils::strip_ansi_and_controls(output); + let output = cleaned.as_str(); + + // Check for errors first + for line in output.lines() { + let t = line.trim(); + // Shell-level "binary missing" lines only. A substring match on + // "not found" would swallow real analysis output ("Class X not found"). + if t.starts_with("phpstan: command not found") + || t.starts_with("phpstan: No such file") + || t.starts_with("sh: ") + || t.starts_with("bash: ") + { + let error_lines: Vec<&str> = output.trim().lines().take(20).collect(); + let truncated = error_lines.join("\n"); + let total_lines = output.trim().lines().count(); + if total_lines > 20 { + return format!( + "PHPStan error:\n{}\n... ({} more lines)", + truncated, + total_lines - 20 + ); + } + return format!("PHPStan error:\n{}", truncated); + } + } + + // Extract summary if present. Match case-insensitively: phpstan prints + // "[ERROR] Found N errors" / "[OK] No errors" with varying capitalization. + for line in output.lines().rev() { + let t = line.trim(); + let lower = t.to_lowercase(); + if lower.contains("[ok]") || lower.contains("no errors") { + return "phpstan: ok".to_string(); + } + if lower.contains("error") && (lower.contains("found") || lower.contains("in")) { + return format!("PHPStan: {}", t); + } + } + + // Last resort: last 20 lines + crate::core::utils::fallback_tail(output, "phpstan", 20) +} + +/// Compact a PHP file path to its last two components (`dir/File.php`), +/// which is enough context across frameworks without a per-convention list. +fn compact_php_path(path: &str) -> String { + let path = path.replace('\\', "/"); + + if let Some(pos) = path.rfind('/') { + if let Some(prev) = path[..pos].rfind('/') { + return path[prev + 1..].to_string(); + } + return path[pos + 1..].to_string(); + } + path +} + +// โ”€โ”€ Tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::utils::count_tokens; + + fn no_errors_json() -> &'static str { + r#"{ + "totals": {"errors": 0, "file_errors": 0}, + "files": {}, + "errors": [] + }"# + } + + fn with_errors_json() -> &'static str { + r#"{ + "totals": {"errors": 5, "file_errors": 5}, + "files": { + "app/Models/User.php": { + "errors": 2, + "messages": [ + {"message": "Property $id does not accept null.", "line": 10, "ignorable": true}, + {"message": "Call to undefined method Model::find().", "line": 25, "ignorable": false} + ] + }, + "app/Http/Controllers/UserController.php": { + "errors": 2, + "messages": [ + {"message": "Parameter $id of anonymous function has no typehint.", "line": 45, "ignorable": false}, + {"message": "Variable $user might not be defined.", "line": 67, "ignorable": false} + ] + }, + "app/Services/AuthService.php": { + "errors": 1, + "messages": [ + {"message": "Return type missing.", "line": 12, "ignorable": false} + ] + } + }, + "errors": [] + }"# + } + + fn large_json_for_truncation() -> String { + let mut files = HashMap::new(); + + // Create 12 files with varying error counts + for i in 1..=12 { + let filename = format!("app/Models/Model{}.php", i); + let error_count = if i <= 3 { 10 } else { i % 5 + 1 }; + + let mut messages = Vec::new(); + for j in 1..=error_count { + messages.push(format!( + r#"{{"message": "Error {} in file {}", "line": {}, "ignorable": false}}"#, + j, i, j * 10 + )); + } + + files.insert( + filename, + format!( + r#"{{"errors": {}, "messages": [{}]}}"#, + error_count, + messages.join(",") + ), + ); + } + + let files_json: Vec = files + .iter() + .map(|(k, v)| format!(r#""{}": {}"#, k, v)) + .collect(); + + format!( + r#"{{"totals": {{"errors": 50, "file_errors": 50}}, "files": {{{}}}, "errors": []}}"#, + files_json.join(",") + ) + } + + #[test] + fn test_filter_phpstan_json_no_errors() { + let result = filter_phpstan_json(no_errors_json()); + assert_eq!(result, "phpstan: ok"); + } + + #[test] + fn test_filter_phpstan_file_errors_not_hidden() { + // Real failing runs report errors=0 (no global errors) with the count + // in file_errors. Gating "ok" on `errors` alone silently hid failures. + let json = r#"{ + "totals": {"errors": 0, "file_errors": 2}, + "files": { + "app/Models/User.php": { + "errors": 2, + "messages": [ + {"message": "Property $id does not accept null.", "line": 10, "ignorable": true}, + {"message": "Call to undefined method.", "line": 25, "ignorable": false} + ] + } + }, + "errors": [] + }"#; + let result = filter_phpstan_json(json); + assert!(result.starts_with("phpstan: 2 errors in 1 files"), "got: {}", result); + assert!(result.contains("Models/User.php (2 errors)"), "got: {}", result); + } + + #[test] + fn test_filter_phpstan_json_with_errors() { + let result = filter_phpstan_json(with_errors_json()); + + // Check summary line + assert!(result.contains("5 errors in 3 files")); + + // Check file names are present (compacted to last two components) + assert!(result.contains("Models/User.php")); + assert!(result.contains("Controllers/UserController.php")); + assert!(result.contains("Services/AuthService.php")); + + // Check line numbers and messages + assert!(result.contains(":10 Property $id does not accept null.")); + assert!(result.contains(":25 Call to undefined method Model::find().")); + assert!(result.contains(":45 Parameter $id of anonymous function has no typehint.")); + } + + #[test] + fn test_filter_phpstan_json_truncation() { + let result = filter_phpstan_json(&large_json_for_truncation()); + + // Should show max 10 files + assert!(result.contains("+2 more files")); + + // Should not show all 12 files inline (paths compacted to last 2 components) + let file_count = result.matches("Models/Model").count(); + assert_eq!(file_count, 10, "Should show exactly 10 files"); + } + + #[test] + fn test_filter_phpstan_token_savings() { + // Use the realistic fixture with many files, long paths, and JSON metadata + // to verify the โ‰ฅ75% savings claim in rules.rs + let input = include_str!("../../../tests/fixtures/phpstan_raw.json"); + let output = filter_phpstan_json(input); + + let input_tokens = count_tokens(input); + let output_tokens = count_tokens(&output); + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + + assert!( + savings >= 60.0, + "PHPStan: expected โ‰ฅ60% savings, got {:.1}% (in={}, out={})", + savings, + input_tokens, + output_tokens + ); + } + + #[test] + fn test_filter_phpstan_empty_input() { + let result = filter_phpstan_json(""); + assert_eq!(result, "PHPStan: No output"); + } + + #[test] + fn test_filter_phpstan_malformed_json() { + let garbage = "some php warning\n{broken json"; + let result = filter_phpstan_json(garbage); + assert!(!result.is_empty(), "should not panic on invalid JSON"); + } + + #[test] + fn test_compact_php_path() { + assert_eq!( + compact_php_path("/var/www/project/app/Models/User.php"), + "Models/User.php" + ); + assert_eq!( + compact_php_path("app/Http/Controllers/UserController.php"), + "Controllers/UserController.php" + ); + assert_eq!( + compact_php_path("/home/user/project/src/Service.php"), + "src/Service.php" + ); + assert_eq!( + compact_php_path("tests/Unit/UserTest.php"), + "Unit/UserTest.php" + ); + } + + #[test] + fn test_filter_phpstan_text_fallback() { + let text = r#"PHPStan analysis complete +[OK] No errors found"#; + let result = filter_phpstan_text(text); + assert_eq!(result, "phpstan: ok"); + } + + #[test] + fn test_filter_phpstan_text_with_errors() { + let text = r#"PHPStan analysis complete + +Found 5 errors in 3 files"#; + let result = filter_phpstan_text(text); + assert!(result.starts_with("PHPStan:"), "should have PHPStan: prefix"); + assert!(result.contains("5 errors"), "should contain error count"); + assert!(result.contains("3 files"), "should contain file count"); + } + + #[test] + fn test_filter_phpstan_text_not_found_in_analysis_not_swallowed() { + // "not found" appears in real analysis messages. The text fallback must + // report the summary, not mistake it for a missing-binary shell error. + let text = r#"Note: Using configuration file phpstan.neon. + + ------ ----------------------------------------------- + Line app/Foo.php + ------ ----------------------------------------------- + 10 Class 'NotFoundHttpException' not found. + 25 Method 'findById' not found in class 'UserRepository'. + ------ ----------------------------------------------- + + [ERROR] Found 2 errors +"#; + let result = filter_phpstan_text(text); + assert_eq!(result, "PHPStan: [ERROR] Found 2 errors"); + } + + fn args(s: &[&str]) -> Vec { + s.iter().map(|a| a.to_string()).collect() + } + + #[test] + fn test_is_analyse_detects_subcommand_after_global_flag() { + // Regression: `phpstan -c phpstan.neon analyse src/` must be filtered. + assert!(is_analyse_command(&args(&[ + "-c", + "phpstan.neon", + "analyse", + "src/" + ]))); + assert!(is_analyse_command(&args(&["analyze", "src/"]))); + assert!(!is_analyse_command(&args(&["--version"]))); + assert!(!is_analyse_command(&args(&["clear-result-cache"]))); + } + + #[test] + fn test_detect_error_format() { + assert!(matches!( + detect_error_format(&args(&["analyse", "src/"])), + ErrorFormat::Unspecified + )); + assert!(matches!( + detect_error_format(&args(&["analyse", "--error-format", "json"])), + ErrorFormat::Json + )); + assert!(matches!( + detect_error_format(&args(&["analyse", "--error-format=json"])), + ErrorFormat::Json + )); + assert!(matches!( + detect_error_format(&args(&["analyse", "--error-format", "table"])), + ErrorFormat::Custom + )); + assert!(matches!( + detect_error_format(&args(&["analyse", "--error-format=table"])), + ErrorFormat::Custom + )); + } + + #[test] + fn test_filter_phpstan_text_strips_ansi() { + let text = "\x1b[32m [OK] No errors\x1b[0m"; + assert_eq!(filter_phpstan_text(text), "phpstan: ok"); + } + + #[test] + fn test_filter_phpstan_text_error_summary_case_insensitive() { + // phpstan prints "[ERROR] Found N errors" โ€” capital F and no " in ", + // so the summary must be matched case-insensitively (regression guard). + let text = " ------\n 42 problem\n ------\n\n [ERROR] Found 2 errors\n"; + let result = filter_phpstan_text(text); + assert_eq!(result, "PHPStan: [ERROR] Found 2 errors"); + } + + #[test] + fn test_filter_phpstan_fixture_structure() { + // Verify output structure on the realistic fixture (14 files, 47 errors) + let input = include_str!("../../../tests/fixtures/phpstan_raw.json"); + let output = filter_phpstan_json(input); + + assert!(output.contains("47 errors in 14 files")); + // Files are sorted by error count descending โ€” User.php has 6, comes first + assert!(output.contains("Models/User.php (6 errors)")); + // 14 files โ†’ only 10 shown, 4 more + assert!(output.contains("+4 more files")); + } +} diff --git a/src/cmds/php/phpunit_cmd.rs b/src/cmds/php/phpunit_cmd.rs new file mode 100644 index 0000000000..ff7cf30c11 --- /dev/null +++ b/src/cmds/php/phpunit_cmd.rs @@ -0,0 +1,396 @@ +//! PHPUnit output filter. +//! +//! Parses PHPUnit's plain-text runner output and emits a compact summary: +//! aggregate counts from the `Tests: X, Assertions: Y, Failures: Z.` line +//! plus a bounded list of failures with their first two detail lines. +//! Dot-progress lines and headers are stripped entirely. + +use super::utils::{php_tool_command, strip_ansi_and_controls}; +use crate::core::runner; +use anyhow::Result; +use lazy_static::lazy_static; +use regex::Regex; + +const MAX_FAILURES_SHOWN: usize = 10; +const MAX_DETAIL_LINES_PER_FAILURE: usize = 2; + +lazy_static! { + // PHPUnit prints each failure heading as "N) Class::method". Anchor to that + // exact shape so detail lines that merely start with a digit and contain ')' + // (e.g. "5 of 10 assertions passed in Foo::bar()") don't split a block. + static ref FAILURE_HEADING_RE: Regex = Regex::new(r"^\d+\) \S").unwrap(); +} + +pub fn run(args: &[String], verbose: u8) -> Result { + let mut cmd = php_tool_command("phpunit"); + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: phpunit {}", args.join(" ")); + } + + runner::run_filtered( + cmd, + "phpunit", + &args.join(" "), + filter_phpunit_output, + runner::RunOptions::stdout_only().tee("phpunit"), + ) +} + +pub(crate) fn filter_phpunit_output(output: &str) -> String { + // PHPUnit colorizes its result line and progress with ANSI under + // `--colors=always`; without stripping, the "OK ("/"FAILURES!"/"Tests:" + // anchors below never match and real counts are lost. + let cleaned = strip_ansi_and_controls(output); + let output = cleaned.as_str(); + + let mut failures: Vec> = Vec::new(); + let mut current: Vec = Vec::new(); + let mut in_failures = false; + + for line in output.lines() { + let trimmed = line.trim(); + + if trimmed.starts_with("OK (") { + return format!("PHPUnit: {}", trimmed); + } + + if trimmed.starts_with("OK, but") { + return build_success_with_skipped(output); + } + + if (trimmed.starts_with("There was") || trimmed.starts_with("There were")) + && (trimmed.contains("failure") || trimmed.contains("error")) + { + in_failures = true; + continue; + } + + if trimmed == "FAILURES!" || trimmed == "ERRORS!" { + if !current.is_empty() { + failures.push(std::mem::take(&mut current)); + } + in_failures = false; + continue; + } + + if in_failures { + if is_numbered_failure_heading(trimmed) { + if !current.is_empty() { + failures.push(std::mem::take(&mut current)); + } + current.push(trimmed.to_string()); + } else if !trimmed.is_empty() { + current.push(trimmed.to_string()); + } + } + } + + if !current.is_empty() { + failures.push(current); + } + + if failures.is_empty() { + let counts = parse_counts(output); + if counts.tests > 0 { + return format!( + "PHPUnit: {} tests, {} assertions", + counts.tests, counts.assertions + ); + } + return "PHPUnit: ok".to_string(); + } + + build_phpunit_summary(output, &failures) +} + +fn is_numbered_failure_heading(line: &str) -> bool { + FAILURE_HEADING_RE.is_match(line) +} + +fn build_success_with_skipped(output: &str) -> String { + let counts = parse_counts(output); + if counts.skipped > 0 { + format!( + "PHPUnit: {} tests, {} assertions, {} skipped", + counts.tests, counts.assertions, counts.skipped + ) + } else { + format!( + "PHPUnit: {} tests, {} assertions", + counts.tests, counts.assertions + ) + } +} + +fn build_phpunit_summary(output: &str, failures: &[Vec]) -> String { + let counts = parse_counts(output); + + // PHPUnit separates failures (assertion mismatches) from errors (thrown + // exceptions); report them distinctly rather than lumping under "failures". + let mut result = format!( + "PHPUnit: {} tests, {} assertions, {} failures", + counts.tests, counts.assertions, counts.failures + ); + if counts.errors > 0 { + result.push_str(&format!(", {} errors", counts.errors)); + } + result.push('\n'); + + for failure_lines in failures.iter().take(MAX_FAILURES_SHOWN) { + if let Some(first) = failure_lines.first() { + result.push_str(&format!("\n{}\n", first)); + } + for detail in failure_lines + .iter() + .skip(1) + .take(MAX_DETAIL_LINES_PER_FAILURE) + { + result.push_str(&format!(" {}\n", detail)); + } + } + + if failures.len() > MAX_FAILURES_SHOWN { + result.push_str(&format!( + "\n... +{} more failures\n", + failures.len() - MAX_FAILURES_SHOWN + )); + } + + result.trim().to_string() +} + +fn parse_counts(output: &str) -> Counts { + let mut counts = Counts::default(); + + for line in output.lines() { + let trimmed = line.trim(); + if !trimmed.starts_with("Tests:") { + continue; + } + + for part in trimmed.split(',') { + let mut it = part.split_whitespace(); + let key = it.next().unwrap_or(""); + let val = it + .next() + .unwrap_or("") + .trim_end_matches('.') + .parse() + .unwrap_or(0); + + match key { + "Tests:" => counts.tests = val, + "Assertions:" => counts.assertions = val, + k if k.starts_with("Failures") => counts.failures += val, + k if k.starts_with("Errors") => counts.errors += val, + k if k.starts_with("Skipped") => counts.skipped = val, + _ => {} + } + } + } + + counts +} + +#[derive(Default)] +struct Counts { + tests: usize, + assertions: usize, + failures: usize, + errors: usize, + skipped: usize, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_numbered_failure_heading_anchored() { + // Real PHPUnit failure headings match. + assert!(is_numbered_failure_heading("1) App\\Tests\\UserTest::testEmail")); + assert!(is_numbered_failure_heading("12) Foo::bar")); + // Detail lines that merely start with a digit and contain ')' must not. + assert!(!is_numbered_failure_heading( + "5 of 10 assertions passed in Foo::bar()" + )); + assert!(!is_numbered_failure_heading("1)")); // no method after ") " + assert!(!is_numbered_failure_heading( + "Failed asserting that Array(3) is identical." + )); + } + + const REAL_PHPUNIT_FAILURE: &str = r#"PHPUnit 10.5.0 by Sebastian Bergmann and contributors. + +Runtime: PHP 8.2.27 with Xdebug 3.3.1 +Configuration: /var/www/html/phpunit.xml + +........................................ 40 / 40 (100%) +.................................................. 80 / 80 (100%) +.F................................................ 100 / 100 (100%) +.......... 110 / 110 (100%) + +Time: 00:01:23.456, Memory: 48.00 MB + +There was 1 failure: + +1) App\Tests\UserTest::testEmailValidation +Failed asserting that false is true. + +#0 /var/www/html/src/User.php:142 (App\User::validate) +#1 /var/www/html/tests/UserTest.php:38 (App\Tests\UserTest::testEmailValidation) + +FAILURES! +Tests: 110, Assertions: 340, Failures: 1."#; + + const REAL_PHPUNIT_SUCCESS: &str = r#"PHPUnit 10.5.0 by Sebastian Bergmann and contributors. + +Runtime: PHP 8.2.0 + +......... 9 / 9 (100%) + +Time: 00:00:00.234, Memory: 6.00 MB + +OK (9 tests, 20 assertions)"#; + + const REAL_PHPUNIT_MULTIPLE_FAILURES: &str = r#"PHPUnit 10.5.0 by Sebastian Bergmann and contributors. + +FF....... 9 / 9 (100%) + +Time: 00:00:00.234, Memory: 6.00 MB + +There were 2 failures: + +1) UserTest::testEmail +Failed asserting that false is true. + +/home/user/tests/UserTest.php:42 + +2) OrderTest::testTotal +Failed asserting that 42 matches expected 100. + +/home/user/tests/OrderTest.php:17 + +FAILURES! +Tests: 9, Assertions: 15, Failures: 2."#; + + #[test] + fn test_phpunit_success() { + let result = filter_phpunit_output(REAL_PHPUNIT_SUCCESS); + assert!(result.contains("PHPUnit"), "got: {}", result); + assert!(result.contains("OK (9 tests, 20 assertions)"), "got: {}", result); + } + + #[test] + fn test_phpunit_failure_captures_test_name() { + let result = filter_phpunit_output(REAL_PHPUNIT_FAILURE); + assert!( + result.contains("UserTest::testEmailValidation"), + "got: {}", + result + ); + assert!( + result.contains("Failed asserting that false is true"), + "got: {}", + result + ); + } + + #[test] + fn test_phpunit_failure_summary_counts() { + let result = filter_phpunit_output(REAL_PHPUNIT_FAILURE); + assert!(result.contains("110 tests"), "got: {}", result); + assert!(result.contains("340 assertions"), "got: {}", result); + assert!(result.contains("1 failures"), "got: {}", result); + } + + #[test] + fn test_phpunit_multiple_failures() { + let result = filter_phpunit_output(REAL_PHPUNIT_MULTIPLE_FAILURES); + assert!(result.contains("UserTest::testEmail"), "got: {}", result); + assert!(result.contains("OrderTest::testTotal"), "got: {}", result); + assert!(result.contains("2 failures"), "got: {}", result); + } + + #[test] + fn test_phpunit_ok_with_skipped() { + let output = r#"OK, but incomplete, skipped, or risky tests! +Tests: 5, Assertions: 10, Skipped: 2."#; + let result = filter_phpunit_output(output); + assert!(result.contains("5 tests"), "got: {}", result); + assert!(result.contains("2 skipped"), "got: {}", result); + } + + #[test] + fn test_phpunit_errors_summary() { + let output = r#"There was 1 error: + +1) FooTest::testBar +RuntimeException: boom + +ERRORS! +Tests: 1, Assertions: 0, Errors: 1."#; + let result = filter_phpunit_output(output); + assert!(result.contains("FooTest::testBar"), "got: {}", result); + // Errors are now reported distinctly from failures. + assert!(result.contains("0 failures, 1 errors"), "got: {}", result); + } + + #[test] + fn test_phpunit_failure_truncation() { + let mut output = String::from("There were 15 failures:\n\n"); + for i in 1..=15 { + output.push_str(&format!( + "{}) Suite::test{}\nFailed asserting thing {}.\n\n", + i, i, i + )); + } + output.push_str("FAILURES!\nTests: 15, Assertions: 15, Failures: 15.\n"); + + let result = filter_phpunit_output(&output); + assert!(result.contains("Suite::test1"), "got: {}", result); + assert!(result.contains("Suite::test10"), "got: {}", result); + assert!(!result.contains("Suite::test11"), "got: {}", result); + assert!(result.contains("+5 more failures"), "got: {}", result); + } + + #[test] + fn test_phpunit_strips_ansi_colors() { + // --colors=always wraps the result line; anchors must still match. + let colored = "\x1b[30;42mOK\x1b[0m \x1b[32m(9 tests, 20 assertions)\x1b[0m"; + let result = filter_phpunit_output(colored); + assert!( + result.contains("OK (9 tests, 20 assertions)"), + "got: {}", + result + ); + } + + #[test] + fn test_phpunit_empty_ok_fallback() { + let result = filter_phpunit_output(""); + assert_eq!(result, "PHPUnit: ok"); + } + + #[test] + fn test_phpunit_only_summary_line() { + let result = filter_phpunit_output("Tests: 4, Assertions: 4.\n"); + assert!(result.contains("4 tests"), "got: {}", result); + } + + #[test] + fn test_phpunit_compression() { + let raw_len = REAL_PHPUNIT_FAILURE.len(); + let filtered_len = filter_phpunit_output(REAL_PHPUNIT_FAILURE).len(); + assert!( + filtered_len < raw_len / 2, + "expected >50% reduction, raw={}, filtered={}", + raw_len, + filtered_len + ); + } +} diff --git a/src/cmds/php/pint_cmd.rs b/src/cmds/php/pint_cmd.rs new file mode 100644 index 0000000000..51068a7469 --- /dev/null +++ b/src/cmds/php/pint_cmd.rs @@ -0,0 +1,227 @@ +//! Laravel Pint (PHP-CS-Fixer wrapper) output filter. +//! +//! Pint emits verbose per-rule progress and config chatter on its default +//! text output. It also supports `--format=json`, which gives a structured +//! list of files with their applied rules. We inject `--format=json` when +//! the user hasn't picked a format, parse it, and emit a compact summary +//! grouped by file and sorted by rule count. + +use super::utils::php_tool_command; +use crate::core::runner; +use crate::core::utils::fallback_tail; +use anyhow::Result; +use serde::Deserialize; + +const MAX_FILES_SHOWN: usize = 15; +const MAX_RULES_PER_FILE: usize = 5; + +#[derive(Deserialize)] +struct PintOutput { + #[serde(default)] + files: Vec, +} + +#[derive(Deserialize)] +struct PintFile { + // Pint โ‰ฅ ~1.14 renamed the JSON keys: nameโ†’path, appliedFixersโ†’fixers. + // Aliases keep both schemas parsing so output stays compressed across versions. + #[serde(alias = "path")] + name: String, + // PHP-CS-Fixer omits the fixers key entirely in dry-run/diff modes, so it + // must default rather than fail the whole parse. + #[serde(rename = "appliedFixers", alias = "fixers", default)] + applied_fixers: Vec, +} + +pub fn run(args: &[String], verbose: u8) -> Result { + let mut cmd = php_tool_command("pint"); + + let has_format = args + .iter() + .any(|a| a == "--format" || a.starts_with("--format=")); + let is_utility_cmd = args + .first() + .map(|a| matches!(a.as_str(), "--version" | "-V" | "--help" | "-h")) + .unwrap_or(false); + + if !has_format && !is_utility_cmd { + cmd.arg("--format=json"); + } + + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: pint {}", args.join(" ")); + } + + let filter = move |stdout: &str| -> String { + if has_format || is_utility_cmd { + fallback_tail(stdout, "pint", 60) + } else { + filter_pint_json(stdout) + } + }; + + runner::run_filtered( + cmd, + "pint", + &args.join(" "), + filter, + runner::RunOptions::stdout_only().tee("pint"), + ) +} + +pub(crate) fn filter_pint_json(output: &str) -> String { + let trimmed = output.trim(); + if trimmed.is_empty() { + return "pint: ok".to_string(); + } + + let parsed: Result = serde_json::from_str(trimmed); + let pint = match parsed { + Ok(p) => p, + Err(_) => return fallback_tail(output, "pint (JSON parse error)", 20), + }; + + if pint.files.is_empty() { + return "pint: ok".to_string(); + } + + let total_files = pint.files.len(); + let total_rules: usize = pint.files.iter().map(|f| f.applied_fixers.len()).sum(); + + let mut files = pint.files; + files.sort_by(|a, b| { + b.applied_fixers + .len() + .cmp(&a.applied_fixers.len()) + .then(a.name.cmp(&b.name)) + }); + + let mut result = format!("pint: {} changes in {} files\n", total_rules, total_files); + + // Resolve cwd once; short_path() used to re-syscall current_dir() per file. + let cwd_prefix = std::env::current_dir() + .ok() + .and_then(|p| p.into_os_string().into_string().ok()) + .map(|s| format!("{}/", s)); + + for file in files.iter().take(MAX_FILES_SHOWN) { + let name = cwd_prefix + .as_deref() + .and_then(|c| file.name.strip_prefix(c)) + .unwrap_or(&file.name); + result.push_str(&format!("\n{} ({})\n", name, file.applied_fixers.len())); + for rule in file.applied_fixers.iter().take(MAX_RULES_PER_FILE) { + result.push_str(&format!(" - {}\n", rule)); + } + if file.applied_fixers.len() > MAX_RULES_PER_FILE { + result.push_str(&format!( + " ... +{} more rules\n", + file.applied_fixers.len() - MAX_RULES_PER_FILE + )); + } + } + + if files.len() > MAX_FILES_SHOWN { + result.push_str(&format!( + "\n... +{} more files\n", + files.len() - MAX_FILES_SHOWN + )); + } + + result.trim().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pint_empty_is_ok() { + assert_eq!(filter_pint_json(""), "pint: ok"); + } + + #[test] + fn test_pint_no_files_is_ok() { + assert_eq!(filter_pint_json(r#"{"files":[]}"#), "pint: ok"); + } + + #[test] + fn test_pint_single_file() { + let json = r#"{"files":[{"name":"app/Foo.php","appliedFixers":["no_unused_imports","ordered_imports"]}]}"#; + let result = filter_pint_json(json); + assert!(result.contains("2 changes in 1 files"), "got: {}", result); + assert!(result.contains("app/Foo.php (2)"), "got: {}", result); + assert!(result.contains("no_unused_imports"), "got: {}", result); + assert!(result.contains("ordered_imports"), "got: {}", result); + } + + #[test] + fn test_pint_current_schema_path_fixers() { + // Pint โ‰ฅ ~1.14 emits path/fixers instead of name/appliedFixers. + // Without aliases this fell back to raw output (no compression). + let json = r#"{"result":"fail","files":[{"path":"app/Foo.php","fixers":["concat_space","ordered_imports"]}]}"#; + let result = filter_pint_json(json); + assert!(result.contains("2 changes in 1 files"), "got: {}", result); + assert!(result.contains("app/Foo.php (2)"), "got: {}", result); + assert!(result.contains("concat_space"), "got: {}", result); + } + + #[test] + fn test_pint_sorted_by_count_desc() { + let json = r#"{"files":[ + {"name":"a.php","appliedFixers":["x"]}, + {"name":"b.php","appliedFixers":["x","y","z"]}, + {"name":"c.php","appliedFixers":["x","y"]} + ]}"#; + let result = filter_pint_json(json); + let pos_b = result.find("b.php").unwrap(); + let pos_c = result.find("c.php").unwrap(); + let pos_a = result.find("a.php").unwrap(); + assert!(pos_b < pos_c && pos_c < pos_a, "got: {}", result); + } + + #[test] + fn test_pint_file_truncation() { + let mut files = Vec::new(); + for i in 1..=20 { + files.push(format!( + r#"{{"name":"f{}.php","appliedFixers":["x"]}}"#, + i + )); + } + let json = format!(r#"{{"files":[{}]}}"#, files.join(",")); + let result = filter_pint_json(&json); + assert!(result.contains("20 changes in 20 files"), "got: {}", result); + assert!(result.contains("+5 more files"), "got: {}", result); + } + + #[test] + fn test_pint_rule_truncation() { + let json = r#"{"files":[{"name":"f.php","appliedFixers":["a","b","c","d","e","f","g"]}]}"#; + let result = filter_pint_json(json); + assert!(result.contains(" - a\n"), "got: {}", result); + assert!(result.contains(" - e\n"), "got: {}", result); + assert!(!result.contains(" - f\n"), "got: {}", result); + assert!(result.contains("+2 more rules"), "got: {}", result); + } + + #[test] + fn test_pint_file_without_fixers_key() { + // PHP-CS-Fixer omits applied_fixers when there's nothing to report; + // the entry must still parse rather than fall back to raw output. + let json = r#"{"files":[{"name":"x.php"}]}"#; + let result = filter_pint_json(json); + assert!(result.contains("0 changes in 1 files"), "got: {}", result); + assert!(result.contains("x.php (0)"), "got: {}", result); + } + + #[test] + fn test_pint_invalid_json_falls_back() { + let result = filter_pint_json("Laravel Pint v1.13.6\n\n... some text ..."); + assert!(!result.contains("pint: ok"), "got: {}", result); + } +} diff --git a/src/cmds/php/test_output.rs b/src/cmds/php/test_output.rs new file mode 100644 index 0000000000..a77ab8961b --- /dev/null +++ b/src/cmds/php/test_output.rs @@ -0,0 +1,84 @@ +//! Shared compact output filtering for PHP test runners. + +use super::utils::strip_ansi_and_controls; + +fn is_progress_line(line: &str) -> bool { + let trimmed = line.trim(); + if trimmed.is_empty() { + return false; + } + + let has_dot = trimmed.contains('.'); + let progress_charset = trimmed.chars().all(|c| { + matches!( + c, + '.' | 'F' | 'E' | 'W' | 'R' | 'S' | 'I' | 'D' | 'N' | 'O' | 'K' | '0' + ..='9' | ' ' | '/' | '%' | '(' | ')' | '-' + ) + }); + + has_dot && progress_charset +} + +pub fn filter_test_runner_output(output: &str) -> String { + let mut lines = Vec::new(); + + for line in strip_ansi_and_controls(output).lines() { + let trimmed = line.trim_end(); + if trimmed.trim().is_empty() { + continue; + } + + if trimmed.starts_with("PHPUnit ") + || trimmed.starts_with("Pest ") + || trimmed.starts_with("ParaTest ") + || trimmed.starts_with("Runtime:") + || trimmed.starts_with("Configuration:") + || trimmed.starts_with("Random Seed:") + { + continue; + } + + if is_progress_line(trimmed) { + continue; + } + + lines.push(trimmed.to_string()); + } + + if lines.is_empty() { + return "ok".to_string(); + } + + if lines.len() > 120 { + let mut reduced = Vec::new(); + reduced.extend(lines.iter().take(80).cloned()); + reduced.push(format!("... +{} more lines", lines.len() - 120)); + reduced.extend(lines.iter().skip(lines.len() - 40).cloned()); + return reduced.join("\n"); + } + + lines.join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_filters_phpunit_headers_and_progress() { + let output = "PHPUnit 12.2.0\n....\nOK (4 tests, 4 assertions)\n"; + let filtered = filter_test_runner_output(output); + assert!(!filtered.contains("PHPUnit 12.2.0")); + assert!(!filtered.contains("....")); + assert!(filtered.contains("OK (4 tests, 4 assertions)")); + } + + #[test] + fn test_keeps_failures() { + let output = "..F\nThere was 1 failure:\nFailed asserting true is false\n"; + let filtered = filter_test_runner_output(output); + assert!(filtered.contains("There was 1 failure")); + assert!(filtered.contains("Failed asserting true is false")); + } +} diff --git a/src/cmds/php/utils.rs b/src/cmds/php/utils.rs new file mode 100644 index 0000000000..f056cd4890 --- /dev/null +++ b/src/cmds/php/utils.rs @@ -0,0 +1,62 @@ +use crate::core::utils::{composer_tool_paths, resolve_binary, resolved_command}; +use lazy_static::lazy_static; +use regex::Regex; +use std::path::Path; +use std::process::Command; + +lazy_static! { + static ref ANSI_RE: Regex = Regex::new(r"\x1b\[[0-9;]*[A-Za-z]").unwrap(); + static ref CONTROL_RE: Regex = Regex::new(r"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]").unwrap(); +} + +pub fn php_tool_command(tool: &str) -> Command { + for local_tool in composer_tool_paths(tool) { + let local_tool_name = local_tool.to_string_lossy().into_owned(); + // Route through resolved_command (the sanctioned constructor) rather than + // a raw dynamic command constructor, so the binary still resolves + // PATHEXT-aware on Windows and the security scan's no-dynamic-exec rule holds. + if resolve_binary(&local_tool_name).is_ok() || local_tool.exists() { + return resolved_command(&local_tool_name); + } + } + + resolved_command(tool) +} + +fn composer_tool_exists(tool: &str) -> bool { + composer_tool_paths(tool).into_iter().any(|local_tool| { + let local_tool_name = local_tool.to_string_lossy().into_owned(); + resolve_binary(&local_tool_name).is_ok() || local_tool.exists() + }) +} + +pub fn strip_ansi_and_controls(input: &str) -> String { + let no_ansi = ANSI_RE.replace_all(input, ""); + CONTROL_RE.replace_all(&no_ansi, "").to_string() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PhpTestRunner { + Pest, + Phpunit, + Unknown, +} + +pub fn detect_php_test_runner() -> PhpTestRunner { + // Pest's canonical marker is the `vendor/bin/pest` binary (composer dep). + // There is no root `pest.php` file โ€” Pest's bootstrap lives at `tests/Pest.php` + // โ€” so a root-level `pest.php` check both never matches Pest and false-positives + // on unrelated utility files in PHPUnit-only projects. + if composer_tool_exists("pest") { + return PhpTestRunner::Pest; + } + + if composer_tool_exists("phpunit") + || Path::new("phpunit.xml").exists() + || Path::new("phpunit.xml.dist").exists() + { + return PhpTestRunner::Phpunit; + } + + PhpTestRunner::Unknown +} diff --git a/src/cmds/python/README.md b/src/cmds/python/README.md index 7295ded352..26e1324549 100644 --- a/src/cmds/python/README.md +++ b/src/cmds/python/README.md @@ -7,6 +7,7 @@ - `pytest_cmd.rs` uses a state machine text parser (no JSON available from pytest) - `ruff_cmd.rs` uses JSON for check mode (`--output-format=json`) and text filtering for format mode - `pip_cmd.rs` auto-detects `uv` as a pip alternative and routes accordingly +- `uv_cmd.rs` preserves `uv run` environment semantics: the program's own output passes through on success (bounded, with a tee hint), diagnostics are extracted on failure - `python -m pytest` and `python3 -m mypy` are rewritten by the hook registry to `rtk pytest` / `rtk mypy` ## Cross-command diff --git a/src/cmds/python/mypy_cmd.rs b/src/cmds/python/mypy_cmd.rs index 7c26cc9d4c..96a719a3c7 100644 --- a/src/cmds/python/mypy_cmd.rs +++ b/src/cmds/python/mypy_cmd.rs @@ -23,15 +23,25 @@ pub fn run(args: &[String], verbose: u8) -> Result { eprintln!("Running: mypy {}", args.join(" ")); } - runner::run_filtered( + runner::run_filtered_with_exit( cmd, "mypy", &args.join(" "), - |raw| filter_mypy_output(&strip_ansi(raw)), + |raw, exit_code| { + let clean = strip_ansi(raw); + let filtered = filter_mypy_output(&clean); + // Nothing recognised on a failed run means mypy never type-checked. + if exit_code != 0 && filtered == MYPY_CLEAN { + return clean.trim().to_string(); + } + filtered + }, runner::RunOptions::default(), ) } +const MYPY_CLEAN: &str = "mypy: No issues found"; + struct MypyError { file: String, line: usize, @@ -128,9 +138,9 @@ pub fn filter_mypy_output(output: &str) -> String { // No errors at all if errors.is_empty() && fileless_lines.is_empty() { if output.contains("Success: no issues found") || output.contains("no issues found") { - return "mypy: No issues found".to_string(); + return MYPY_CLEAN.to_string(); } - return "mypy: No issues found".to_string(); + return MYPY_CLEAN.to_string(); } // Group by file diff --git a/src/cmds/python/pytest_cmd.rs b/src/cmds/python/pytest_cmd.rs index 87eca2ec9b..63b76e312f 100644 --- a/src/cmds/python/pytest_cmd.rs +++ b/src/cmds/python/pytest_cmd.rs @@ -1,8 +1,9 @@ //! Filters pytest output to show only failures and the summary line. +use crate::core::config; use crate::core::runner; use crate::core::truncate::CAP_WARNINGS; -use crate::core::utils::{resolved_command, tool_exists, truncate}; +use crate::core::utils::{resolved_command, strip_ansi, tool_exists, truncate}; use anyhow::Result; const MAX_XFAIL: usize = CAP_WARNINGS; @@ -51,15 +52,26 @@ pub fn run(args: &[String], verbose: u8) -> Result { eprintln!("Running: pytest --tb=short -q {}", args.join(" ")); } - runner::run_filtered( + runner::run_filtered_with_exit( cmd, "pytest", &args.join(" "), - filter_pytest_output, + |raw, exit_code| { + let clean = strip_ansi(raw); + let filtered = filter_pytest_output(&clean); + // Any other failure parsed as empty means the run broke before reporting. + if exit_code != 0 && exit_code != PYTEST_EXIT_NO_TESTS && filtered == PYTEST_NO_TESTS { + return truncate(clean.trim(), config::limits().passthrough_max_chars); + } + filtered + }, runner::RunOptions::stdout_only().tee("pytest"), ) } +const PYTEST_NO_TESTS: &str = "Pytest: No tests collected"; +const PYTEST_EXIT_NO_TESTS: i32 = 5; + pub(crate) fn filter_pytest_output(output: &str) -> String { let mut state = ParseState::Header; let mut test_files: Vec = Vec::new(); @@ -181,7 +193,7 @@ fn build_pytest_summary( } = counts; if passed == 0 && failed == 0 && skipped == 0 && xfailed == 0 && xpassed == 0 { - return "Pytest: No tests collected".to_string(); + return PYTEST_NO_TESTS.to_string(); } let extras_present = skipped > 0 || xfailed > 0 || xpassed > 0 || !xfail_lines.is_empty(); diff --git a/src/cmds/python/ruff_cmd.rs b/src/cmds/python/ruff_cmd.rs index f8a5120356..b02df1bd03 100644 --- a/src/cmds/python/ruff_cmd.rs +++ b/src/cmds/python/ruff_cmd.rs @@ -40,11 +40,22 @@ pub fn run(args: &[String], verbose: u8) -> Result { let mut cmd = resolved_command("ruff"); + // Both spellings: injecting a second --output-format makes ruff reject the call. + let user_set_output_format = args + .iter() + .any(|a| a == "--output-format" || a.starts_with("--output-format=")); + + let user_json_format = args.iter().enumerate().any(|(i, a)| { + a == "--output-format=json" + || (a == "--output-format" && args.get(i + 1).is_some_and(|n| n == "json")) + }); + let use_json_filter = !user_set_output_format || user_json_format; + if is_check { - if !args.contains(&"--output-format".to_string()) { - cmd.arg("check").arg("--output-format=json"); - } else { + if user_set_output_format { cmd.arg("check"); + } else { + cmd.arg("check").arg("--output-format=json"); } let start_idx = if !args.is_empty() && args[0] == "check" { @@ -78,12 +89,12 @@ pub fn run(args: &[String], verbose: u8) -> Result { "ruff", &args.join(" "), move |stdout| { - if is_check && !stdout.trim().is_empty() { + if is_check && use_json_filter && !stdout.trim().is_empty() { filter_ruff_check_json(stdout) } else if is_format { filter_ruff_format(stdout) } else { - stdout.trim().to_string() + truncate(stdout.trim(), config::limits().passthrough_max_chars) } }, runner::RunOptions::stdout_only(), diff --git a/src/cmds/python/uv_cmd.rs b/src/cmds/python/uv_cmd.rs new file mode 100644 index 0000000000..073eba25d8 --- /dev/null +++ b/src/cmds/python/uv_cmd.rs @@ -0,0 +1,569 @@ +//! Filters `uv run` output while preserving uv-managed environment semantics. +//! +//! `uv run` executes arbitrary programs, so on success its stdout and stderr are +//! the signal the caller asked for and are passed through unchanged. Collapsing a +//! successful run to a summary would discard the program's result with no way to +//! recover it. uv is silent unless it resolves or installs, so its own chatter is +//! left alone rather than stripped: suppressing it would also erase it from the +//! tee file, breaking recovery. + +use crate::core::runner; +use crate::core::stream::{self, FilterMode, StdinMode}; +use crate::core::tracking; +use crate::core::truncate::{CAP_INVENTORY, CAP_WARNINGS}; +use crate::core::utils::{exit_code_from_status, resolved_command, strip_ansi, truncate}; +use anyhow::{Context, Result}; +use lazy_static::lazy_static; +use regex::Regex; + +lazy_static! { + static ref PYTHON_FRAME_RE: Regex = Regex::new(r#"^\s*File ".*", line \d+.*$"#).unwrap(); + static ref PYTHON_EXCEPTION_RE: Regex = + Regex::new(r"^\s*[A-Za-z_][A-Za-z0-9_.]*(?:Error|Exception):").unwrap(); + static ref JS_FRAME_RE: Regex = Regex::new(r"^\s*at .+:\d+:\d+.*$").unwrap(); + static ref ERROR_START_PATTERNS: Vec = vec![ + Regex::new(r"(?i)\berror\b").unwrap(), + Regex::new(r"(?i)\bfailed\b").unwrap(), + Regex::new(r"(?i)\bfailure\b").unwrap(), + Regex::new(r"(?i)\bexception\b").unwrap(), + Regex::new(r"(?i)\bpanic\b").unwrap(), + Regex::new(r"(?i)\bwarn(?:ing)?\b").unwrap(), + Regex::new(r"(?i)\bassert(?:ion)?\b").unwrap(), + Regex::new(r"^\s*FAILED\b").unwrap(), + Regex::new(r"^\s*ERROR\b").unwrap(), + Regex::new(r"^\s*E\s+").unwrap(), + Regex::new(r"^\s*Caused by:").unwrap(), + Regex::new(r"^\s*note:").unwrap(), + Regex::new(r"^\s*help:").unwrap(), + ]; +} + +const MAX_TRACEBACK_FRAMES: usize = CAP_WARNINGS; +const MAX_ERROR_CONTINUATION_LINES: usize = CAP_WARNINGS; +const MAX_FALLBACK_TAIL_LINES: usize = CAP_WARNINGS; +const MAX_PROGRAM_LINE_CHARS: usize = 500; +const TEE_SLUG_STDOUT: &str = "uv-run-stdout"; +const TEE_SLUG_STDERR: &str = "uv-run-stderr"; + +pub fn run(args: &[String], verbose: u8) -> Result { + let timer = tracking::TimedExecution::start(); + let args_display = args.join(" "); + let original_cmd = display_command("uv", &args_display); + let rtk_cmd = display_command("rtk uv", &args_display); + + let mut cmd = resolved_command("uv"); + cmd.args(args); + + if verbose > 0 { + eprintln!("Running: {}", original_cmd); + } + + if args.first().map(String::as_str) != Some("run") { + let status = cmd.status().context("Failed to run uv")?; + timer.track_passthrough(&original_cmd, &format!("{rtk_cmd} (passthrough)")); + return Ok(exit_code_from_status(&status, "uv")); + } + + let result = stream::run_streaming(&mut cmd, StdinMode::Inherit, FilterMode::CaptureOnly) + .context("Failed to run uv")?; + let filtered = filter_uv_run_output( + &result.raw, + &result.raw_stdout, + &result.raw_stderr, + result.exit_code, + ); + + runner::print_with_hint(&filtered, &result.raw, &result.raw, "uv", result.exit_code); + timer.track(&original_cmd, &rtk_cmd, &result.raw, &filtered); + + Ok(result.exit_code) +} + +fn display_command(prefix: &str, args_display: &str) -> String { + if args_display.trim().is_empty() { + prefix.to_string() + } else { + format!("{prefix} {args_display}") + } +} + +fn filter_uv_run_output(output: &str, stdout: &str, stderr: &str, exit_code: i32) -> String { + if exit_code == 0 { + return filter_successful_run(stdout, stderr); + } + + // On failure the streams are scanned merged: a Python traceback interleaves + // stdout and stderr, and splitting it would break frame ordering. + let clean = strip_ansi(output); + let extracted = extract_diagnostics(&clean); + if !extracted.is_empty() { + return extracted; + } + + let tail: Vec = clean + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(|line| truncate(line, 200)) + .collect(); + + // The exit code already carries the failure; restating it would only add + // tokens, so the command's own message is returned untouched. + let skip = tail.len().saturating_sub(MAX_FALLBACK_TAIL_LINES); + tail[skip..].join("\n") +} + +/// Expects ANSI-stripped input. +fn extract_diagnostics(clean: &str) -> String { + let lines: Vec<&str> = clean.lines().collect(); + let mut selected: Vec = Vec::new(); + let mut i = 0; + + while i < lines.len() { + let line = lines[i]; + let trimmed = line.trim(); + + if trimmed.is_empty() { + i += 1; + continue; + } + + if is_traceback_start(trimmed) { + let (block, next_idx) = collect_traceback_block(&lines, i); + selected.extend(block); + selected.push(String::new()); + i = next_idx; + continue; + } + + if is_error_start(trimmed) { + let (block, next_idx) = collect_error_block(&lines, i); + selected.extend(block); + selected.push(String::new()); + i = next_idx; + continue; + } + + i += 1; + } + + selected.join("\n").trim().to_string() +} + +fn filter_successful_run(stdout: &str, stderr: &str) -> String { + // Distinct slugs: both streams can need a tee in the same run, and tee + // filenames are second-resolution, so a shared slug would make the second + // write clobber the first and leave one hint pointing at the other's bytes. + let payload = program_output(stdout, TEE_SLUG_STDOUT); + let diagnostics = program_output(stderr, TEE_SLUG_STDERR); + + match (payload.is_empty(), diagnostics.is_empty()) { + (true, true) => "ok".to_string(), + (false, true) => payload, + (true, false) => diagnostics, + (false, false) => format!("{payload}\n{diagnostics}"), + } +} + +fn program_output(text: &str, tee_slug: &str) -> String { + let clean = strip_ansi(text); + let lines: Vec<&str> = clean.lines().collect(); + let last_content = lines.iter().rposition(|line| !line.trim().is_empty()); + + let Some(last_content) = last_content else { + return String::new(); + }; + let lines = &lines[..=last_content]; + let capped: Vec = lines + .iter() + .map(|line| truncate(line, MAX_PROGRAM_LINE_CHARS)) + .collect(); + let line_was_cut = capped.iter().zip(lines).any(|(cut, full)| cut.len() != full.len()); + + if capped.len() <= CAP_INVENTORY { + let out = capped.join("\n"); + if line_was_cut { + if let Some(hint) = crate::core::tee::force_tee_hint(&clean, tee_slug) { + return format!("{out}\n{hint}"); + } + } + return out; + } + + // A program's result is usually its last line, so keep both ends. + let head = CAP_INVENTORY / 2; + let tail = CAP_INVENTORY - head; + let omitted = capped.len() - CAP_INVENTORY; + + let mut out = capped[..head].join("\n"); + out.push_str(&format!("\n... ({omitted} lines omitted)\n")); + out.push_str(&capped[capped.len() - tail..].join("\n")); + + // A cut in the head region sits before the tail offset, so `tail -n +N` skips it. + let head_line_was_cut = capped[..head] + .iter() + .zip(&lines[..head]) + .any(|(cut, full)| cut != full); + + let hint = if head_line_was_cut { + crate::core::tee::force_tee_hint(&clean, tee_slug) + } else { + crate::core::tee::force_tee_tail_hint(&clean, tee_slug, head + 1) + }; + + if let Some(hint) = hint { + out.push_str(&format!("\n{hint}")); + } + + out +} + +fn collect_traceback_block(lines: &[&str], start_idx: usize) -> (Vec, usize) { + let mut block = vec![lines[start_idx].trim().to_string()]; + let mut frames = Vec::new(); + let mut tail = Vec::new(); + let mut idx = start_idx + 1; + + while idx < lines.len() { + let trimmed = lines[idx].trim(); + if trimmed.is_empty() { + break; + } + + if PYTHON_FRAME_RE.is_match(trimmed) { + frames.push(truncate(trimmed, 160)); + } else { + tail.push(truncate(trimmed, 200)); + } + + idx += 1; + } + + block.extend(frames.iter().take(MAX_TRACEBACK_FRAMES).cloned()); + if frames.len() > MAX_TRACEBACK_FRAMES { + block.push(format!( + "... +{} more frames", + frames.len() - MAX_TRACEBACK_FRAMES + )); + let full_traceback = lines[start_idx..idx].join("\n"); + if let Some(hint) = crate::core::tee::force_tee_hint(&full_traceback, "uv-traceback") { + block.push(format!(" {hint}")); + } + } + + let tail_lines = tail + .into_iter() + .rev() + .take(2) + .collect::>() + .into_iter() + .rev() + .collect::>(); + block.extend(tail_lines); + + (dedupe_preserving_order(block), idx) +} + +fn collect_error_block(lines: &[&str], start_idx: usize) -> (Vec, usize) { + let mut block = vec![truncate(lines[start_idx].trim(), 200)]; + let mut continuation_count = 0; + let mut idx = start_idx + 1; + + while idx < lines.len() { + let line = lines[idx]; + let trimmed = line.trim(); + + if trimmed.is_empty() || !is_error_continuation(line) { + break; + } + + continuation_count += 1; + if continuation_count <= MAX_ERROR_CONTINUATION_LINES { + block.push(truncate(trimmed, 200)); + } + + idx += 1; + } + + if continuation_count > MAX_ERROR_CONTINUATION_LINES { + block.push(format!( + "... +{} more lines", + continuation_count - MAX_ERROR_CONTINUATION_LINES + )); + let full_block = lines[start_idx..idx].join("\n"); + if let Some(hint) = crate::core::tee::force_tee_hint(&full_block, "uv-error-block") { + block.push(format!(" {hint}")); + } + } + + (dedupe_preserving_order(block), idx) +} + +fn dedupe_preserving_order(lines: Vec) -> Vec { + let mut deduped = Vec::new(); + for line in lines { + if deduped.last() != Some(&line) { + deduped.push(line); + } + } + deduped +} + +fn is_traceback_start(line: &str) -> bool { + line.starts_with("Traceback ") +} + +fn is_error_start(line: &str) -> bool { + if is_traceback_start(line) + || PYTHON_FRAME_RE.is_match(line) + || PYTHON_EXCEPTION_RE.is_match(line) + || JS_FRAME_RE.is_match(line) + { + return true; + } + + if line.contains("No module named ") { + return true; + } + + ERROR_START_PATTERNS.iter().any(|pattern| pattern.is_match(line)) +} + +fn is_error_continuation(line: &str) -> bool { + let trimmed = line.trim(); + line.starts_with(' ') + || line.starts_with('\t') + || trimmed.starts_with('>') + || trimmed.starts_with('|') + || trimmed.starts_with("During handling of the above exception") + || trimmed.starts_with("The above exception") + || trimmed.starts_with("Caused by:") + || trimmed.starts_with("note:") + || trimmed.starts_with("help:") + || PYTHON_FRAME_RE.is_match(trimmed) + || PYTHON_EXCEPTION_RE.is_match(trimmed) + || JS_FRAME_RE.is_match(trimmed) +} + +#[cfg(test)] +mod tests { + use super::{filter_uv_run_output, CAP_INVENTORY, MAX_TRACEBACK_FRAMES}; + use crate::core::utils::count_tokens; + + #[test] + fn test_filter_uv_run_keeps_program_output_on_success() { + let stdout = "hello from script\n"; + + assert_eq!( + filter_uv_run_output(stdout, stdout, "", 0), + "hello from script" + ); + } + + #[test] + fn test_filter_uv_run_keeps_data_producing_stdout() { + let stdout = "{\n \"users\": 42,\n \"active\": 37\n}\n"; + let raw = stdout.to_string(); + + let result = filter_uv_run_output(&raw, stdout, "", 0); + + assert!(result.contains("\"users\": 42")); + assert!(result.contains("\"active\": 37")); + } + + #[test] + fn test_filter_uv_run_keeps_non_error_stderr_on_success() { + // uv's own chatter is suppressed upstream by `-q`, so whatever reaches + // the filter on stderr belongs to the program and must survive. + let stderr = "INFO:root:connected to db\nINFO:root:migrated 3 tables\n"; + let stdout = "done\n"; + let raw = format!("{stderr}{stdout}"); + + let result = filter_uv_run_output(&raw, stdout, stderr, 0); + + assert!(result.contains("done")); + assert!(result.contains("INFO:root:connected to db")); + assert!(result.contains("INFO:root:migrated 3 tables")); + } + + #[test] + fn test_stdout_and_stderr_tee_slugs_are_distinct() { + // Tee filenames are `{epoch_secs}_{slug}.log`. Both streams can need a + // tee within the same second, so a shared slug would make the stderr + // write clobber the stdout one and leave the stdout hint resolving to + // stderr's bytes - the omitted stdout lines become unrecoverable. + assert_ne!(super::TEE_SLUG_STDOUT, super::TEE_SLUG_STDERR); + } + + #[test] + fn test_program_output_truncates_over_line_cap_keeping_both_ends() { + let stdout: String = (0..120).map(|i| format!("line{i}\n")).collect(); + + let result = super::program_output(&stdout, super::TEE_SLUG_STDOUT); + + assert!(result.contains("line0"), "head must survive"); + assert!(result.contains("line119"), "tail must survive"); + assert!(result.contains("lines omitted")); + assert!(result.lines().count() < 120); + } + + #[test] + fn test_program_output_head_cut_switches_away_from_the_tail_hint() { + // 60 lines with a long line 4: the tail hint would start past it. + let stdout: String = (0..60) + .map(|i| { + if i == 3 { + format!("{}\n", "x".repeat(900)) + } else { + format!("line{i}\n") + } + }) + .collect(); + + let result = super::program_output(&stdout, super::TEE_SLUG_STDOUT); + + assert!(result.contains("lines omitted")); + assert!( + !result.contains("see remaining"), + "a head-region cut must not be reported with a tail offset that skips it, got: {result}" + ); + } + + #[test] + fn test_program_output_caps_a_single_huge_line() { + let stdout = format!("{}\n", "x".repeat(50_000)); + + let result = super::program_output(&stdout, super::TEE_SLUG_STDOUT); + + assert!( + result.len() < 2_000, + "one huge line must be capped, got {} bytes", + result.len() + ); + assert!(result.contains("...")); + } + + #[test] + fn test_program_output_exact_cap_is_untouched() { + let stdout: String = (0..CAP_INVENTORY).map(|i| format!("line{i}\n")).collect(); + + let result = super::program_output(&stdout, super::TEE_SLUG_STDOUT); + + assert!(!result.contains("lines omitted")); + assert_eq!(result.lines().count(), CAP_INVENTORY); + } + + #[test] + fn test_program_output_handles_multibyte_without_panic() { + let stdout: String = (0..80).map(|i| format!("ๆ—ฅๆœฌ่ชž ๐ŸŽ‰ line{i}\n")).collect(); + + let result = super::program_output(&stdout, super::TEE_SLUG_STDOUT); + + assert!(result.contains("ๆ—ฅๆœฌ่ชž")); + assert!(result.contains("lines omitted")); + } + + #[test] + fn test_filter_uv_run_silent_success_is_ok() { + assert_eq!(filter_uv_run_output("", "", "", 0), "ok"); + } + + #[test] + fn test_filter_uv_run_success_keeps_stderr_warnings_with_payload() { + let stdout = "result: 7\n"; + let stderr = "WARNING: deprecated api\n"; + let raw = format!("{stderr}{stdout}"); + + let result = filter_uv_run_output(&raw, stdout, stderr, 0); + + assert!(result.contains("result: 7")); + assert!(result.contains("WARNING: deprecated api")); + } + + #[test] + fn test_filter_uv_run_truncates_python_tracebacks() { + let output = r#" +Traceback (most recent call last): + File "/tmp/project/main.py", line 10, in + run() + File "/tmp/project/app.py", line 22, in run + inner() + File "/tmp/project/lib.py", line 33, in inner + boom() + File "/tmp/project/helpers.py", line 44, in boom + raise RuntimeError("kaboom") +RuntimeError: kaboom +"#; + + let result = filter_uv_run_output(output, "", "", 1); + assert!(result.contains("Traceback (most recent call last):")); + assert!(result.contains(r#"File "/tmp/project/main.py", line 10, in "#)); + assert!(result.contains("RuntimeError: kaboom")); + assert!(!result.contains("run()")); + } + + #[test] + fn test_filter_uv_run_truncates_many_python_frames() { + let mut output = String::from("Traceback (most recent call last):\n"); + for i in 0..(MAX_TRACEBACK_FRAMES + 2) { + output.push_str(&format!( + " File \"/tmp/project/module_{i}.py\", line {i}, in call_{i}\n" + )); + output.push_str(" call_next()\n"); + } + output.push_str("RuntimeError: kaboom\n"); + + let result = filter_uv_run_output(&output, "", "", 1); + assert!(result.contains("Traceback (most recent call last):")); + assert!(result.contains("... +2 more frames")); + } + + #[test] + fn test_filter_uv_run_keeps_failure_summary_lines() { + let output = r#" +Resolved 8 packages in 30ms +============================= test session starts ============================= +FAILED tests/test_api.py::test_healthcheck - AssertionError: expected 200 +1 failed, 12 passed in 0.31s +"#; + + let result = filter_uv_run_output(output, "", "", 1); + assert!(result.contains("FAILED tests/test_api.py::test_healthcheck")); + assert!(result.contains("1 failed, 12 passed in 0.31s")); + assert!(!result.contains("Resolved 8 packages")); + } + + #[test] + fn test_filter_uv_run_failure_returns_message_without_added_marker() { + // The exit code is propagated, so the filter must not restate it. + let output = "sync aborted by signal"; + let result = filter_uv_run_output(output, "", "", 2); + + assert_eq!(result, "sync aborted by signal"); + } + + #[test] + fn test_filter_uv_run_silent_failure_emits_nothing() { + assert_eq!(filter_uv_run_output("", "", "", 2), ""); + } + + #[test] + fn test_filter_uv_run_pytest_fixture_token_savings() { + let input = include_str!("../../../tests/fixtures/uv_run_pytest_failure.txt"); + let output = filter_uv_run_output(input, "", "", 1); + let input_tokens = count_tokens(input); + let output_tokens = count_tokens(&output); + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + + assert!( + savings >= 70.0, + "uv run pytest: expected >=70% savings, got {:.1}% ({} -> {} tokens)", + savings, + input_tokens, + output_tokens + ); + assert!(output.contains("FAILED tests/test_users.py::test_normalize_user_rejects_empty")); + assert!(output.contains("1 failed, 1 passed")); + assert!(!output.contains("Downloading cpython")); + } +} diff --git a/src/cmds/rust/cargo_cmd.rs b/src/cmds/rust/cargo_cmd.rs index 061b1bfbde..7dc2a49785 100644 --- a/src/cmds/rust/cargo_cmd.rs +++ b/src/cmds/rust/cargo_cmd.rs @@ -4,8 +4,9 @@ use crate::core::args_utils; use crate::core::runner; use crate::core::stream::{BlockHandler, BlockStreamFilter, StreamFilter}; use crate::core::truncate::{CAP_ERRORS, CAP_LIST, CAP_WARNINGS}; -use crate::core::utils::{resolved_command, truncate}; +use crate::core::utils::{join_with_overflow, resolved_command, truncate}; use anyhow::Result; +use serde::Deserialize; use std::cmp::Ordering; use std::collections::HashMap; use std::ffi::OsString; @@ -39,15 +40,17 @@ struct CargoBuildHandler { warnings: usize, error_count: usize, finished_line: Option, + label: &'static str, } impl CargoBuildHandler { - fn new() -> Self { + fn with_label(label: &'static str) -> Self { Self { compiled: 0, warnings: 0, error_count: 0, finished_line: None, + label, } } } @@ -93,19 +96,28 @@ impl BlockHandler for CargoBuildHandler { !(line.trim().is_empty() && block.len() > 3) } - fn format_summary(&self, _exit_code: i32, _raw: &str) -> Option { - if self.error_count == 0 && self.warnings == 0 { - let mut s = format!("cargo build ({} crates compiled)", self.compiled); - if let Some(ref finished) = self.finished_line { - s = format!("{}\n{}", s, finished); - } - Some(format!("{}\n", s)) - } else { - Some(format!( - "cargo build: {} errors, {} warnings ({} crates)\n", - self.error_count, self.warnings, self.compiled - )) + fn format_summary(&self, exit_code: i32, _raw: &str) -> Option { + if self.error_count == 0 && self.warnings == 0 && exit_code == 0 { + return Some(cargo_build_success_line( + self.compiled, + self.finished_line.as_deref(), + self.label, + )); } + // The streamed path only runs for non-json build/check; error blocks are + // emitted live, so the summary carries no rendered diagnostics. + let empty = JsonDiagnostics { + errors: Vec::new(), + warnings: Vec::new(), + }; + Some(cargo_build_failure_summary( + self.compiled, + self.error_count, + self.warnings, + &empty, + self.label, + exit_code, + )) } } @@ -183,21 +195,23 @@ impl BlockHandler for CargoTestHandler { } fn format_summary(&self, _exit_code: i32, raw: &str) -> Option { - if self.summary_lines.is_empty() && self.has_compile_errors { - let build_filtered = filter_cargo_build(raw); - if build_filtered.starts_with("cargo build:") { - return Some(format!( - "{}\n", - build_filtered.replacen("cargo build:", "cargo test:", 1) - )); + if self.summary_lines.is_empty() { + let json = extract_json_diagnostics(raw); + if self.has_compile_errors || !json.errors.is_empty() { + // Content-based (exit 0): a real compile error yields "cargo test: N + // errors"; a bare "could not compile" leaves the raw tail fallback. + let build_filtered = filter_cargo_build_labeled(raw, "test", 0); + if build_filtered.contains("cargo test:") { + return Some(format!("{}\n", build_filtered)); + } + // Fallback: last 5 meaningful lines + let meaningful: Vec<&str> = raw + .lines() + .filter(|l| !l.trim().is_empty() && !l.trim_start().starts_with("Compiling")) + .collect(); + let last5: Vec<&str> = meaningful.iter().rev().take(5).rev().copied().collect(); + return Some(format!("{}\n", last5.join("\n"))); } - // Fallback: last 5 meaningful lines - let meaningful: Vec<&str> = raw - .lines() - .filter(|l| !l.trim().is_empty() && !l.trim_start().starts_with("Compiling")) - .collect(); - let last5: Vec<&str> = meaningful.iter().rev().take(5).rev().copied().collect(); - return Some(format!("{}\n", last5.join("\n"))); } // No failures emitted โ€” aggregate pass results @@ -271,6 +285,38 @@ where ) } +/// Same as `run_cargo_filtered` but the filter also receives the child exit code, +/// so it can tell a genuine failure from a clean run when no diagnostics parse. +fn run_cargo_filtered_with_exit( + subcommand: &str, + args: &[String], + verbose: u8, + filter_fn: F, +) -> Result +where + F: Fn(&str, i32) -> String, +{ + let mut cmd = resolved_command("cargo"); + cmd.arg(subcommand); + + let restored_args = args_utils::restore_double_dash(args); + for arg in &restored_args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: cargo {} {}", subcommand, restored_args.join(" ")); + } + + runner::run_filtered_with_exit( + cmd, + &format!("cargo {}", subcommand), + &restored_args.join(" "), + filter_fn, + runner::RunOptions::with_tee(&format!("cargo_{}", subcommand)), + ) +} + fn run_cargo_streamed( subcommand: &str, args: &[String], @@ -298,16 +344,40 @@ fn run_cargo_streamed( ) } +fn has_json_message_format(args: &[String]) -> bool { + let mut json = false; + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + if let Some(val) = arg.strip_prefix("--message-format=") { + json = val.contains("json"); + } else if arg == "--message-format" { + if let Some(val) = iter.next() { + json = val.contains("json"); + } + } + } + json +} + fn run_build(args: &[String], verbose: u8) -> Result { + if has_json_message_format(args) { + return run_cargo_filtered_with_exit("build", args, verbose, |o, exit| { + filter_cargo_build_labeled(o, "build", exit) + }); + } run_cargo_streamed( "build", args, verbose, - Box::new(BlockStreamFilter::new(CargoBuildHandler::new())), + Box::new(BlockStreamFilter::new(CargoBuildHandler::with_label("build"))), ) } fn run_test(args: &[String], verbose: u8) -> Result { + // No json branch here on purpose: --message-format=json only reformats the + // build phase, the test harness output stays human-readable. CargoTestHandler + // reads both โ€” it aggregates the `test result:` lines and, on a compile error, + // parses the json diagnostics in format_summary. run_cargo_streamed( "test", args, @@ -317,15 +387,23 @@ fn run_test(args: &[String], verbose: u8) -> Result { } fn run_clippy(args: &[String], verbose: u8) -> Result { + if has_json_message_format(args) { + return run_cargo_filtered_with_exit("clippy", args, verbose, filter_cargo_clippy_json); + } run_cargo_filtered("clippy", args, verbose, filter_cargo_clippy) } fn run_check(args: &[String], verbose: u8) -> Result { + if has_json_message_format(args) { + return run_cargo_filtered_with_exit("check", args, verbose, |o, exit| { + filter_cargo_build_labeled(o, "check", exit) + }); + } run_cargo_streamed( "check", args, verbose, - Box::new(BlockStreamFilter::new(CargoBuildHandler::new())), + Box::new(BlockStreamFilter::new(CargoBuildHandler::with_label("check"))), ) } @@ -757,8 +835,134 @@ fn filter_cargo_nextest(output: &str) -> String { String::new() } +struct JsonDiagnostics { + errors: Vec, + warnings: Vec, +} + +#[derive(Deserialize)] +struct CargoJsonLine { + reason: String, + message: Option, +} + +#[derive(Deserialize)] +struct CargoDiagnostic { + level: String, + #[serde(default)] + message: String, + rendered: Option, +} + +fn extract_json_diagnostics(raw: &str) -> JsonDiagnostics { + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + for line in raw.lines() { + let line = line.trim_start(); + if !line.starts_with('{') { + continue; + } + let Ok(entry) = serde_json::from_str::(line) else { + continue; + }; + if entry.reason != "compiler-message" { + continue; + } + let Some(msg) = entry.message else { + continue; + }; + let bucket = match msg.level.as_str() { + "error" | "error: internal compiler error" => &mut errors, + "warning" => &mut warnings, + _ => continue, + }; + if msg.message.starts_with("aborting due to") + || msg.message.starts_with("could not compile") + || (msg.message.contains("warning") && msg.message.contains("generated")) + { + continue; + } + if let Some(rendered) = msg.rendered { + bucket.push(crate::core::utils::strip_ansi(&rendered).trim_end().to_string()); + } else if !msg.message.is_empty() { + bucket.push(msg.message); + } + } + JsonDiagnostics { errors, warnings } +} + +fn merge_diag_counts(error_count: usize, warnings: usize, json: &JsonDiagnostics) -> (usize, usize) { + ( + error_count.max(json.errors.len()), + warnings.max(json.warnings.len()), + ) +} + +fn cargo_build_success_line(compiled: usize, finished: Option<&str>, label: &str) -> String { + match finished { + Some(f) => format!("cargo {} ({} crates compiled)\n{}\n", label, compiled, f), + None => format!("cargo {} ({} crates compiled)\n", label, compiled), + } +} + +fn cargo_build_failure_summary( + compiled: usize, + errors: usize, + warnings: usize, + json: &JsonDiagnostics, + label: &str, + exit_code: i32, +) -> String { + let mut out = if errors == 0 && warnings == 0 { + format!("cargo {}: failed (exit {})\n", label, exit_code) + } else { + format!( + "cargo {}: {} errors, {} warnings ({} crates)\n", + label, errors, warnings, compiled + ) + }; + if !json.errors.is_empty() { + let shown: Vec = json.errors.iter().take(CAP_ERRORS).cloned().collect(); + out.push_str(&join_with_overflow( + &shown, + json.errors.len(), + CAP_ERRORS, + "errors", + )); + out.push('\n'); + } + if !json.warnings.is_empty() { + let shown: Vec = json.warnings.iter().take(CAP_WARNINGS).cloned().collect(); + out.push_str(&join_with_overflow( + &shown, + json.warnings.len(), + CAP_WARNINGS, + "warnings", + )); + out.push('\n'); + } + if json.errors.len() > CAP_ERRORS || json.warnings.len() > CAP_WARNINGS { + let full = json + .errors + .iter() + .chain(json.warnings.iter()) + .map(String::as_str) + .collect::>() + .join("\n\n"); + if let Some(hint) = crate::core::tee::force_tee_hint(&full, "cargo-json-issues") { + out.push_str(&format!(" {}\n", hint)); + } + } + out +} + +#[cfg(test)] fn filter_cargo_build(output: &str) -> String { - let mut handler = CargoBuildHandler::new(); + filter_cargo_build_labeled(output, "build", 0) +} + +fn filter_cargo_build_labeled(output: &str, label: &'static str, exit_code: i32) -> String { + let mut handler = CargoBuildHandler::with_label(label); let mut blocks: Vec> = Vec::new(); let mut current_block: Vec = Vec::new(); let mut in_block = false; @@ -786,18 +990,15 @@ fn filter_cargo_build(output: &str) -> String { blocks.push(current_block); } - if handler.error_count == 0 && handler.warnings == 0 { - let mut s = format!("cargo build ({} crates compiled)", handler.compiled); - if let Some(ref finished) = handler.finished_line { - s = format!("{}\n{}", s, finished); - } - return s; + let json = extract_json_diagnostics(output); + let (errors, warnings) = merge_diag_counts(handler.error_count, handler.warnings, &json); + + if errors == 0 && warnings == 0 && exit_code == 0 { + return cargo_build_success_line(handler.compiled, handler.finished_line.as_deref(), label); } - let mut result = format!( - "cargo build: {} errors, {} warnings ({} crates)\n", - handler.error_count, handler.warnings, handler.compiled - ); + let mut result = + cargo_build_failure_summary(handler.compiled, errors, warnings, &json, label, exit_code); const MAX_CHECK_BLOCKS: usize = CAP_ERRORS; for (i, blk) in blocks.iter().enumerate().take(MAX_CHECK_BLOCKS) { result.push_str(&blk.join("\n")); @@ -1031,15 +1232,17 @@ pub(crate) fn filter_cargo_test(output: &str) -> String { } if result.trim().is_empty() { - let has_compile_errors = output.lines().any(|line| { - let trimmed = line.trim_start(); - trimmed.starts_with("error[") || trimmed.starts_with("error:") - }); + let json = extract_json_diagnostics(output); + let has_compile_errors = !json.errors.is_empty() + || output.lines().any(|line| { + let trimmed = line.trim_start(); + trimmed.starts_with("error[") || trimmed.starts_with("error:") + }); if has_compile_errors { - let build_filtered = filter_cargo_build(output); - if build_filtered.starts_with("cargo build:") { - return build_filtered.replacen("cargo build:", "cargo test:", 1); + let build_filtered = filter_cargo_build_labeled(output, "test", 0); + if build_filtered.contains("cargo test:") { + return build_filtered; } } @@ -1227,6 +1430,14 @@ fn filter_cargo_clippy(output: &str) -> String { result.trim().to_string() } +fn filter_cargo_clippy_json(output: &str, exit_code: i32) -> String { + let json = extract_json_diagnostics(output); + if json.errors.is_empty() && json.warnings.is_empty() && exit_code == 0 { + return "cargo clippy: No issues found".to_string(); + } + filter_cargo_build_labeled(output, "clippy", exit_code) +} + pub fn run_passthrough(args: &[OsString], verbose: u8) -> Result { crate::core::runner::run_passthrough("cargo", args, verbose) } @@ -2103,13 +2314,31 @@ error: test run failed #[test] fn test_cargo_build_stream_success() { let input = " Compiling libc v0.2.153\n Compiling cfg-if v1.0.0\n Compiling rtk v0.5.0\n Finished dev [unoptimized + debuginfo] target(s) in 15.23s\n"; - let mut f = BlockStreamFilter::new(CargoBuildHandler::new()); + let mut f = BlockStreamFilter::new(CargoBuildHandler::with_label("build")); let result = run_block_filter(&mut f, input, 0); assert!(result.contains("3 crates compiled"), "got: {}", result); assert!(result.contains("Finished"), "got: {}", result); assert!(!result.contains("Compiling"), "got: {}", result); } + #[test] + fn test_cargo_build_stream_json_success() { + let input = concat!( + " Compiling demo v0.1.0 (/tmp/demo)\n", + r#"{"reason":"compiler-artifact","package_id":"demo 0.1.0","target":{"name":"demo"},"executable":"/tmp/demo/target/debug/demo"}"#, + "\n", + " Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.39s\n", + r#"{"reason":"build-finished","success":true}"#, + "\n", + ); + let mut f = BlockStreamFilter::new(CargoBuildHandler::with_label("build")); + let result = run_block_filter(&mut f, input, 0); + assert!(result.contains("1 crates compiled"), "got: {}", result); + assert!(result.contains("Finished"), "got: {}", result); + assert!(!result.contains("error"), "got: {}", result); + assert!(!result.contains("Compiling"), "got: {}", result); + } + #[test] fn test_cargo_build_stream_errors() { let input = r#" Compiling rtk v0.5.0 @@ -2121,7 +2350,7 @@ error[E0308]: mismatched types error: aborting due to 1 previous error "#; - let mut f = BlockStreamFilter::new(CargoBuildHandler::new()); + let mut f = BlockStreamFilter::new(CargoBuildHandler::with_label("build")); let result = run_block_filter(&mut f, input, 1); assert!(result.contains("E0308"), "got: {}", result); assert!(result.contains("mismatched types"), "got: {}", result); @@ -2129,6 +2358,332 @@ error: aborting due to 1 previous error assert!(!result.contains("aborting"), "got: {}", result); } + #[test] + fn test_filter_cargo_build_json_warning_rendered() { + let input = concat!( + " Compiling v_cargo v0.1.0 (/tmp/v_cargo)\n", + r#"{"reason":"compiler-message","package_id":"v_cargo 0.1.0","message":{"level":"warning","message":"unused variable: `x`","rendered":"warning: unused variable: `x`\n --> src/main.rs:2:9"}}"#, + "\n", + r#"{"reason":"build-finished","success":true}"#, + "\n", + ); + let result = filter_cargo_build_labeled(input, "build", 0); + assert!(result.contains("unused variable"), "got: {}", result); + assert!(result.contains("1 warnings"), "got: {}", result); + assert!(!result.contains("crates compiled"), "got: {}", result); + } + + #[test] + fn test_cargo_check_stream_success_label() { + let input = " Checking demo v0.1.0 (/tmp/demo)\n Finished dev [unoptimized + debuginfo] target(s) in 0.42s\n"; + let mut f = BlockStreamFilter::new(CargoBuildHandler::with_label("check")); + let result = run_block_filter(&mut f, input, 0); + assert!(result.contains("cargo check"), "got: {}", result); + assert!(!result.contains("cargo build"), "got: {}", result); + } + + #[test] + fn test_filter_cargo_build_labeled_clippy_json_not_clean() { + let input = concat!( + " Checking demo v0.1.0 (/tmp/demo)\n", + r#"{"reason":"compiler-message","message":{"code":{"code":"E0308"},"level":"error","message":"mismatched types","rendered":"error[E0308]: mismatched types\n --> src/main.rs:2:18"}}"#, + "\n", + r#"{"reason":"build-finished","success":false}"#, + "\n", + ); + let result = filter_cargo_build_labeled(input, "clippy", 1); + assert!(result.contains("cargo clippy:"), "got: {}", result); + assert!(result.contains("1 errors"), "got: {}", result); + assert!( + !result.contains("No issues found"), + "json clippy errors must not be swallowed: {}", + result + ); + } + + #[test] + fn test_filter_cargo_clippy_json_clean() { + let input = concat!( + " Checking demo v0.1.0 (/tmp/demo)\n", + r#"{"reason":"compiler-artifact","package_id":"demo 0.1.0","target":{"name":"demo"}}"#, + "\n", + r#"{"reason":"build-finished","success":true}"#, + "\n", + ); + assert_eq!( + filter_cargo_clippy_json(input, 0), + "cargo clippy: No issues found" + ); + } + + #[test] + fn test_filter_cargo_clippy_json_warnings() { + let input = concat!( + " Checking demo v0.1.0 (/tmp/demo)\n", + r#"{"reason":"compiler-message","message":{"level":"warning","message":"unused variable: `x`","rendered":"warning: unused variable: `x`\n --> src/main.rs:2:9"}}"#, + "\n", + r#"{"reason":"build-finished","success":true}"#, + "\n", + ); + let result = filter_cargo_clippy_json(input, 0); + assert!(result.contains("unused variable"), "got: {}", result); + assert!(result.contains("cargo clippy: 0 errors, 1 warnings"), "got: {}", result); + } + + #[test] + fn test_batch_filter_nonzero_exit_without_diagnostics_is_not_compiled() { + // build-script failure: exit 101, no compiler-message diagnostics parsed. + let input = concat!( + " Compiling demo v0.1.0 (/tmp/demo)\n", + r#"{"reason":"build-finished","success":false}"#, + "\n", + ); + let result = filter_cargo_build_labeled(input, "build", 101); + assert!( + !result.contains("crates compiled"), + "a failed build must not be reported as compiled: {}", + result + ); + assert!(result.contains("cargo build: failed (exit 101)"), "got: {}", result); + } + + #[test] + fn test_failure_summary_line_is_first() { + let json = JsonDiagnostics { + errors: vec!["error[E0308]: mismatched types".to_string()], + warnings: Vec::new(), + }; + let out = cargo_build_failure_summary(1, 1, 0, &json, "build", 101); + assert!( + out.starts_with("cargo build: 1 errors"), + "summary line must come first: {}", + out + ); + } + + #[test] + fn test_filter_cargo_build_labeled_check() { + let input = concat!( + r#"{"reason":"compiler-message","message":{"level":"error","message":"mismatched types","rendered":"error[E0308]: mismatched types\n --> src/main.rs:2:18"}}"#, + "\n", + r#"{"reason":"build-finished","success":false}"#, + "\n", + ); + let result = filter_cargo_build_labeled(input, "check", 1); + assert!(result.contains("cargo check:"), "got: {}", result); + assert!(!result.contains("cargo build:"), "got: {}", result); + } + + #[test] + fn test_extract_json_diagnostics_strips_ansi() { + let output = concat!( + r#"{"reason":"compiler-message","message":{"level":"error","message":"mismatched types","rendered":"\u001b[1m\u001b[31merror[E0308]\u001b[0m: mismatched types"}}"#, + "\n", + r#"{"reason":"build-finished","success":false}"#, + "\n", + ); + let json = extract_json_diagnostics(output); + assert_eq!(json.errors.len(), 1); + assert!( + !json.errors[0].contains('\u{1b}'), + "ansi escapes must be stripped: {:?}", + json.errors[0] + ); + assert!( + json.errors[0].contains("error[E0308]"), + "got: {:?}", + json.errors[0] + ); + } + + + #[test] + fn test_extract_json_diagnostics_skips_generated_summary() { + let output = concat!( + r#"{"reason":"compiler-message","message":{"level":"warning","message":"unused variable: `x`","rendered":"warning: unused variable: `x`"}}"#, + "\n", + r#"{"reason":"compiler-message","message":{"level":"warning","message":"`demo` (bin \"demo\") generated 1 warning","rendered":"warning: `demo` (bin \"demo\") generated 1 warning"}}"#, + "\n", + r#"{"reason":"build-finished","success":true}"#, + "\n", + ); + let json = extract_json_diagnostics(output); + assert_eq!(json.warnings.len(), 1, "generated summary must not inflate the count"); + assert!( + !json.warnings.iter().any(|w| w.contains("generated")), + "the generated summary line must not appear in the output" + ); + } + + #[test] + fn test_extract_json_diagnostics_counts_ice_as_error() { + let output = concat!( + r#"{"reason":"compiler-message","message":{"level":"error: internal compiler error","message":"unexpected panic","rendered":"error: internal compiler error: unexpected panic"}}"#, + "\n", + r#"{"reason":"build-finished","success":false}"#, + "\n", + ); + let json = extract_json_diagnostics(output); + assert_eq!(json.errors.len(), 1, "an ICE must count as an error"); + assert!(json.errors[0].contains("internal compiler error"), "got: {:?}", json.errors[0]); + } + + #[test] + fn test_json_format_inline_eq() { + assert!(has_json_message_format(&["--message-format=json".into()])); + } + + #[test] + fn test_json_format_space_separated() { + assert!(has_json_message_format(&[ + "--message-format".into(), + "json".into(), + ])); + } + + #[test] + fn test_json_format_rendered_ansi() { + assert!(has_json_message_format(&[ + "--message-format=json-diagnostic-rendered-ansi".into() + ])); + } + + #[test] + fn test_json_format_render_diagnostics() { + assert!(has_json_message_format(&[ + "--message-format=json-render-diagnostics".into() + ])); + } + + #[test] + fn test_json_format_space_rendered() { + assert!(has_json_message_format(&[ + "--message-format".into(), + "json-diagnostic-rendered-ansi".into(), + ])); + } + + #[test] + fn test_non_json_format_not_detected() { + assert!(!has_json_message_format(&["--message-format=human".into()])); + } + + #[test] + fn test_no_format_flag() { + assert!(!has_json_message_format(&[ + "--release".into(), + "-p".into(), + "my-crate".into(), + ])); + } + + #[test] + fn test_json_format_among_other_args() { + assert!(has_json_message_format(&[ + "--release".into(), + "-p".into(), + "my-crate".into(), + "--message-format=json".into(), + ])); + } + + #[test] + fn test_json_format_trailing_message_format_no_value() { + assert!(!has_json_message_format(&["--message-format".into()])); + } + + #[test] + fn test_json_format_last_occurrence_wins() { + assert!(!has_json_message_format(&[ + "--message-format=json".into(), + "--message-format=human".into(), + ])); + assert!(has_json_message_format(&[ + "--message-format=human".into(), + "--message-format=json".into(), + ])); + } + + #[test] + fn test_filter_cargo_build_json_failure_not_compiled() { + let output = concat!( + r#"{"reason":"compiler-message","message":{"rendered":"error[E0277]: the trait bound is not satisfied","level":"error","message":"trait bound"}}"#, + "\n", + r#"{"reason":"build-finished","success":false}"#, + "\n", + ); + let result = filter_cargo_build(output); + assert!(result.contains("E0277"), "got: {}", result); + assert!(result.contains("1 errors"), "got: {}", result); + assert!(!result.contains("crates compiled"), "got: {}", result); + } + + #[test] + fn test_filter_cargo_build_json_errors_capped() { + let total = CAP_ERRORS + 5; + let mut output = String::new(); + for i in 0..total { + output.push_str(&format!( + r#"{{"reason":"compiler-message","message":{{"code":{{"code":"E0308"}},"level":"error","message":"mismatched types","rendered":"error[E0308]: mismatched types ({})"}}}}"#, + i + )); + output.push('\n'); + } + output.push_str(r#"{"reason":"build-finished","success":false}"#); + output.push('\n'); + + let result = filter_cargo_build(&output); + let rendered = result.matches("error[E0308]").count(); + assert_eq!(rendered, CAP_ERRORS, "json errors must be capped: {}", result); + assert!( + result.contains(&format!("โ€ฆ +{} more errors", total - CAP_ERRORS)), + "expected overflow hint: {}", + result + ); + assert!( + result.contains(&format!("{} errors", total)), + "summary must report the real total: {}", + result + ); + } + + #[test] + fn test_filter_cargo_build_json_savings() { + // Real --message-format=json lines carry a verbose envelope (spans, + // children, code, message) around the human `rendered` text. The filter + // keeps only `rendered` and caps the list, so savings come from both + // dropping the envelope and capping a large failing build. + let template = r#"{"reason":"compiler-message","package_id":"demo 0.1.0 (path+file:///tmp/demo)","manifest_path":"/tmp/demo/Cargo.toml","target":{"kind":["bin"],"crate_types":["bin"],"name":"demo","src_path":"/tmp/demo/src/main.rs","edition":"2021","doc":true,"doctest":false,"test":true},"message":{"rendered":"error[E0308]: mismatched types\n --> src/main.rs:IDX:18\n |\nIDX | let _xIDX: i32 = \"errIDX\";\n | --- ^^^^^^^ expected `i32`, found `&str`\n | |\n | expected due to this\n\n","$message_type":"diagnostic","children":[{"children":[],"code":null,"level":"note","message":"expected due to the type annotation here","rendered":null,"spans":[]}],"code":{"code":"E0308","explanation":null},"level":"error","message":"mismatched types","spans":[{"byte_end":40,"byte_start":33,"column_end":25,"column_start":18,"expansion":null,"file_name":"src/main.rs","is_primary":true,"label":"expected `i32`, found `&str`","line_end":IDX,"line_start":IDX,"suggested_replacement":null,"suggestion_applicability":null,"text":[{"highlight_end":25,"highlight_start":18,"text":" let _xIDX: i32 = \"errIDX\";"}]}]}}"#; + + let total = CAP_ERRORS + 30; + let mut input = String::new(); + for i in 0..total { + input.push_str(&template.replace("IDX", &i.to_string())); + input.push('\n'); + } + input.push_str(r#"{"reason":"build-finished","success":false}"#); + input.push('\n'); + + let result = filter_cargo_build(&input); + + // The cap is what bounds the output, so assert it holds here too. + let rendered = result.matches("error[E0308]").count(); + assert_eq!(rendered, CAP_ERRORS, "json errors must be capped: {}", result); + assert!( + result.contains(&format!("โ€ฆ +{} more errors", total - CAP_ERRORS)), + "expected overflow hint: {}", + result + ); + + let raw = input.split_whitespace().count(); + let out = result.split_whitespace().count(); + let savings = 100.0 - (out as f64 / raw as f64) * 100.0; + assert!( + savings >= 60.0, + "token savings dropped below 60%: {savings:.1}%" + ); + } + #[test] fn test_cargo_test_stream_all_pass() { let input = r#" Compiling rtk v0.5.0 @@ -2213,4 +2768,40 @@ error: could not compile `rtk` (test "repro_compile_fail") due to 1 previous err assert!(result.contains("cargo test:"), "got: {}", result); assert!(result.contains("1 errors"), "got: {}", result); } + + #[test] + fn test_cargo_test_stream_json_compile_error() { + let input = concat!( + " Compiling v_cargo v0.1.0 (/tmp/v_cargo)\n", + r#"{"reason":"compiler-message","package_id":"v_cargo 0.1.0","message":{"code":{"code":"E0425"},"level":"error","message":"cannot find value `missing_symbol` in this scope","rendered":"error[E0425]: cannot find value `missing_symbol` in this scope\n --> tests/repro.rs:3:13"}}"#, + "\n", + r#"{"reason":"build-finished","success":false}"#, + "\n", + ); + let mut f = BlockStreamFilter::new(CargoTestHandler::new()); + let result = run_block_filter(&mut f, input, 101); + assert!(!result.trim().is_empty(), "json compile error must not be silent"); + assert!(result.contains("cargo test:"), "got: {}", result); + assert!(result.contains("1 errors"), "got: {}", result); + assert!(result.contains("E0425"), "diagnostic must be surfaced: {}", result); + } + + #[test] + fn test_cargo_test_stream_no_diagnostic_falls_back_to_raw() { + // "could not compile" sets has_compile_errors but is skipped by the build + // re-scan, so the reused filter yields a success-shaped line. We must not + // trust it โ€” fall back to the raw tail instead of a bogus "compiled". + let input = concat!( + " Compiling foo v0.1.0 (/tmp/foo)\n", + "error: could not compile `foo` (test \"bar\") due to previous error\n", + ); + let mut f = BlockStreamFilter::new(CargoTestHandler::new()); + let result = run_block_filter(&mut f, input, 101); + assert!( + !result.contains("crates compiled"), + "must not report a failed test run as compiled: {}", + result + ); + assert!(result.contains("could not compile"), "got: {}", result); + } } diff --git a/src/cmds/scala/mod.rs b/src/cmds/scala/mod.rs new file mode 100644 index 0000000000..56b8f5bf66 --- /dev/null +++ b/src/cmds/scala/mod.rs @@ -0,0 +1 @@ +automod::dir!(pub "src/cmds/scala"); diff --git a/src/cmds/scala/sbt_cmd.rs b/src/cmds/scala/sbt_cmd.rs new file mode 100644 index 0000000000..9751807cac --- /dev/null +++ b/src/cmds/scala/sbt_cmd.rs @@ -0,0 +1,841 @@ +use crate::core::runner::{self, RunOptions}; +use crate::core::utils::{resolved_command, truncate}; +use anyhow::Result; +use lazy_static::lazy_static; +use regex::Regex; +use std::ffi::OsString; + +lazy_static! { + /// Matches the ScalaTest summary line: + /// Tests: succeeded N, failed N, canceled N, ignored N, pending N + static ref TEST_SUMMARY_RE: Regex = Regex::new( + r"Tests: succeeded (\d+), failed (\d+), canceled (\d+), ignored (\d+), pending (\d+)" + ).unwrap(); + + /// Matches the munit summary line (also used by discipline-munit / ZIO Test): + /// [info] Passed: Total N, Failed N, Errors N, Passed N + /// [info] Failed: Total N, Failed N, Errors N, Passed N + static ref MUNIT_SUMMARY_RE: Regex = Regex::new( + r"^\[info\] (?:Passed|Failed): Total \d+, Failed (\d+), Errors (\d+), Passed (\d+)" + ).unwrap(); + + /// Matches suite count line: + /// Suites: completed N, aborted N + static ref SUITE_SUMMARY_RE: Regex = Regex::new( + r"Suites: completed (\d+), aborted (\d+)" + ).unwrap(); + + /// Matches the "Run completed in" timing line + static ref RUN_TIME_RE: Regex = Regex::new( + r"Run completed in (\d+) seconds?" + ).unwrap(); + + /// Matches [info] Compiling N Scala source(s) + static ref COMPILE_COUNT_RE: Regex = Regex::new( + r"\[info\] Compiling (\d+) Scala source" + ).unwrap(); + + /// Matches [success] Total time: Ns + static ref SUCCESS_TIME_RE: Regex = Regex::new( + r"\[success\] Total time: (\d+) s" + ).unwrap(); + + /// Matches [error] lines + static ref ERROR_RE: Regex = Regex::new( + r"^\[error\]" + ).unwrap(); + + /// Lines that are SBT noise (loading, resolving, downloading, etc.) + static ref NOISE_RE: Regex = Regex::new( + r"^\[info\] (welcome to sbt|loading |set current project|Updating |Resolved |Fetching |downloading |Done )" + ).unwrap(); +} + +/// Integration test subcommand patterns (sbt configuration/task notation). +/// These produce ScalaTest output and should use the same filtering as `sbt test`. +fn is_integration_test_cmd(subcommand: &str) -> bool { + matches!( + subcommand, + "it:test" | "IntegrationTest/test" | "integration-test/test" + ) || (subcommand.ends_with(":test") || subcommand.ends_with("/test")) +} + +fn is_test_task(subcommand: &str) -> bool { + let task = subcommand.split_whitespace().next().unwrap_or(subcommand); + let task = task.rsplit(['/', ':']).next().unwrap_or(task); + matches!(task, "testOnly" | "testQuick") +} + +/// Returns true if `s` is a scoped SBT task (e.g. `Test/test`, `it/Test/compile`). +fn is_scoped_task(s: &str) -> bool { + !s.starts_with('-') && (s.contains('/') || s.contains(':')) +} + +/// Run an SBT task through the shared runner (never_worse cap, tee hint, tracking, +/// and exit-code propagation all handled centrally). +/// +/// A scoped task passed as the first arg (e.g. `Test/test`, `exampleJVM/run`) is used +/// verbatim so sbt runs the right configuration scope; otherwise `default_task` is used. +fn run_task( + args: &[String], + default_task: &str, + filter: fn(&str) -> String, + tee_label: &str, + verbose: u8, +) -> Result { + let mut cmd = resolved_command("sbt"); + + let (sbt_task, rest) = match args.first() { + Some(a) if is_scoped_task(a) => (a.as_str(), &args[1..]), + _ => (default_task, args), + }; + cmd.arg(sbt_task); + for arg in rest { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: sbt {} {}", sbt_task, rest.join(" ")); + } + + let args_display = if rest.is_empty() { + sbt_task.to_string() + } else { + format!("{} {}", sbt_task, rest.join(" ")) + }; + + runner::run_filtered( + cmd, + "sbt", + &args_display, + filter, + RunOptions::with_tee(tee_label), + ) +} + +pub fn run_test(args: &[String], verbose: u8) -> Result { + run_task(args, "test", filter_sbt_test, "sbt_test", verbose) +} + +pub fn run_compile(args: &[String], verbose: u8) -> Result { + run_task(args, "compile", filter_sbt_compile, "sbt_compile", verbose) +} + +pub fn run_run(args: &[String], verbose: u8) -> Result { + run_task(args, "run", filter_sbt_run, "sbt_run", verbose) +} + +pub fn run_other(args: &[OsString], verbose: u8) -> Result { + if args.is_empty() { + anyhow::bail!("sbt: no subcommand specified"); + } + + let subcommand = args[0].to_string_lossy().into_owned(); + + // Integration test commands (it:test, IntegrationTest/test, etc.) produce standard + // ScalaTest output โ€” filter them like `sbt test`, through the shared runner so the + // never_worse cap and tee hint apply (an unrecognized output would otherwise be + // reprinted verbatim plus the hint, i.e. more than the raw command produced). + if is_integration_test_cmd(&subcommand) || is_test_task(&subcommand) { + let mut cmd = resolved_command("sbt"); + cmd.arg(&subcommand); + for arg in &args[1..] { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: sbt {} ...", subcommand); + } + + let tee_label = if is_integration_test_cmd(&subcommand) { + "sbt_it_test" + } else { + "sbt_test" + }; + + let rest: Vec = args[1..] + .iter() + .map(|a| a.to_string_lossy().into_owned()) + .collect(); + let args_display = if rest.is_empty() { + subcommand + } else { + format!("{} {}", subcommand, rest.join(" ")) + }; + + return runner::run_filtered( + cmd, + "sbt", + &args_display, + filter_sbt_test, + RunOptions::with_tee(tee_label), + ); + } + + // Any other subcommand: pass through unfiltered (still tracked, exit code propagated). + runner::run_passthrough("sbt", args, verbose) +} + +/// A single test failure with its name and detail lines captured from the output. +struct FailureBlock { + name: String, + details: Vec, +} + +/// Filter SBT test output (ScalaTest format). +/// +/// On success: compact single-line summary. +/// On failure: show each failed test with its detail lines (works for native +/// ScalaTest assertion failures, Mockito Scala verification failures, and +/// ScalaMock expectation failures โ€” all of which emit details as [info] lines). +fn filter_sbt_test(output: &str) -> String { + let mut succeeded: u32 = 0; + let mut failed: u32 = 0; + let mut ignored: u32 = 0; + let mut canceled: u32 = 0; + let mut pending: u32 = 0; + let mut suites: u32 = 0; + let mut run_time_secs: Option = None; + let mut has_summary = false; + + let mut failures: Vec = Vec::new(); + let mut failed_suites: Vec = Vec::new(); + let mut error_lines: Vec = Vec::new(); + // true while we are inside the detail block of a failed test + let mut in_failure_detail = false; + + for line in output.lines() { + let trimmed = line.trim(); + + // --- Summary lines (always reset failure-detail mode) --- + + if let Some(caps) = TEST_SUMMARY_RE.captures(trimmed) { + succeeded = caps[1].parse().unwrap_or(0); + failed = caps[2].parse().unwrap_or(0); + canceled = caps[3].parse().unwrap_or(0); + ignored = caps[4].parse().unwrap_or(0); + pending = caps[5].parse().unwrap_or(0); + has_summary = true; + in_failure_detail = false; + continue; + } + if let Some(caps) = MUNIT_SUMMARY_RE.captures(trimmed) { + let munit_failed: u32 = caps[1].parse().unwrap_or(0); + let errors: u32 = caps[2].parse().unwrap_or(0); + succeeded = caps[3].parse().unwrap_or(0); + failed = munit_failed + errors; + has_summary = true; + in_failure_detail = false; + continue; + } + if let Some(caps) = SUITE_SUMMARY_RE.captures(trimmed) { + suites = caps[1].parse().unwrap_or(0); + in_failure_detail = false; + continue; + } + if let Some(caps) = RUN_TIME_RE.captures(trimmed) { + run_time_secs = caps[1].parse().ok(); + in_failure_detail = false; + continue; + } + + // --- Failed test header: "- test name *** FAILED ***" --- + + if trimmed.contains("*** FAILED ***") { + let name = trimmed + .strip_suffix(" *** FAILED ***") + .unwrap_or(trimmed) + .strip_prefix("[info]") + .unwrap_or(trimmed) + .trim() + .trim_start_matches('-') + .trim() + .to_string(); + failures.push(FailureBlock { name, details: Vec::new() }); + in_failure_detail = true; + continue; + } + + // --- Detail lines inside a failure block --- + // + // ScalaTest places failure details as [info] lines with deeper + // indentation (4+ spaces after "[info]"). This covers: + // - native assertion messages ("42 was not equal to 43") + // - Mockito verification msgs ("org.mockito.exceptions.verification...") + // - ScalaMock expectation msgs ("Unexpected call: ...") + // + // A line with shallower indentation (new test case or section header) + // signals the end of the detail block. + + if in_failure_detail { + if let Some(after_info) = trimmed.strip_prefix("[info]") { + if after_info.starts_with(" ") { + let detail = after_info.trim(); + if !detail.is_empty() { + // Skip raw JVM stack frames โ€” they add noise without signal. + // Keep Mockito "-> at" pointers and ScalaMock locations + // (they include the file:line reference). + let is_stack_frame = detail.starts_with("at ") + || detail.starts_with("..."); + if !is_stack_frame { + if let Some(block) = failures.last_mut() { + if block.details.len() < 4 { + block.details.push(detail.to_string()); + } + } + } + } + continue; + } else { + // Shallower indentation โ†’ back to normal test output + in_failure_detail = false; + } + } else { + in_failure_detail = false; + } + } + + // --- [error] lines: collect failed suite names, drop sbt boilerplate --- + + if ERROR_RE.is_match(trimmed) { + let text = trimmed.strip_prefix("[error] ").unwrap_or(trimmed).trim(); + if text.is_empty() + || text.starts_with("Total time:") + || text.contains("TestsFailedException") + || text.contains("compileIncremental") + { + continue; + } + // "Failed tests:" header + class names โ†’ collect separately + if text == "Failed tests:" { + continue; // the header is implicit from context + } + if text.starts_with("com.") || text.starts_with("org.") || text.starts_with(" ") { + failed_suites.push(text.trim_start().to_string()); + } else { + error_lines.push(text.to_string()); + } + } + } + + // --- Fallback: no summary line found --- + + if !has_summary { + if !error_lines.is_empty() { + let mut result = String::from("sbt test: errors\n"); + result.push_str("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•\n"); + for line in error_lines.iter().take(20) { + result.push_str(&format!(" {}\n", truncate(line, 120))); + } + return result.trim().to_string(); + } + if output.trim().is_empty() { + return String::new(); + } + return output.to_string(); + } + + let time_str = run_time_secs.map(|s| format!("{}s", s)).unwrap_or_default(); + + // --- All passed --- + + if failed == 0 && canceled == 0 { + let mut summary = format!("sbt test: {} passed", succeeded); + if ignored > 0 { + summary.push_str(&format!(", {} ignored", ignored)); + } + if pending > 0 { + summary.push_str(&format!(", {} pending", pending)); + } + if suites > 0 { + summary.push_str(&format!(" ({} suites", suites)); + if !time_str.is_empty() { + summary.push_str(&format!(", {}", time_str)); + } + summary.push(')'); + } else if !time_str.is_empty() { + summary.push_str(&format!(" ({})", time_str)); + } + return summary; + } + + // --- Failures present --- + + let mut result = format!("sbt test: {} passed, {} failed", succeeded, failed); + if canceled > 0 { + result.push_str(&format!(", {} canceled", canceled)); + } + if ignored > 0 { + result.push_str(&format!(", {} ignored", ignored)); + } + if !time_str.is_empty() { + result.push_str(&format!(" ({})", time_str)); + } + result.push('\n'); + result.push_str("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•\n"); + + for block in &failures { + result.push_str(&format!(" [FAIL] {}\n", truncate(&block.name, 120))); + for detail in &block.details { + result.push_str(&format!(" {}\n", truncate(detail, 120))); + } + } + + // Failed suite class names (useful for navigation) + if !failed_suites.is_empty() { + result.push('\n'); + for suite in &failed_suites { + result.push_str(&format!(" {}\n", suite)); + } + } + + // Any remaining [error] lines (e.g. build-level errors) + if !error_lines.is_empty() { + result.push('\n'); + for line in error_lines.iter().take(10) { + result.push_str(&format!(" {}\n", truncate(line, 120))); + } + } + + result.trim().to_string() +} + +/// Filter SBT compile output. +/// +/// On success: compact summary with source count and time. +/// On failure: show all [error] lines. +fn filter_sbt_compile(output: &str) -> String { + // Nothing in, nothing out (Transparency: never emit tokens the command didn't). + if output.trim().is_empty() { + return String::new(); + } + + let mut source_count: u32 = 0; + let mut total_time_secs: Option = None; + let mut error_lines: Vec = Vec::new(); + let mut has_success = false; + + for line in output.lines() { + let trimmed = line.trim(); + + // Count compiled sources + if let Some(caps) = COMPILE_COUNT_RE.captures(trimmed) { + source_count += caps[1].parse::().unwrap_or(0); + continue; + } + + // Parse success time + if let Some(caps) = SUCCESS_TIME_RE.captures(trimmed) { + total_time_secs = caps[1].parse().ok(); + has_success = true; + continue; + } + + // Collect [error] lines + if ERROR_RE.is_match(trimmed) { + let error_text = trimmed.strip_prefix("[error] ").unwrap_or(trimmed); + if !error_text.is_empty() { + error_lines.push(error_text.to_string()); + } + } + } + + // Compilation errors + if !error_lines.is_empty() { + let mut result = format!("sbt compile: {} errors\n", error_lines.len()); + result.push_str("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•\n"); + + for (i, error) in error_lines.iter().take(30).enumerate() { + result.push_str(&format!("{}. {}\n", i + 1, truncate(error, 120))); + } + + if error_lines.len() > 30 { + result.push_str(&format!("\n... +{} more errors\n", error_lines.len() - 30)); + } + + return result.trim().to_string(); + } + + // Success + if has_success || source_count > 0 { + let mut summary = String::from("sbt compile: "); + if source_count > 0 { + summary.push_str(&format!("{} sources", source_count)); + } else { + summary.push_str("Success"); + } + if let Some(secs) = total_time_secs { + summary.push_str(&format!(" ({}s)", secs)); + } + return summary; + } + + // Fallback: nothing recognized + "sbt compile: Success".to_string() +} + +/// Filter SBT run output โ€” light filtering. +/// +/// Strips SBT preamble noise, keeps actual program output. +fn filter_sbt_run(output: &str) -> String { + let mut result_lines: Vec<&str> = Vec::new(); + + for line in output.lines() { + let trimmed = line.trim(); + + // Skip empty lines at the start + if trimmed.is_empty() && result_lines.is_empty() { + continue; + } + + // Skip SBT noise lines + if NOISE_RE.is_match(trimmed) { + continue; + } + + // Skip [info] Compiling lines + if COMPILE_COUNT_RE.is_match(trimmed) { + continue; + } + + // Skip [info] running ... preamble + if trimmed.starts_with("[info] running ") || trimmed.starts_with("[info] Running ") { + continue; + } + + // Strip [info] prefix from program output + if let Some(content) = trimmed.strip_prefix("[info] ") { + result_lines.push(content); + } else if let Some(content) = trimmed.strip_prefix("[success] ") { + // Skip success time line + if content.starts_with("Total time:") { + continue; + } + result_lines.push(content); + } else if ERROR_RE.is_match(trimmed) { + let error_text = trimmed.strip_prefix("[error] ").unwrap_or(trimmed); + result_lines.push(error_text); + } else { + result_lines.push(trimmed); + } + } + + result_lines.join("\n").trim().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn count_tokens(text: &str) -> usize { + text.split_whitespace().count() + } + + // --- sbt test: all-pass --- + + #[test] + fn test_filter_sbt_test_all_pass() { + let input = include_str!("../../../tests/fixtures/sbt/sbt_test_pass.txt"); + let output = filter_sbt_test(input); + + assert!(output.starts_with("sbt test:"), "output: {}", output); + assert!(output.contains("30 passed")); + assert!(output.contains("2 ignored")); + assert!(output.contains("5 suites")); + assert!(output.contains("5s")); + assert!(!output.contains('\n'), "all-pass output should be a single line"); + } + + #[test] + fn test_filter_sbt_test_all_pass_token_savings() { + let input = include_str!("../../../tests/fixtures/sbt/sbt_test_pass.txt"); + let output = filter_sbt_test(input); + let savings = 100.0 + - (count_tokens(&output) as f64 / count_tokens(input) as f64 * 100.0); + assert!( + savings >= 60.0, + "sbt test (pass): expected >=60% savings, got {:.1}%", + savings + ); + } + + // --- sbt test: ScalaTest failures --- + + #[test] + fn test_filter_sbt_test_with_failures() { + let input = include_str!("../../../tests/fixtures/sbt/sbt_test_fail.txt"); + let output = filter_sbt_test(input); + + assert!(output.contains("15 passed"), "output: {}", output); + assert!(output.contains("3 failed")); + assert!(output.contains("[FAIL]")); + // Detail lines from the fixture should appear + assert!( + output.contains("Expected ServiceException"), + "missing failure detail: {}", + output + ); + assert!(output.contains("MyServiceSpec.scala:45")); + assert!(output.contains("timed out")); + assert!(output.contains("42 was not equal to 43")); + } + + #[test] + fn test_filter_sbt_test_failures_no_noise() { + let input = include_str!("../../../tests/fixtures/sbt/sbt_test_fail.txt"); + let output = filter_sbt_test(input); + + // SBT boilerplate must be stripped + assert!(!output.contains("welcome to sbt")); + assert!(!output.contains("loading settings")); + assert!(!output.contains("TestsFailedException")); + assert!(!output.contains("Total time:")); + } + + #[test] + fn test_filter_sbt_test_fail_token_savings() { + let input = include_str!("../../../tests/fixtures/sbt/sbt_test_fail.txt"); + let output = filter_sbt_test(input); + let savings = 100.0 + - (count_tokens(&output) as f64 / count_tokens(input) as f64 * 100.0); + assert!( + savings >= 40.0, + "sbt test (fail): expected >=40% savings, got {:.1}%", + savings + ); + } + + // --- sbt test: Mockito Scala verification failures --- + + #[test] + fn test_filter_sbt_test_mockito_failure_details() { + let input = include_str!("../../../tests/fixtures/sbt/sbt_test_mockito_fail.txt"); + let output = filter_sbt_test(input); + + assert!(output.contains("4 passed"), "output: {}", output); + assert!(output.contains("2 failed")); + // Mockito-specific detail lines must appear + assert!( + output.contains("WantedButNotInvoked"), + "missing Mockito detail: {}", + output + ); + assert!(output.contains("Wanted but not invoked")); + assert!( + output.contains("TooManyActualInvocations"), + "missing second Mockito failure: {}", + output + ); + // Pure JVM stack frames ("at com.example...") must be suppressed; + // Mockito pointer lines ("-> at com.example...") may remain โ€” they + // carry the file:line reference that identifies the assertion site. + assert!( + !output.lines().any(|l| l.trim_start().starts_with("at com.")), + "bare stack frame leaked into output: {}", + output + ); + } + + #[test] + fn test_filter_sbt_test_mockito_token_savings() { + let input = include_str!("../../../tests/fixtures/sbt/sbt_test_mockito_fail.txt"); + let output = filter_sbt_test(input); + let savings = 100.0 + - (count_tokens(&output) as f64 / count_tokens(input) as f64 * 100.0); + assert!( + savings >= 40.0, + "sbt test (mockito): expected >=40% savings, got {:.1}%", + savings + ); + } + + // --- sbt test: ScalaMock expectation failures --- + + #[test] + fn test_filter_sbt_test_scalamock_failure_details() { + let input = include_str!("../../../tests/fixtures/sbt/sbt_test_scalamock_fail.txt"); + let output = filter_sbt_test(input); + + assert!(output.contains("5 passed"), "output: {}", output); + assert!(output.contains("2 failed")); + // ScalaMock-specific detail lines must appear + assert!( + output.contains("Unexpected call"), + "missing ScalaMock detail: {}", + output + ); + assert!(output.contains("Unsatisfied expectation")); + } + + #[test] + fn test_filter_sbt_test_scalamock_token_savings() { + let input = include_str!("../../../tests/fixtures/sbt/sbt_test_scalamock_fail.txt"); + let output = filter_sbt_test(input); + let savings = 100.0 + - (count_tokens(&output) as f64 / count_tokens(input) as f64 * 100.0); + assert!( + savings >= 40.0, + "sbt test (scalamock): expected >=40% savings, got {:.1}%", + savings + ); + } + + // --- integration tests (it:test, IntegrationTest/test) --- + + #[test] + fn test_filter_sbt_it_test_pass() { + let input = include_str!("../../../tests/fixtures/sbt/sbt_it_test_pass.txt"); + let output = filter_sbt_test(input); // same filter as sbt test + + assert!(output.starts_with("sbt test:"), "output: {}", output); + assert!(output.contains("5 passed")); + assert!(output.contains("2 suites")); + assert!(output.contains("18s")); + assert!(!output.contains('\n'), "all-pass output should be a single line"); + } + + #[test] + fn test_is_integration_test_cmd() { + assert!(is_integration_test_cmd("it:test")); + assert!(is_integration_test_cmd("IntegrationTest/test")); + assert!(is_integration_test_cmd("integration-test/test")); + assert!(is_integration_test_cmd("e2e/test")); + assert!(is_integration_test_cmd("it:test")); + assert!(!is_integration_test_cmd("test")); + assert!(!is_integration_test_cmd("compile")); + assert!(!is_integration_test_cmd("assembly")); + } + + #[test] + fn test_is_test_task() { + assert!(is_test_task("testOnly")); + assert!(is_test_task("testQuick")); + assert!(is_test_task("Test/testOnly")); + assert!(is_test_task("core/testOnly")); + assert!(is_test_task("it:testOnly")); + assert!(is_test_task("testOnly com.example.MySpec")); + assert!(is_test_task("testOnly *CalcSpec")); + assert!(is_test_task("core/testOnly com.example.MySpec")); + assert!(!is_test_task("test")); + assert!(!is_test_task("Test/test")); + assert!(!is_test_task("testOnlyFoo")); + assert!(!is_test_task("compile")); + assert!(!is_test_task("clean; testOnly com.example.MySpec")); + } + + // --- sbt test: munit format --- + + #[test] + fn test_filter_sbt_test_munit_pass() { + let input = include_str!("../../../tests/fixtures/sbt/sbt_test_munit_pass.txt"); + let output = filter_sbt_test(input); + + assert!(output.starts_with("sbt test:"), "output: {}", output); + assert!(output.contains("12 passed"), "output: {}", output); + assert!(!output.contains('\n'), "all-pass output should be a single line"); + assert!(!output.contains("parse error"), "output: {}", output); + } + + #[test] + fn test_filter_sbt_test_munit_fail() { + let input = include_str!("../../../tests/fixtures/sbt/sbt_test_munit_fail.txt"); + let output = filter_sbt_test(input); + + assert!(output.contains("8 passed"), "output: {}", output); + assert!(output.contains("2 failed"), "output: {}", output); + assert!(output.contains("[FAIL]"), "output: {}", output); + assert!(!output.contains("parse error"), "output: {}", output); + } + + #[test] + fn test_filter_sbt_test_munit_token_savings() { + let input = include_str!("../../../tests/fixtures/sbt/sbt_test_munit_pass.txt"); + let output = filter_sbt_test(input); + let savings = 100.0 + - (count_tokens(&output) as f64 / count_tokens(input) as f64 * 100.0); + assert!( + savings >= 60.0, + "sbt test (munit): expected >=60% savings, got {:.1}%", + savings + ); + } + + // --- sbt compile --- + + #[test] + fn test_filter_sbt_compile_success() { + let input = "[info] loading settings for project root from build.sbt ...\n\ + [info] Compiling 15 Scala sources to /target/scala-2.13/classes ...\n\ + [success] Total time: 12 s, completed Jan 15, 2025"; + let output = filter_sbt_compile(input); + + assert!(output.contains("sbt compile:")); + assert!(output.contains("15 sources")); + assert!(output.contains("12s")); + } + + #[test] + fn test_filter_sbt_compile_errors() { + let input = include_str!("../../../tests/fixtures/sbt/sbt_compile_error.txt"); + let output = filter_sbt_compile(input); + + assert!(output.contains("sbt compile:")); + assert!(output.contains("errors")); + assert!(output.contains("type mismatch")); + assert!(output.contains("not found: value")); + } + + #[test] + fn test_filter_sbt_compile_error_token_savings() { + let input = include_str!("../../../tests/fixtures/sbt/sbt_compile_error.txt"); + let output = filter_sbt_compile(input); + let savings = 100.0 + - (count_tokens(&output) as f64 / count_tokens(input) as f64 * 100.0); + assert!( + savings >= 30.0, + "sbt compile (error): expected >=30% savings, got {:.1}%", + savings + ); + } + + // --- sbt run --- + + #[test] + fn test_filter_sbt_run_strips_noise() { + let input = "[info] welcome to sbt 1.9.7\n\ + [info] loading settings for project root from build.sbt ...\n\ + [info] set current project to myapp\n\ + [info] running com.example.Main\n\ + [info] Hello, World!\n\ + [info] Server started on port 8080\n\ + [success] Total time: 3 s, completed Jan 15, 2025"; + let output = filter_sbt_run(input); + + assert!(output.contains("Hello, World!")); + assert!(output.contains("Server started on port 8080")); + assert!(!output.contains("welcome to sbt")); + assert!(!output.contains("loading settings")); + assert!(!output.contains("set current project")); + assert!(!output.contains("running com.example")); + assert!(!output.contains("Total time:")); + } + + // --- edge cases --- + + #[test] + fn test_filter_sbt_test_empty_input() { + // Empty command output must produce empty filtered output (guard::never_worse + // invariant): RTK never emits tokens the underlying command didn't. + assert!(filter_sbt_test("").is_empty()); + } + + #[test] + fn test_filter_sbt_compile_empty_input() { + assert!(filter_sbt_compile("").is_empty()); + } + + #[test] + fn test_filter_sbt_run_empty_input() { + assert!(filter_sbt_run("").is_empty()); + } +} diff --git a/src/cmds/system/pipe_cmd.rs b/src/cmds/system/pipe_cmd.rs index c1a1a07c87..563d54a10f 100644 --- a/src/cmds/system/pipe_cmd.rs +++ b/src/cmds/system/pipe_cmd.rs @@ -27,6 +27,13 @@ pub fn resolve_filter(name: &str) -> Option String> { "ruff-check" => Some(crate::cmds::python::ruff_cmd::filter_ruff_check_json), "ruff-format" => Some(crate::cmds::python::ruff_cmd::filter_ruff_format), "prettier" => Some(crate::cmds::js::prettier_cmd::filter_prettier_output), + "phpunit" => Some(crate::cmds::php::phpunit_cmd::filter_phpunit_output), + "pest" | "paratest" | "php-test" => { + Some(crate::cmds::php::test_output::filter_test_runner_output) + } + "ecs" => Some(crate::cmds::php::ecs_cmd::filter_ecs_output), + "phpstan" => Some(phpstan_wrapper), + "pint" => Some(pint_wrapper), _ => None, } } @@ -47,6 +54,24 @@ fn git_diff_wrapper(input: &str) -> String { crate::cmds::git::git::compact_diff(input, 200) } +fn phpstan_wrapper(input: &str) -> String { + // Runner forces --format=json; piped output may be either JSON or the + // default human table. Pick by content. + if input.trim_start().starts_with('{') { + crate::cmds::php::phpstan_cmd::filter_phpstan_json(input) + } else { + crate::cmds::php::phpstan_cmd::filter_phpstan_text(input) + } +} + +fn pint_wrapper(input: &str) -> String { + if input.trim_start().starts_with('{') { + crate::cmds::php::pint_cmd::filter_pint_json(input) + } else { + crate::core::utils::fallback_tail(input, "pint", 60) + } +} + fn vitest_wrapper(input: &str) -> String { use crate::cmds::js::vitest_cmd::VitestParser; use crate::parser::{FormatMode, OutputParser, TokenFormatter}; @@ -155,6 +180,14 @@ pub fn auto_detect_filter(input: &str) -> fn(&str) -> String { } let first_trimmed = first_1k.trim_start(); + + // phpunit banner: "PHPUnit X.Y.Z by Sebastian Bergmann and contributors." + // Anchor to the leading "PHPUnit " token so a LICENSE/composer/`git log` + // that merely mentions the author isn't misrouted here. + if first_trimmed.starts_with("PHPUnit ") && first_1k.contains("by Sebastian Bergmann") { + return crate::cmds::php::phpunit_cmd::filter_phpunit_output; + } + if first_trimmed.starts_with('{') && first_1k.contains("\"Action\"") { return go_test_wrapper; } @@ -231,7 +264,8 @@ pub fn run(filter_name: Option<&str>, passthrough: bool) -> Result<()> { anyhow::anyhow!( "Unknown filter '{}'. Available: cargo-test, pytest, go-test, go-build, \ tsc, vitest, grep, rg, find, fd, git-log, git-diff, git-status, \ - log, mypy, ruff-check, ruff-format, prettier", + log, mypy, ruff-check, ruff-format, prettier, phpunit, pest, \ + paratest, php-test, ecs, phpstan, pint", name ) })?, @@ -248,6 +282,31 @@ pub fn run(filter_name: Option<&str>, passthrough: bool) -> Result<()> { mod tests { use super::*; + #[test] + fn test_auto_detect_phpunit_banner() { + // The phpunit banner must route to the phpunit filter with no -f flag. + let input = "PHPUnit 11.0.0 by Sebastian Bergmann and contributors.\n\n\ + ... 3 / 3 (100%)\n\nOK (3 tests, 5 assertions)\n"; + let f = auto_detect_filter(input); + let out = f(input); + assert!(out.starts_with("PHPUnit:"), "out={}", out); + } + + #[test] + fn test_auto_detect_phpunit_not_misrouted_by_author_mention() { + // Text that merely mentions the author (LICENSE, git log, composer meta) + // must pass through untouched, not route to the phpunit filter. + let input = "commit abc123\nAuthor: written by Sebastian Bergmann and contributors\n\n Update changelog\n"; + let f = auto_detect_filter(input); + let out = f(input); + assert_eq!(out, input, "should pass through unchanged, got: {}", out); + } + + #[test] + fn test_resolve_filter_phpunit() { + assert!(resolve_filter("phpunit").is_some()); + } + #[test] fn test_resolve_filter_cargo_test() { let f = resolve_filter("cargo-test").expect("cargo-test filter must exist"); diff --git a/src/cmds/system/search.rs b/src/cmds/system/search.rs index ffdd93c166..99507228ac 100644 --- a/src/cmds/system/search.rs +++ b/src/cmds/system/search.rs @@ -6,13 +6,17 @@ //! used (see `Engine`); the compression is identical because both emit the same //! `file:line:content` shape. -use crate::core::stream::{exec_capture, exec_capture_stdin, CaptureResult}; +use crate::core::stream::{ + self, exec_capture, exec_capture_stdin, CaptureResult, FilterMode, StdinMode, StreamFilter, +}; use crate::core::tracking; use crate::core::utils::{resolved_command, strip_ansi}; use crate::core::{args_utils, config}; use anyhow::{Context, Result}; use regex::Regex; use std::collections::HashMap; +use std::io::IsTerminal; +use std::process::Command; /// Short single-char flags that consume one following token (or inline remainder) /// as their value. `-e` is handled separately โ€” its value goes to `patterns`. @@ -297,17 +301,137 @@ fn engine_capture>( patterns: &[String], paths: &[String], ) -> Result { + let mut cmd = engine_command(engine, extra_args, patterns, paths, false); + exec_capture_stdin(&mut cmd).context("search failed") +} + +fn engine_command>( + engine: Engine, + extra_args: &[T], + patterns: &[String], + paths: &[String], + line_buffered: bool, +) -> Command { let mut cmd = resolved_command(engine.bin()); cmd.args(engine.parse_flags()); for a in extra_args { cmd.arg(a.as_ref()); } + if line_buffered { + // The engine writes through a pipe, so flush each match immediately. + cmd.arg("--line-buffered"); + } for p in patterns { cmd.args(["-e", p]); } cmd.arg("--"); cmd.args(paths); - exec_capture_stdin(&mut cmd).context("search failed") + cmd +} + +fn format_match_line(line: &str, show_file: bool, show_line: bool) -> Option { + let (file, line_num, is_match, content) = parse_match_line(line)?; + let sep = if is_match { ':' } else { '-' }; + let mut output = String::new(); + if show_file { + output.push_str(&file); + output.push(sep); + } + if show_line { + output.push_str(&line_num.to_string()); + output.push(sep); + } + output.push_str(content); + output.push('\n'); + Some(output) +} + +/// Emits each piped match as it arrives. Buffered search waits for EOF, so +/// `tail -f app.log | rtk grep ERROR` would otherwise show no matches. +struct SearchStreamFilter { + show_file: bool, + show_line: bool, + max_results: usize, + shown: usize, + cap_reported: bool, +} + +impl StreamFilter for SearchStreamFilter { + fn feed_line(&mut self, line: &str) -> Option { + let Some(output) = format_match_line(line, self.show_file, self.show_line) else { + if line == "--" && self.shown >= self.max_results { + return None; + } + return Some(format!("{line}\n")); + }; + + if self.shown >= self.max_results { + if self.cap_reported { + return None; + } + self.cap_reported = true; + return Some(format!( + "[rtk] output capped at {} results\n", + self.max_results + )); + } + + self.shown += 1; + Some(output) + } + + fn flush(&mut self) -> String { + String::new() + } +} + +fn show_file(paths: &[String], extra_args: &[String]) -> bool { + paths.len() > 1 + || paths.iter().any(|p| std::path::Path::new(p).is_dir()) + || has_short_flag(extra_args, 'H') + || has_short_flag(extra_args, 'r') + || has_short_flag(extra_args, 'R') + || extra_args + .iter() + .any(|f| f == "--with-filename" || f == "--recursive") +} + +fn show_line(extra_args: &[String]) -> bool { + !has_short_flag(extra_args, 'N') + && !extra_args.iter().any(|f| f == "--no-line-number") +} + +fn run_streaming_search( + timer: &tracking::TimedExecution, + engine: Engine, + extra_args: &[String], + patterns: &[String], + paths: &[String], + max_results: usize, + real_cmd: &str, +) -> Result { + let filter = SearchStreamFilter { + show_file: show_file(paths, extra_args), + show_line: show_line(extra_args), + max_results, + shown: 0, + cap_reported: false, + }; + let mut cmd = engine_command(engine, extra_args, patterns, paths, true); + let result = stream::run_streaming( + &mut cmd, + StdinMode::Inherit, + FilterMode::Streaming(Box::new(filter)), + ) + .context("search failed")?; + + timer.track( + real_cmd, + &format!("rtk {}", engine.label()), + &result.raw_stdout, + &result.filtered, + ); + Ok(result.exit_code) } /// Runs the agent's command verbatim for forms RTK does not group: format/shape @@ -317,20 +441,32 @@ fn passthrough>( engine: Engine, args: &[T], real_cmd: &str, + stream_stdin: bool, ) -> Result { let mut cmd = resolved_command(engine.bin()); + if stream_stdin && !std::io::stdout().is_terminal() { + // Keep passthrough output live when stdout is piped. + cmd.arg("--line-buffered"); + } for a in args { cmd.arg(a.as_ref()); } - let result = exec_capture_stdin(&mut cmd).context("search failed")?; - print!("{}", strip_ansi(&result.stdout)); - if !result.stderr.is_empty() { - eprint!("{}", result.stderr); - } + let exit_code = if stream_stdin { + stream::run_streaming(&mut cmd, StdinMode::Inherit, FilterMode::Passthrough) + .context("search failed")? + .exit_code + } else { + let result = exec_capture_stdin(&mut cmd).context("search failed")?; + print!("{}", strip_ansi(&result.stdout)); + if !result.stderr.is_empty() { + eprint!("{}", result.stderr); + } + result.exit_code + }; timer.track_passthrough(real_cmd, &format!("rtk {} (passthrough)", real_cmd)); - Ok(result.exit_code) + Ok(exit_code) } fn has_short_flag(flags: &[String], ch: char) -> bool { @@ -339,6 +475,20 @@ fn has_short_flag(flags: &[String], ch: char) -> bool { .any(|f| f.starts_with('-') && !f.starts_with("--") && f[1..].contains(ch)) } +fn has_context_flag(flags: &[String]) -> bool { + has_short_flag(flags, 'A') + || has_short_flag(flags, 'B') + || has_short_flag(flags, 'C') + || flags.iter().any(|f| { + f == "--after-context" + || f == "--before-context" + || f == "--context" + || f.starts_with("--after-context=") + || f.starts_with("--before-context=") + || f.starts_with("--context=") + }) +} + pub fn run( engine: Engine, max_line_len: usize, @@ -374,7 +524,7 @@ pub fn run( let (patterns, paths, extra_args) = extract_pattern_path(&args); if patterns.is_empty() { - return passthrough(&timer, engine, &args, &real_cmd); + return passthrough(&timer, engine, &args, &real_cmd, false); } let pattern_display = if patterns.len() == 1 { @@ -389,9 +539,24 @@ pub fn run( eprintln!("grep: '{}' in {}", pattern_display, path_display); } + let reads_piped_stdin = !std::io::stdin().is_terminal() + && (paths.is_empty() || paths.iter().any(|path| path == "-")); + // format/shape flags (-c/-l/-o/...): already-minimal native output, passthrough. if has_format_flag(&extra_args) { - return passthrough(&timer, engine, &args, &real_cmd); + return passthrough(&timer, engine, &args, &real_cmd, reads_piped_stdin); + } + + if reads_piped_stdin { + return run_streaming_search( + &timer, + engine, + &extra_args, + &patterns, + &paths, + max_results, + &real_cmd, + ); } let result = engine_capture(engine, &extra_args, &patterns, &paths)?; @@ -402,7 +567,7 @@ pub fn run( // Unparseable shape re-runs verbatim below (with its own stderr), so handle it // before surfacing this run's stderr (#2333). if unparsed_signal(&raw_output) > 0 { - return passthrough(&timer, engine, &args, &real_cmd); + return passthrough(&timer, engine, &args, &real_cmd, false); } if !result.stderr.is_empty() { @@ -446,39 +611,25 @@ pub fn run( // show one (multiple files, a directory, -r or -H), the line number only with // -n. We force -nH--null for robust parsing, then drop what the engine itself // would not have shown. - let show_file = by_file.len() > 1 - || paths.len() > 1 - || paths.iter().any(|p| std::path::Path::new(p).is_dir()) - || has_short_flag(&extra_args, 'H') - || has_short_flag(&extra_args, 'r') - || has_short_flag(&extra_args, 'R') - || extra_args - .iter() - .any(|f| f == "--with-filename" || f == "--recursive"); + let show_file = by_file.len() > 1 || show_file(&paths, &extra_args); // Always surface the line number (the openable position) unless the agent // explicitly turned it off; the filename is the only conditional part. - let show_line = !has_short_flag(&extra_args, 'N') - && !extra_args.iter().any(|f| f == "--no-line-number"); + let show_line = show_line(&extra_args); // Faithful baseline: exactly what the real command prints, full content. let mut plain = String::new(); for line in raw_output.lines() { - let Some((file, line_num, is_match, content)) = parse_match_line(line) else { + let Some(output) = format_match_line(line, show_file, show_line) else { + if line == "--" { + plain.push_str("--\n"); + } continue; }; - let sep = if is_match { ':' } else { '-' }; - if show_file { - plain.push_str(&file); - plain.push(sep); - } - if show_line { - plain.push_str(&line_num.to_string()); - plain.push(sep); - } - plain.push_str(content); - plain.push('\n'); + plain.push_str(&output); } + let has_context = has_context_flag(&extra_args); + let per_file = config::limits().grep_max_per_file; let mut files: Vec<_> = by_file.iter().collect(); files.sort_by_key(|(f, _)| *f); @@ -496,10 +647,15 @@ pub fn run( let file_display = compact_path(file); let mut file_shown = 0; + let mut prev_line: usize = 0; for (line_num, is_match, content) in entries.iter().take(per_file) { if shown >= max_results { break; } + if has_context && prev_line > 0 && *line_num > prev_line + 1 { + body.push_str("--\n"); + } + prev_line = *line_num; let sep = if *is_match { ':' } else { '-' }; if show_file { body.push_str(&file_display); @@ -709,6 +865,51 @@ mod tests { assert!(compact.len() <= 60); } + #[test] + fn streaming_search_preserves_native_shape() { + let mut filter = SearchStreamFilter { + show_file: false, + show_line: true, + max_results: 10, + shown: 0, + cap_reported: false, + }; + + assert_eq!( + filter.feed_line("engine warning"), + Some("engine warning\n".to_string()) + ); + assert_eq!( + filter.feed_line(concat!("(standard input)\0", "1:match")), + Some("1:match\n".to_string()) + ); + } + + #[test] + fn streaming_search_reports_the_cap_once() { + let mut filter = SearchStreamFilter { + show_file: false, + show_line: true, + max_results: 1, + shown: 0, + cap_reported: false, + }; + + assert_eq!( + filter.feed_line(concat!("(standard input)\0", "1:first")), + Some("1:first\n".to_string()) + ); + assert_eq!( + filter.feed_line(concat!("(standard input)\0", "2:second")), + Some("[rtk] output capped at 1 results\n".to_string()) + ); + assert_eq!( + filter.feed_line(concat!("(standard input)\0", "3:third")), + None + ); + assert_eq!(filter.feed_line("--"), None); + } + #[test] fn test_clean_line_multibyte() { // Thai text that exceeds max_len in bytes @@ -1376,4 +1577,32 @@ mod tests { let stdout = "file.txt\x003-context_before\nfile.txt\x004:match\nfile.txt\x005-context_after\n"; assert_eq!(unparsed_signal(stdout), 0); } + + // --- has_context_flag --- + + #[test] + fn test_has_context_flag_short() { + let f = |args: &[&str]| -> bool { + has_context_flag(&args.iter().map(|s| s.to_string()).collect::>()) + }; + assert!(f(&["-A", "3"])); + assert!(f(&["-B", "2"])); + assert!(f(&["-C", "1"])); + assert!(!f(&["-rn"])); + assert!(!f(&["-i", "-w"])); + } + + #[test] + fn test_has_context_flag_long() { + let f = |args: &[&str]| -> bool { + has_context_flag(&args.iter().map(|s| s.to_string()).collect::>()) + }; + assert!(f(&["--after-context", "3"])); + assert!(f(&["--before-context", "2"])); + assert!(f(&["--context", "1"])); + assert!(f(&["--after-context=3"])); + assert!(f(&["--before-context=2"])); + assert!(f(&["--context=1"])); + assert!(!f(&["--color", "auto"])); + } } diff --git a/src/core/README.md b/src/core/README.md index 5d6f8e5ca1..186170330a 100644 --- a/src/core/README.md +++ b/src/core/README.md @@ -40,10 +40,10 @@ CREATE TABLE commands ( original_cmd TEXT, -- "ls -la" rtk_cmd TEXT, -- "rtk ls" project_path TEXT, -- cwd (for project-scoped stats) - input_tokens INTEGER, -- estimated from raw output - output_tokens INTEGER, -- estimated from filtered output + input_tokens INTEGER, -- estimated from raw output (bytes / 4, no tokenizer) + output_tokens INTEGER, -- estimated from filtered output (bytes / 4) saved_tokens INTEGER, -- input - output - savings_pct REAL, -- (saved / input) * 100 + savings_pct REAL, -- (saved / input) * 100, i.e. reduction in bash output bytes exec_time_ms INTEGER -- elapsed milliseconds ); diff --git a/src/core/constants.rs b/src/core/constants.rs index a5ecd3846d..5944e7b0f2 100644 --- a/src/core/constants.rs +++ b/src/core/constants.rs @@ -4,3 +4,28 @@ pub const CONFIG_TOML: &str = "config.toml"; pub const FILTERS_TOML: &str = "filters.toml"; pub const TRUSTED_FILTERS_JSON: &str = "trusted_filters.json"; pub const DEFAULT_HISTORY_DAYS: i64 = 90; + +/// RTK-only subcommands that should never fall back to raw execution. +/// When adding a new RTK-only subcommand to `Commands`, add its clap name here. +pub const RTK_META_COMMANDS: &[&str] = &[ + "gain", + "discover", + "learn", + "init", + "config", + "proxy", + "run", + "hook", + "hook-audit", + "pipe", + "cc-economics", + "verify", + "trust", + "untrust", + "session", + "rewrite", + "telemetry", + "smart", + "deps", + "json", +]; diff --git a/src/core/telemetry.rs b/src/core/telemetry.rs index d9bee51f09..8a60d45b29 100644 --- a/src/core/telemetry.rs +++ b/src/core/telemetry.rs @@ -25,8 +25,8 @@ pub fn maybe_ping() { return; } - // Check opt-out: env var - if std::env::var("RTK_TELEMETRY_DISABLED").unwrap_or_default() == "1" { + // Check opt-out: env var (single source of truth in telemetry_cmd) + if super::telemetry_cmd::telemetry_disabled_by_env() { return; } diff --git a/src/core/telemetry_cmd.rs b/src/core/telemetry_cmd.rs index e14b53b3aa..25f6a4c206 100644 --- a/src/core/telemetry_cmd.rs +++ b/src/core/telemetry_cmd.rs @@ -1,6 +1,9 @@ use anyhow::{Context, Result}; use clap::Subcommand; +const TELEMETRY_DISABLED_ENV: &str = "RTK_TELEMETRY_DISABLED"; +const TELEMETRY_DISABLED_VALUE: &str = "1"; + #[derive(Debug, Subcommand)] pub enum TelemetrySubcommand { Status, @@ -18,6 +21,17 @@ pub fn run(command: &TelemetrySubcommand) -> Result<()> { } } +/// Returns true when telemetry is explicitly disabled through the +/// `RTK_TELEMETRY_DISABLED` env var (value `"1"`). +/// +/// Single source of truth for the env opt-out so the consent prompt +/// (`init::prompt_telemetry_consent`), the status command, and +/// `telemetry::maybe_ping` never diverge โ€” if the accepted values ever grow +/// (e.g. `"true"`, `"y"`), they change here once. +pub fn telemetry_disabled_by_env() -> bool { + std::env::var(TELEMETRY_DISABLED_ENV).unwrap_or_default() == TELEMETRY_DISABLED_VALUE +} + fn run_status() -> Result<()> { let config = crate::core::config::Config::load().unwrap_or_default(); @@ -33,7 +47,7 @@ fn run_status() -> Result<()> { "no" }; - let env_override = std::env::var("RTK_TELEMETRY_DISABLED").unwrap_or_default() == "1"; + let env_override = telemetry_disabled_by_env(); println!("Telemetry status:"); println!(" consent: {}", consent_str); @@ -181,3 +195,40 @@ fn send_erasure_request(device_hash: &str) -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + /// Regression for #1307: the env opt-out must short-circuit telemetry + /// consent paths so `rtk init` cannot hang in non-interactive environments. + /// All cases are bundled in one test to serialize env-var mutations. + #[test] + fn test_telemetry_disabled_by_env_honors_opt_out() { + #[allow(deprecated)] + std::env::remove_var(TELEMETRY_DISABLED_ENV); + assert!( + !telemetry_disabled_by_env(), + "unset env must not count as disabled" + ); + + #[allow(deprecated)] + std::env::set_var(TELEMETRY_DISABLED_ENV, TELEMETRY_DISABLED_VALUE); + assert!( + telemetry_disabled_by_env(), + "RTK_TELEMETRY_DISABLED=1 must disable telemetry prompts (issue #1307)" + ); + + for other in ["0", "true", "false", "yes", "no", ""] { + #[allow(deprecated)] + std::env::set_var(TELEMETRY_DISABLED_ENV, other); + assert!( + !telemetry_disabled_by_env(), + "value {other:?} must not be treated as disabled" + ); + } + + #[allow(deprecated)] + std::env::remove_var(TELEMETRY_DISABLED_ENV); + } +} diff --git a/src/core/toml_filter.rs b/src/core/toml_filter.rs index 164b3e69bb..b5a9be6ca8 100644 --- a/src/core/toml_filter.rs +++ b/src/core/toml_filter.rs @@ -22,7 +22,7 @@ /// 6. head/tail_lines โ€” keep first/last N lines /// 7. max_lines โ€” absolute line cap /// 8. on_empty โ€” message if result is empty -use super::constants::{FILTERS_TOML, RTK_DATA_DIR}; +use super::constants::RTK_META_COMMANDS; use lazy_static::lazy_static; use regex::{Regex, RegexSet}; use serde::Deserialize; @@ -188,48 +188,11 @@ impl TomlFilterRegistry { fn load() -> Self { let mut filters = Vec::new(); - // Priority 1: project-local .rtk/filters.toml (trust-gated) - let project_filter_path = std::path::Path::new(".rtk/filters.toml"); - if project_filter_path.exists() { - let (trust_status, verified_content) = - crate::hooks::trust::check_trust_with_content(project_filter_path) - .unwrap_or((crate::hooks::trust::TrustStatus::Untrusted, None)); - - match trust_status { - crate::hooks::trust::TrustStatus::Trusted - | crate::hooks::trust::TrustStatus::EnvOverride => { - if let Some(content) = verified_content { - match Self::parse_and_compile(&content, "project") { - Ok(f) => filters.extend(f), - Err(e) => eprintln!("[rtk] warning: .rtk/filters.toml: {}", e), - } - } - } - crate::hooks::trust::TrustStatus::Untrusted => { - eprintln!("[rtk] WARNING: untrusted project filters (.rtk/filters.toml)"); - eprintln!("[rtk] Filters NOT applied. Run `rtk trust` to review and enable."); - } - crate::hooks::trust::TrustStatus::ContentChanged { .. } => { - eprintln!("[rtk] WARNING: .rtk/filters.toml changed since trusted."); - eprintln!("[rtk] Filters NOT applied. Run `rtk trust` to re-review."); - } - } - } - - // Priority 2: user-global ~/.config/rtk/filters.toml - if let Some(config_dir) = dirs::config_dir() { - let global_path = config_dir.join(RTK_DATA_DIR).join(FILTERS_TOML); - if let Ok(content) = std::fs::read_to_string(&global_path) { - match Self::parse_and_compile(&content, "user-global") { - Ok(f) => filters.extend(f), - Err(e) => eprintln!("[rtk] warning: {}: {}", global_path.display(), e), - } - } + for path in crate::hooks::trust::gated_filter_paths() { + Self::extend_with_trusted(&mut filters, &path); } - // Priority 3: built-in (embedded at compile time) - let builtin = BUILTIN_TOML; - match Self::parse_and_compile(builtin, "builtin") { + match Self::parse_and_compile(BUILTIN_TOML, "builtin") { Ok(f) => filters.extend(f), Err(e) => eprintln!("[rtk] warning: builtin filters: {}", e), } @@ -237,6 +200,28 @@ impl TomlFilterRegistry { TomlFilterRegistry { filters } } + fn extend_with_trusted(filters: &mut Vec, path: &std::path::Path) { + if !path.exists() { + return; + } + let label = path.display().to_string(); + let (status, content) = crate::hooks::trust::check_trust_with_content(path) + .unwrap_or((crate::hooks::trust::TrustStatus::Untrusted, None)); + match status { + crate::hooks::trust::TrustStatus::Trusted + | crate::hooks::trust::TrustStatus::EnvOverride => { + if let Some(content) = content { + match Self::parse_and_compile(&content, &label) { + Ok(f) => filters.extend(f), + Err(e) => eprintln!("[rtk] warning: {}: {}", label, e), + } + } + } + crate::hooks::trust::TrustStatus::Untrusted + | crate::hooks::trust::TrustStatus::ContentChanged { .. } => {} + } + } + fn parse_and_compile(content: &str, source: &str) -> Result, String> { let file: TomlFilterFile = toml::from_str(content) .map_err(|e| format!("TOML parse error in {}: {}", source, e))?; @@ -314,6 +299,10 @@ const RUST_HANDLED_COMMANDS: &[&str] = &[ "learn", ]; +pub fn is_rtk_reserved_command(name: &str) -> bool { + RUST_HANDLED_COMMANDS.contains(&name) || RTK_META_COMMANDS.contains(&name) +} + fn compile_filter(name: String, def: TomlFilterDef) -> Result { // Mutual exclusion: strip and keep cannot both be set if !def.strip_lines_matching.is_empty() && !def.keep_lines_matching.is_empty() { @@ -410,6 +399,79 @@ lazy_static! { static ref REGISTRY: TomlFilterRegistry = TomlFilterRegistry::load(); } +pub fn toml_disabled() -> bool { + std::env::var("RTK_NO_TOML").ok().as_deref() == Some("1") +} + +pub fn active_filter_summaries(content: &str) -> Vec<(String, String)> { + toml::from_str::(content) + .map(|f| { + f.filters + .into_iter() + .map(|(name, def)| (name, def.match_command)) + .collect() + }) + .unwrap_or_default() +} + +pub fn filter_parse_error(content: &str) -> Option { + toml::from_str::(content) + .err() + .map(|e| e.to_string()) +} + +lazy_static! { + static ref MATCH_SET: RegexSet = build_match_set(); +} + +pub fn command_matches_filter(command: &str) -> bool { + MATCH_SET.is_match(command) +} + +fn build_match_set() -> RegexSet { + let patterns = collect_match_patterns(); + RegexSet::new(&patterns).unwrap_or_else(|_| { + let valid: Vec = patterns + .into_iter() + .filter(|p| Regex::new(p).is_ok()) + .collect(); + RegexSet::new(&valid).unwrap_or_else(|_| RegexSet::empty()) + }) +} + +fn collect_match_patterns() -> Vec { + let mut patterns: Vec = Vec::new(); + for path in crate::hooks::trust::gated_filter_paths() { + if !path.exists() { + continue; + } + let (status, content) = crate::hooks::trust::check_trust_with_content(&path) + .unwrap_or((crate::hooks::trust::TrustStatus::Untrusted, None)); + if matches!( + status, + crate::hooks::trust::TrustStatus::Trusted + | crate::hooks::trust::TrustStatus::EnvOverride + ) { + if let Some(content) = content { + patterns.extend(match_patterns_in(&content)); + } + } + } + patterns.extend(match_patterns_in(BUILTIN_TOML)); + patterns +} + +fn match_patterns_in(content: &str) -> Vec { + match toml::from_str::(content) { + Ok(file) if file.schema_version == 1 => file + .filters + .into_values() + .map(|def| def.match_command) + .collect(), + _ => Vec::new(), + } +} + // --------------------------------------------------------------------------- // Public API โ€” pure functions (testable without global state) // --------------------------------------------------------------------------- @@ -435,6 +497,22 @@ pub fn find_filter_in<'a>( /// 7. max_lines โ€” absolute line cap /// 8. on_empty โ€” message if result is empty pub fn apply_filter(filter: &CompiledFilter, stdout: &str) -> String { + apply_filter_with_info(filter, stdout).0 +} + +#[derive(Debug, PartialEq)] +pub enum Lossiness { + None, + /// `tail -n +{tail_offset}` over `tee_payload` reproduces the dropped lines, + /// up to the tee `max_file_size` cap (larger payloads are truncated in the tee file). + Tail { + tee_payload: String, + tail_offset: usize, + }, + Whole, +} + +pub fn apply_filter_with_info(filter: &CompiledFilter, stdout: &str) -> (String, Lossiness) { let mut lines: Vec = stdout.lines().map(String::from).collect(); // 1. strip_ansi @@ -472,7 +550,7 @@ pub fn apply_filter(filter: &CompiledFilter, stdout: &str) -> String { continue; // errors/warnings present โ€” skip this rule } } - return rule.message.clone(); + return (rule.message.clone(), Lossiness::Whole); } } } @@ -485,41 +563,60 @@ pub fn apply_filter(filter: &CompiledFilter, stdout: &str) -> String { } // 5. truncate_lines_at โ€” uses utils::truncate (unicode-safe) + let mut intra_line_loss = false; if let Some(max_chars) = filter.truncate_lines_at { lines = lines .into_iter() - .map(|l| crate::core::utils::truncate(&l, max_chars)) + .map(|line| { + let truncated = crate::core::utils::truncate(&line, max_chars); + if truncated != line { + intra_line_loss = true; + } + truncated + }) .collect(); } + let snapshot_for_tail = !intra_line_loss + && filter.tail_lines.is_none() + && (filter.head_lines.is_some() || filter.max_lines.is_some()); + let pre_cut = snapshot_for_tail.then(|| lines.clone()); + // 6. head + tail let total = lines.len(); + let mut noncontiguous_drop = false; + let mut head_cut: Option = None; if let (Some(head), Some(tail)) = (filter.head_lines, filter.tail_lines) { if total > head + tail { let mut result = lines[..head].to_vec(); result.push(format!("... ({} lines omitted)", total - head - tail)); result.extend_from_slice(&lines[total - tail..]); lines = result; + noncontiguous_drop = true; } } else if let Some(head) = filter.head_lines { if total > head { lines.truncate(head); lines.push(format!("... ({} lines omitted)", total - head)); + head_cut = Some(head); } } else if let Some(tail) = filter.tail_lines { if total > tail { let omitted = total - tail; lines = lines[omitted..].to_vec(); lines.insert(0, format!("... ({} lines omitted)", omitted)); + noncontiguous_drop = true; } } // 7. max_lines โ€” absolute cap applied after head/tail (includes omit messages) + let mut max_cut: Option = None; if let Some(max) = filter.max_lines { if lines.len() > max { - let truncated = lines.len() - max; + let dropped = lines.len() - max; lines.truncate(max); - lines.push(format!("... ({} lines truncated)", truncated)); + lines.push(format!("... ({} lines truncated)", dropped)); + max_cut = Some(max); } } @@ -527,11 +624,30 @@ pub fn apply_filter(filter: &CompiledFilter, stdout: &str) -> String { let result = lines.join("\n"); if result.trim().is_empty() { if let Some(ref msg) = filter.on_empty { - return msg.clone(); + return (msg.clone(), Lossiness::None); } } - result + let loss = if let Some(snapshot) = pre_cut { + match (head_cut, max_cut) { + (Some(_), Some(_)) => Lossiness::Whole, + (Some(head), None) => Lossiness::Tail { + tee_payload: snapshot.join("\n"), + tail_offset: head + 1, + }, + (None, Some(max)) => Lossiness::Tail { + tee_payload: snapshot.join("\n"), + tail_offset: max + 1, + }, + (None, None) => Lossiness::None, + } + } else if noncontiguous_drop || intra_line_loss || head_cut.is_some() || max_cut.is_some() { + Lossiness::Whole + } else { + Lossiness::None + }; + + (result, loss) } // --------------------------------------------------------------------------- @@ -706,6 +822,159 @@ mod tests { .expect("expected at least one filter") } + #[test] + fn command_matches_filter_agrees_with_find_matching_filter() { + for cmd in ["jj log", "jq .", "frobnicate xyz", "cd /tmp"] { + assert_eq!( + command_matches_filter(cmd), + find_matching_filter(cmd).is_some(), + "match-set disagreed with registry for {cmd:?}" + ); + } + } + + #[test] + fn test_loss_tail_configured_unfired_max_fires_is_whole() { + let toml = "schema_version = 1\n[filters.f]\nmatch_command = \"^cmd\"\ntail_lines = 100\nmax_lines = 2\n"; + let (out, loss) = apply_filter_with_info(&first_filter(toml), "a\nb\nc\nd\ne"); + assert!(out.contains("lines truncated")); + assert_ne!(loss, Lossiness::None); + } + + #[test] + fn test_match_patterns_in_honors_schema_version() { + let v1 = "schema_version = 1\n[filters.a]\nmatch_command = \"^aaa\"\n"; + assert_eq!(match_patterns_in(v1), vec!["^aaa".to_string()]); + + let v2 = "schema_version = 2\n[filters.a]\nmatch_command = \"^aaa\"\n"; + assert!(match_patterns_in(v2).is_empty()); + + assert!(match_patterns_in("not valid toml {{{").is_empty()); + } + + #[test] + fn test_filter_parse_error() { + assert!( + filter_parse_error("schema_version = 1\n[filters.a]\nmatch_command = \"^a\"\n") + .is_none() + ); + assert!(filter_parse_error("[filters.a]\nmatch_command = \"^a\"\n").is_some()); + assert!(filter_parse_error("this is { not toml").is_some()); + } + + #[test] + fn test_is_rtk_reserved_command() { + assert!(is_rtk_reserved_command("git")); + assert!(is_rtk_reserved_command("cargo")); + assert!(is_rtk_reserved_command("json")); + assert!(is_rtk_reserved_command("rewrite")); + assert!(!is_rtk_reserved_command("jj")); + assert!(!is_rtk_reserved_command("jq")); + assert!(!is_rtk_reserved_command("just")); + assert!(!is_rtk_reserved_command("frobnicate")); + } + + fn loss_of(toml: &str, input: &str) -> Lossiness { + apply_filter_with_info(&first_filter(toml), input).1 + } + + #[test] + fn test_loss_head_lines_is_tail() { + let toml = "schema_version = 1\n[filters.f]\nmatch_command = \"^cmd\"\nhead_lines = 2\n"; + let (out, loss) = apply_filter_with_info(&first_filter(toml), "a\nb\nc\nd\ne"); + assert!(out.starts_with("a\nb\n")); + match loss { + Lossiness::Tail { + tee_payload, + tail_offset, + } => { + assert_eq!(tail_offset, 3); + let recovered: Vec<&str> = tee_payload.lines().skip(tail_offset - 1).collect(); + assert_eq!(recovered, vec!["c", "d", "e"]); + } + other => panic!("expected Tail, got {:?}", other), + } + } + + #[test] + fn test_loss_max_lines_is_tail() { + let toml = "schema_version = 1\n[filters.f]\nmatch_command = \"^cmd\"\nmax_lines = 2\n"; + match loss_of(toml, "a\nb\nc\nd\ne") { + Lossiness::Tail { + tee_payload, + tail_offset, + } => { + assert_eq!(tail_offset, 3); + let recovered: Vec<&str> = tee_payload.lines().skip(tail_offset - 1).collect(); + assert_eq!(recovered, vec!["c", "d", "e"]); + } + other => panic!("expected Tail, got {:?}", other), + } + } + + #[test] + fn test_loss_tail_lines_is_whole() { + let toml = "schema_version = 1\n[filters.f]\nmatch_command = \"^cmd\"\ntail_lines = 2\n"; + assert_eq!(loss_of(toml, "a\nb\nc\nd\ne"), Lossiness::Whole); + } + + #[test] + fn test_loss_head_then_max_is_whole() { + let toml = "schema_version = 1\n[filters.f]\nmatch_command = \"^cmd\"\nhead_lines = 2\nmax_lines = 2\n"; + assert_eq!(loss_of(toml, "a\nb\nc\nd\ne"), Lossiness::Whole); + } + + #[test] + fn test_loss_head_plus_tail_is_whole() { + let toml = "schema_version = 1\n[filters.f]\nmatch_command = \"^cmd\"\nhead_lines = 1\ntail_lines = 1\n"; + assert_eq!(loss_of(toml, "a\nb\nc\nd\ne"), Lossiness::Whole); + } + + #[test] + fn test_loss_truncate_lines_at_is_whole() { + let toml = + "schema_version = 1\n[filters.f]\nmatch_command = \"^cmd\"\ntruncate_lines_at = 3\n"; + assert_eq!(loss_of(toml, "abcdefgh\nshort"), Lossiness::Whole); + } + + #[test] + fn test_loss_match_output_is_whole() { + let toml = "schema_version = 1\n[filters.f]\nmatch_command = \"^cmd\"\n[[filters.f.match_output]]\npattern = \"ok\"\nmessage = \"all good\"\n"; + let (out, loss) = apply_filter_with_info(&first_filter(toml), "everything ok here\nmore"); + assert_eq!(out, "all good"); + assert_eq!(loss, Lossiness::Whole); + } + + #[test] + fn test_loss_strip_only_is_none() { + let toml = "schema_version = 1\n[filters.f]\nmatch_command = \"^cmd\"\nstrip_lines_matching = [\"^noise\"]\n"; + let (out, loss) = apply_filter_with_info(&first_filter(toml), "keep\nnoise line\nkeep2"); + assert_eq!(out, "keep\nkeep2"); + assert_eq!(loss, Lossiness::None); + } + + #[test] + fn test_loss_on_empty_is_none() { + let toml = "schema_version = 1\n[filters.f]\nmatch_command = \"^cmd\"\nstrip_lines_matching = [\".\"]\non_empty = \"nothing\"\n"; + let (out, loss) = apply_filter_with_info(&first_filter(toml), "a\nb\nc"); + assert_eq!(out, "nothing"); + assert_eq!(loss, Lossiness::None); + } + + #[test] + fn test_loss_no_truncation_is_none() { + let toml = "schema_version = 1\n[filters.f]\nmatch_command = \"^cmd\"\nhead_lines = 10\n"; + assert_eq!(loss_of(toml, "a\nb\nc"), Lossiness::None); + } + + #[test] + fn test_apply_filter_wrapper_matches_with_info() { + let toml = "schema_version = 1\n[filters.f]\nmatch_command = \"^cmd\"\nhead_lines = 2\n"; + let f = first_filter(toml); + let input = "a\nb\nc\nd"; + assert_eq!(apply_filter(&f, input), apply_filter_with_info(&f, input).0); + } + // --- Pipeline primitives (existing) --- #[test] diff --git a/src/core/utils.rs b/src/core/utils.rs index a3bc84fe00..956d012ce9 100644 --- a/src/core/utils.rs +++ b/src/core/utils.rs @@ -7,8 +7,11 @@ use anyhow::{Context, Result}; use regex::Regex; +use serde_json::Value; +use std::fs; use std::path::PathBuf; use std::process::Command; +use std::sync::OnceLock; /// Truncates a string to `max_len` characters, appending `...` if needed. /// @@ -355,6 +358,62 @@ pub fn resolved_command(name: &str) -> Command { } } +/// Return Composer bin directories in precedence order. +/// +/// Composer allows overriding the default `vendor/bin` via `COMPOSER_BIN_DIR` +/// or `composer.json` `config.bin-dir`. Keep the default as a fallback so we +/// continue recognizing the common layout even when the repo is not configured. +pub fn composer_bin_dirs() -> Vec { + // Resolution depends only on the process's env + cwd composer.json, both + // constant for a single rtk invocation. The rewrite hot path queries this + // several times per command segment, so read the file once and cache. + static CACHE: OnceLock> = OnceLock::new(); + CACHE + .get_or_init(|| { + let env_bin_dir = std::env::var("COMPOSER_BIN_DIR").ok(); + let composer_json = fs::read_to_string("composer.json").ok(); + composer_bin_dirs_from(env_bin_dir.as_deref(), composer_json.as_deref()) + }) + .clone() +} + +pub fn composer_tool_paths(tool: &str) -> Vec { + composer_bin_dirs() + .into_iter() + .map(|dir| dir.join(tool)) + .collect() +} + +fn composer_bin_dirs_from(env_bin_dir: Option<&str>, composer_json: Option<&str>) -> Vec { + let mut dirs = Vec::new(); + + if let Some(dir) = env_bin_dir + .map(str::trim) + .filter(|dir| !dir.is_empty()) + .map(PathBuf::from) + .or_else(|| composer_json.and_then(read_composer_bin_dir)) + { + dirs.push(dir); + } + + let default_dir = PathBuf::from("vendor/bin"); + if !dirs.iter().any(|dir| dir == &default_dir) { + dirs.push(default_dir); + } + + dirs +} + +fn read_composer_bin_dir(composer_json: &str) -> Option { + let parsed: Value = serde_json::from_str(composer_json).ok()?; + let bin_dir = parsed.get("config")?.get("bin-dir")?.as_str()?.trim(); + if bin_dir.is_empty() { + None + } else { + Some(PathBuf::from(bin_dir)) + } +} + /// Check if a tool exists on PATH (PATHEXT-aware on Windows). /// /// Replaces manual `Command::new("which").arg(tool)` checks that fail on Windows. @@ -428,6 +487,32 @@ mod tests { assert_eq!(truncate("hello world", 3), "..."); } + #[test] + fn test_composer_bin_dirs_use_default_when_unconfigured() { + assert_eq!( + composer_bin_dirs_from(None, None), + vec![PathBuf::from("vendor/bin")] + ); + } + + #[test] + fn test_composer_bin_dirs_prefer_env_override() { + let composer_json = r#"{"config":{"bin-dir":"tools/bin"}}"#; + assert_eq!( + composer_bin_dirs_from(Some("custom/bin"), Some(composer_json)), + vec![PathBuf::from("custom/bin"), PathBuf::from("vendor/bin")] + ); + } + + #[test] + fn test_composer_bin_dirs_read_composer_config() { + let composer_json = r#"{"config":{"bin-dir":"tools/bin"}}"#; + assert_eq!( + composer_bin_dirs_from(None, Some(composer_json)), + vec![PathBuf::from("tools/bin"), PathBuf::from("vendor/bin")] + ); + } + #[test] fn test_strip_ansi_simple() { let input = "\x1b[31mError\x1b[0m"; @@ -554,6 +639,17 @@ mod tests { assert!(result.ends_with("...")); } + #[test] + fn test_truncate_multibyte_cyrillic_issue_2787() { + // Regression: `rtk gain --history` panicked slicing this at byte 22, + // which falls inside 'ะฝ' (Cyrillic chars are 2 bytes each) + let cmd = "rtk ls -la ะั€ะพะดะธะฝะฐะผะธั‡ะตัะบะธะน ั€ะฐัั‡ั‘ั‚ ะะพะฒั‹ะน"; + let result = truncate(cmd, 25); + assert_eq!(result.chars().count(), 25); + assert!(result.ends_with("...")); + assert_eq!(result, "rtk ls -la ะั€ะพะดะธะฝะฐะผะธั‡ะต..."); + } + // ===== resolve_binary tests (issue #212) ===== #[test] diff --git a/src/discover/README.md b/src/discover/README.md index 4897fe8709..bb6bc38c30 100644 --- a/src/discover/README.md +++ b/src/discover/README.md @@ -21,7 +21,7 @@ When a hook sends `cargo fmt --all && cargo test 2>&1 | tail -20`: โ†’ [Arg("cargo"), Arg("test"), Redirect("2>&1"), Operator("&&"), Arg("git"), Arg("status")] ``` -**Compound splitting** โ€” The rewrite engine walks the tokens, splitting on `Operator` (`&&`, `||`, `;`) and `Pipe` (`|`). Each segment is rewritten independently. For pipes, only the left side is rewritten (the pipe consumer like `grep` or `head` runs raw). `find`/`fd` before a pipe is never rewritten because rtk's grouped output format breaks pipe consumers like `xargs`. +**Compound splitting** โ€” The rewrite engine walks the tokens, splitting on `Operator` (`&&`, `||`, `;`) and typed `Pipe` tokens (`|`, `|&`). For normal pipelines, producers and intermediate stages stay raw, and only an argument-safe final stage marked `pipeline_final_safe` is rewritten. The initial safe set is ordinary `grep` and `rg` invocations; search pattern-file forms (`-f`/`--file`) defer because they can consume pipeline stdin as configuration. Stderr pipelines (`|&`) and pipelines containing opaque shell groups remain raw. **Per-segment rewriting** โ€” Each segment goes through: diff --git a/src/discover/lexer.rs b/src/discover/lexer.rs index b4077224a0..6cca2a39e1 100644 --- a/src/discover/lexer.rs +++ b/src/discover/lexer.rs @@ -1,8 +1,16 @@ -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PipeKind { + /// Standard stdout pipeline (`|`). + Stdout, + /// Combined stdout-and-stderr pipeline (`|&`). + StdoutAndStderr, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TokenKind { Arg, Operator, - Pipe, + Pipe(PipeKind), Redirect, Shellism, } @@ -118,9 +126,17 @@ fn tokenize_inner(input: &str, emit_newline: bool) -> Vec { value: "||".into(), offset: start, }); + } else if chars.peek() == Some(&'&') { + chars.next(); + byte_pos += 1; + tokens.push(ParsedToken { + kind: TokenKind::Pipe(PipeKind::StdoutAndStderr), + value: "|&".into(), + offset: start, + }); } else { tokens.push(ParsedToken { - kind: TokenKind::Pipe, + kind: TokenKind::Pipe(PipeKind::Stdout), value: "|".into(), offset: start, }); @@ -346,7 +362,7 @@ pub fn split_for_permissions(cmd: &str) -> Vec<&str> { for tok in &tokens { let is_boundary = match tok.kind { - TokenKind::Operator | TokenKind::Pipe => true, + TokenKind::Operator | TokenKind::Pipe(_) => true, TokenKind::Shellism => matches!(tok.value.as_str(), "&" | "(" | ")"), _ => false, }; @@ -398,7 +414,7 @@ pub fn split_on_operators(cmd: &str, stop_at_pipe: bool) -> Vec<&str> { } seg_start = tok.offset + tok.value.len(); } - TokenKind::Pipe => { + TokenKind::Pipe(_) => { let segment = trimmed[seg_start..tok.offset].trim(); if !segment.is_empty() { results.push(segment); @@ -643,13 +659,35 @@ mod tests { #[test] fn test_pipe_detection() { let tokens = tokenize("cat file | grep pattern"); - assert!(tokens.iter().any(|t| t.kind == TokenKind::Pipe)); + assert!(tokens + .iter() + .any(|t| t.kind == TokenKind::Pipe(PipeKind::Stdout))); + } + + #[test] + fn test_stderr_pipe_is_atomic() { + let tokens = tokenize("cargo test |& grep FAILED"); + let pipes: Vec<_> = tokens + .iter() + .filter(|token| matches!(token.kind, TokenKind::Pipe(_))) + .collect(); + + assert_eq!(pipes.len(), 1); + assert_eq!(pipes[0].kind, TokenKind::Pipe(PipeKind::StdoutAndStderr)); + assert_eq!(pipes[0].value, "|&"); + assert!(!tokens + .iter() + .any(|token| token.kind == TokenKind::Shellism && token.value == "&")); + assert_eq!( + split_for_permissions("cargo test |& grep FAILED"), + vec!["cargo test", "grep FAILED"] + ); } #[test] fn test_quoted_pipe_not_pipe() { let tokens = tokenize("\"a|b\""); - assert!(!tokens.iter().any(|t| t.kind == TokenKind::Pipe)); + assert!(!tokens.iter().any(|t| matches!(t.kind, TokenKind::Pipe(_)))); } #[test] @@ -657,7 +695,7 @@ mod tests { let tokens = tokenize("a | b | c"); let pipes: Vec<_> = tokens .iter() - .filter(|t| t.kind == TokenKind::Pipe) + .filter(|t| matches!(t.kind, TokenKind::Pipe(_))) .collect(); assert_eq!(pipes.len(), 2); } @@ -896,7 +934,7 @@ mod tests { assert!(tokens .iter() .any(|t| t.kind == TokenKind::Redirect && t.value == "2>&1")); - assert!(tokens.iter().any(|t| t.kind == TokenKind::Pipe)); + assert!(tokens.iter().any(|t| matches!(t.kind, TokenKind::Pipe(_)))); } #[test] @@ -997,7 +1035,7 @@ mod tests { let tokens = tokenize("find . -name '*.rs' | xargs grep 'fn run'"); let pipe_idx = tokens .iter() - .position(|t| t.kind == TokenKind::Pipe) + .position(|t| matches!(t.kind, TokenKind::Pipe(_))) .unwrap(); assert!(pipe_idx > 0); let before_pipe: Vec<_> = tokens[..pipe_idx] diff --git a/src/discover/registry.rs b/src/discover/registry.rs index b459cb02b0..7f39899ee4 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -1,11 +1,15 @@ //! Matches shell commands against known RTK rewrite rules to decide how to handle them. +use crate::core::utils::composer_bin_dirs; use lazy_static::lazy_static; use regex::{Regex, RegexSet}; +use std::path::Path; -use super::lexer::{split_on_operators, tokenize, TokenKind}; +use super::lexer::{shell_split, split_on_operators, tokenize, ParsedToken, PipeKind, TokenKind}; use super::rules::{IGNORED_EXACT, IGNORED_PREFIXES, RULES}; +const PHP_TOOL_NAMES: [&str; 6] = ["phpunit", "phpstan", "ecs", "pest", "paratest", "pint"]; + /// Result of classifying a command. #[derive(Debug, PartialEq)] pub enum Classification { @@ -120,6 +124,9 @@ pub fn classify_command(cmd: &str) -> Classification { let cmd_normalized = strip_absolute_path(cmd_clean); // Strip git global options: git -C /tmp status โ†’ git status (#163) let cmd_normalized = strip_git_global_opts(&cmd_normalized); + // Normalize PHP tool paths: vendor/bin/phpunit, bin/phpunit, or composer + // custom bin-dir โ†’ phpunit (so one rule matches every Composer layout). + let cmd_normalized = normalize_php_tool_command(&cmd_normalized); // Strip golangci-lint global options before `run` so classify/rewrite stays // aligned with the runtime wrapper behavior. let cmd_normalized = strip_golangci_global_opts(&cmd_normalized); @@ -247,6 +254,78 @@ pub fn split_command_chain(cmd: &str) -> Vec<&str> { split_on_operators(trimmed, true) } +fn normalize_php_tool_command(cmd: &str) -> String { + normalize_php_tool_command_with_dirs(cmd, &composer_bin_dirs()) +} + +/// Peel a leading `php` interpreter wrapper off a Composer-tool invocation +/// (`php vendor/bin/phpunit โ€ฆ` โ†’ `vendor/bin/phpunit โ€ฆ`) so the tool path +/// normalizes to its bare name. Only meaningful for the resolved tools, where +/// a `php` prefix is always the interpreter (never `php artisan`/`run-tests.php`). +fn strip_php_wrapper(cmd: &str) -> &str { + cmd.strip_prefix("php ").map_or(cmd, str::trim_start) +} + +fn normalize_php_tool_command_with_dirs(cmd: &str, bin_dirs: &[std::path::PathBuf]) -> String { + let first_space = cmd.find(char::is_whitespace); + let first_word = match first_space { + Some(pos) => &cmd[..pos], + None => cmd, + }; + + let Some(tool) = normalize_php_tool_word(first_word, bin_dirs) else { + return cmd.to_string(); + }; + + match first_space { + Some(pos) => format!("{}{}", tool, &cmd[pos..]), + None => tool.to_string(), + } +} + +fn normalize_php_tool_word<'a>(word: &str, bin_dirs: &'a [std::path::PathBuf]) -> Option<&'a str> { + let normalized_word = normalize_php_tool_path(word); + + for tool in PHP_TOOL_NAMES { + if normalized_word == tool { + return Some(tool); + } + + if bin_dirs + .iter() + .any(|bin_dir| matches_php_tool_path(&normalized_word, bin_dir, tool)) + { + return Some(tool); + } + } + + None +} + +fn matches_php_tool_path(word: &str, bin_dir: &Path, tool: &str) -> bool { + let normalized_dir = normalize_php_tool_path(&bin_dir.to_string_lossy()); + let candidate = format!("{normalized_dir}/{tool}"); + word == candidate || word.ends_with(&format!("/{candidate}")) +} + +fn normalize_php_tool_path(path: &str) -> String { + let mut normalized = path.trim().replace('\\', "/"); + while let Some(stripped) = normalized.strip_prefix("./") { + normalized = stripped.to_string(); + } + + if let Some((stem, ext)) = normalized.rsplit_once('.') { + if ["bat", "cmd", "exe", "ps1"] + .iter() + .any(|candidate| ext.eq_ignore_ascii_case(candidate)) + { + normalized = stem.to_string(); + } + } + + normalized +} + /// Strip git global options before the subcommand (#163). /// `git -C /tmp status` โ†’ `git status`, preserving the rest. /// Returns the original string unchanged if not a git command. @@ -462,8 +541,8 @@ fn collapse_line_continuations(s: &str) -> std::borrow::Cow<'_, str> { /// Returns `None` if the command is unsupported or ignored (hook should pass through). /// /// Handles compound commands (`&&`, `||`, `;`) by rewriting each segment independently. -/// For pipes (`|`), only rewrites the left-hand command (pipe targets stay raw), -/// but continues rewriting segments after subsequent `&&`/`||`/`;` operators. +/// For pipelines, preserves intermediate stages and only rewrites a pipeline-safe final stage, +/// then continues rewriting segments after subsequent `&&`/`||`/`;` operators. /// Also strips user-configured transparent wrapper prefixes /// (`[hooks].transparent_prefixes` in `config.toml`) before routing. /// @@ -471,9 +550,8 @@ fn collapse_line_continuations(s: &str) -> std::borrow::Cow<'_, str> { /// being run, only *how* it's run โ€” e.g. `docker exec mycontainer`, /// `direnv exec .`, `poetry run`, or `bundle exec`. Stripping it lets the inner /// command match a filter; the prefix is then re-prepended to the rewrite. The -/// built-in [`BUILTIN_TRANSPARENT_PREFIXES`] (`uv run`, `noglob`, `command`, -/// `builtin`, `exec`, `nocorrect`) are always applied in addition to -/// user-configured prefixes. +/// built-in [`ROUTABLE_WRAPPER_PREFIXES`] and [`SHELL_KEYWORD_PREFIXES`] are +/// always applied in addition to user-configured prefixes. /// /// Matching is strict: a configured prefix `"foo bar"` matches a command that /// starts with `"foo bar "` (or strictly equals `"foo bar"`), not anything @@ -516,6 +594,95 @@ pub fn rewrite_command( rewrite_compound(trimmed, &compiled, &normalized_prefixes) } +/// Pipeline boundaries used to rewrite its final stage. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct PipelineAnalysis { + end_offset: usize, + next_clause_offset: Option, + final_stage_start: Option, +} + +fn analyze_pipeline( + cmd: &str, + tokens: &[ParsedToken], + segment_start: usize, + first_pipe_offset: usize, +) -> PipelineAnalysis { + let next_clause_offset = tokens + .iter() + .find(|token| { + token.offset > first_pipe_offset + && (token.kind == TokenKind::Operator + || (token.kind == TokenKind::Shellism && token.value == "&")) + }) + .map(|token| token.offset); + let end_offset = next_clause_offset.unwrap_or(cmd.len()); + + let mut stage_start = segment_start; + let mut final_stage_start = None; + let mut has_supported_structure = true; + + for token in tokens { + if token.offset >= end_offset { + break; + } + if token.offset < first_pipe_offset { + continue; + } + let TokenKind::Pipe(kind) = token.kind else { + continue; + }; + + if cmd[stage_start..token.offset].trim().is_empty() || kind == PipeKind::StdoutAndStderr { + has_supported_structure = false; + } + + stage_start = token.offset + token.value.len(); + final_stage_start = Some(stage_start); + } + + if cmd[stage_start..end_offset].trim().is_empty() { + has_supported_structure = false; + } + + PipelineAnalysis { + end_offset, + next_clause_offset, + final_stage_start: if has_supported_structure { + final_stage_start + } else { + None + }, + } +} + +fn rewrite_pipeline_final_stage( + cmd: &str, + segment_start: usize, + analysis: PipelineAnalysis, + excluded: &[ExcludePattern], + transparent_prefixes: &[String], +) -> Option { + let final_stage_start = analysis.final_stage_start?; + let final_stage = cmd[final_stage_start..analysis.end_offset].trim(); + + rewrite_segment_inner( + final_stage, + excluded, + transparent_prefixes, + RewriteContext::PipelineFinal, + 0, + ) + .filter(|rewritten| rewritten != final_stage) + .map(|rewritten| { + format!( + "{} {}", + cmd[segment_start..final_stage_start].trim(), + rewritten + ) + }) +} + /// Rewrite a compound command (with `&&`, `||`, `;`, `|`) by rewriting each segment. fn rewrite_compound( cmd: &str, @@ -523,6 +690,16 @@ fn rewrite_compound( transparent_prefixes: &[String], ) -> Option { let tokens = tokenize(cmd); + let has_pipe = tokens + .iter() + .any(|token| matches!(token.kind, TokenKind::Pipe(_))); + let has_opaque_grouping = tokens.iter().any(|token| { + token.kind == TokenKind::Shellism && matches!(token.value.as_str(), "(" | ")" | "{" | "}") + }); + if has_pipe && has_opaque_grouping { + return None; + } + let mut result = String::with_capacity(cmd.len() + 32); let mut any_changed = false; let mut seg_start: usize = 0; @@ -556,38 +733,30 @@ fn rewrite_compound( seg_start += 1; } } - TokenKind::Pipe => { - let seg = cmd[seg_start..tok.offset].trim(); - let is_pipe_incompatible = seg.starts_with("find ") - || seg == "find" - || seg.starts_with("fd ") - || seg == "fd"; - let rewritten = if is_pipe_incompatible { - seg.to_string() - } else { - rewrite_segment(seg, excluded, transparent_prefixes) - .unwrap_or_else(|| seg.to_string()) - }; - if rewritten != seg { + TokenKind::Pipe(_) => { + let analysis = analyze_pipeline(cmd, &tokens, seg_start, tok.offset); + let pipeline = cmd[seg_start..analysis.end_offset].trim(); + let rewritten_pipeline = rewrite_pipeline_final_stage( + cmd, + seg_start, + analysis, + excluded, + transparent_prefixes, + ); + + if let Some(rewritten) = rewritten_pipeline { any_changed = true; + result.push_str(&rewritten); + } else { + result.push_str(pipeline); } - result.push_str(&rewritten); - - let pipe_group_end = tokens.iter().find(|t| { - t.offset > tok.offset - && (t.kind == TokenKind::Operator - || (t.kind == TokenKind::Shellism && t.value == "&")) - }); - match pipe_group_end { - Some(next_op) => { - result.push(' '); - result.push_str(cmd[tok.offset..next_op.offset].trim()); - seg_start = next_op.offset; + match analysis.next_clause_offset { + Some(next_clause_offset) => { + seg_start = next_clause_offset; + continue; } None => { - result.push(' '); - result.push_str(cmd[tok.offset..].trim_start()); return if any_changed { Some(result) } else { None }; } } @@ -651,19 +820,51 @@ fn rewrite_line_range(cmd: &str) -> Option { None } -/// Built-in transparent wrappers that use the same strip/recurse/re-prepend -/// contract as user-configured `transparent_prefixes`. -const BUILTIN_TRANSPARENT_PREFIXES: &[&str] = &[ - "uv run", - "noglob", - "command", - "builtin", - "exec", - "nocorrect", -]; +/// Transparent wrappers that RULES can also match as a whole string, so an +/// unfiltered inner command falls through instead of dropping the rewrite. +const ROUTABLE_WRAPPER_PREFIXES: &[&str] = &["uv run"]; + +/// Shell keywords that wrap a command without changing which one runs. They are +/// not spawnable, so they must never fall through: `rtk exec foo` cannot run. +const SHELL_KEYWORD_PREFIXES: &[&str] = &["noglob", "command", "builtin", "exec", "nocorrect"]; + +/// Every built-in transparent wrapper, paired with whether it may fall through. +/// Derived from the two lists above so they cannot drift apart. +fn builtin_transparent_prefixes() -> impl Iterator { + ROUTABLE_WRAPPER_PREFIXES + .iter() + .map(|prefix| (*prefix, true)) + .chain(SHELL_KEYWORD_PREFIXES.iter().map(|prefix| (*prefix, false))) +} const MAX_PREFIX_DEPTH: usize = 10; +#[derive(Clone, Copy, PartialEq, Eq)] +enum RewriteContext { + Normal, + PipelineFinal, +} + +/// Checks whether grep or rg reads patterns from a file. +fn search_uses_pattern_file(cmd: &str) -> bool { + shell_split(cmd) + .into_iter() + .skip(1) + .take_while(|arg| arg != "--") + .any(|arg| { + arg == "--file" + || arg.starts_with("--file=") + || arg + .strip_prefix('-') + .filter(|flags| !flags.starts_with('-')) + .is_some_and(|flags| flags.contains('f')) + }) +} + +fn pipeline_final_command_is_safe(rtk_cmd: &str, cmd: &str) -> bool { + !matches!(rtk_cmd, "rtk grep" | "rtk rg") || !search_uses_pattern_file(cmd) +} + enum ExcludePattern { Regex(Regex), Prefix(String), @@ -719,7 +920,13 @@ fn rewrite_segment( excluded: &[ExcludePattern], transparent_prefixes: &[String], ) -> Option { - rewrite_segment_inner(seg, excluded, transparent_prefixes, 0) + rewrite_segment_inner( + seg, + excluded, + transparent_prefixes, + RewriteContext::Normal, + 0, + ) } fn is_excluded(cmd: &str, excluded: &[ExcludePattern]) -> bool { @@ -733,6 +940,7 @@ fn rewrite_segment_inner( seg: &str, excluded: &[ExcludePattern], transparent_prefixes: &[String], + context: RewriteContext, depth: usize, ) -> Option { let trimmed = seg.trim(); @@ -755,29 +963,43 @@ fn rewrite_segment_inner( ); return None; } - let rewritten = - rewrite_segment_inner(rest_after_env, excluded, transparent_prefixes, depth + 1)?; + let rewritten = rewrite_segment_inner( + rest_after_env, + excluded, + transparent_prefixes, + context, + depth + 1, + )?; return Some(format!("{}{}", env_prefix, rewritten)); } - for &prefix in BUILTIN_TRANSPARENT_PREFIXES { + for (prefix, routable) in builtin_transparent_prefixes() { if let Some(rest) = strip_word_prefix(trimmed, prefix) { if rest.is_empty() { return None; } - return rewrite_segment_inner(rest, excluded, transparent_prefixes, depth + 1) - .map(|rewritten| format!("{} {}", prefix, rewritten)); + if let Some(rewritten) = + rewrite_segment_inner(rest, excluded, transparent_prefixes, context, depth + 1) + { + return Some(format!("{} {}", prefix, rewritten)); + } + // #2768: falling through re-tests the full prefixed string, which is + // only valid when the wrapper is itself a routable command. + if !routable { + return None; + } + break; } } - // User-configured wrapper prefixes (e.g. `docker exec mycontainer`). Same - // strip-recurse-reprepend contract as the builtin list above. + // User-configured wrapper prefixes (e.g. `docker exec mycontainer`). These + // never fall through: an unmatched inner command drops the rewrite. for prefix in transparent_prefixes { if let Some(rest) = strip_word_prefix(trimmed, prefix) { if rest.is_empty() { return None; } - return rewrite_segment_inner(rest, excluded, transparent_prefixes, depth + 1) + return rewrite_segment_inner(rest, excluded, transparent_prefixes, context, depth + 1) .map(|rewritten| format!("{} {}", prefix, rewritten)); } } @@ -791,7 +1013,9 @@ fn rewrite_segment_inner( return Some(trimmed.to_string()); } - if cmd_part.starts_with("head -") || cmd_part.starts_with("tail ") { + if context == RewriteContext::Normal + && (cmd_part.starts_with("head -") || cmd_part.starts_with("tail ")) + { return rewrite_line_range(cmd_part).map(|r| format!("{}{}", r, redirect_suffix)); } @@ -815,11 +1039,37 @@ fn rewrite_segment_inner( } rtk_equivalent } - _ => return None, + // TOML-only commands: consult the registry so the hook filters them too (#2179). + Classification::Unsupported { .. } => { + if context == RewriteContext::PipelineFinal { + return None; + } + if crate::core::toml_filter::toml_disabled() { + return None; + } + let normalized = strip_absolute_path(cmd_part.trim()); + if is_excluded(&normalized, excluded) { + return None; + } + let base = normalized.split_whitespace().next().unwrap_or(""); + if crate::core::toml_filter::is_rtk_reserved_command(base) { + return None; + } + if crate::core::toml_filter::command_matches_filter(&normalized) { + return Some(format!("rtk {}{}", cmd_part, redirect_suffix)); + } + return None; + } + Classification::Ignored => return None, }; // Find the matching rule (rtk_cmd values are unique across all rules) let rule = RULES.iter().find(|r| r.rtk_cmd == rtk_equivalent)?; + if context == RewriteContext::PipelineFinal + && (!rule.pipeline_final_safe || !pipeline_final_command_is_safe(rule.rtk_cmd, cmd_part)) + { + return None; + } if let Some(parts) = parse_golangci_run_parts(cmd_part) { let rewritten = if parts.global_segment.is_empty() { @@ -845,9 +1095,30 @@ fn rewrite_segment_inner( } } + // For the Composer-resolved php tools, normalize the leading invocation + // (php wrapper + ini flags, ./, vendor/bin, composer bin-dir) exactly as + // classify_command does, so a small canonical prefix list matches every + // invocation form instead of enumerating each literal spelling. + let php_normalized; + let strip_target: &str = if rule + .rtk_cmd + .strip_prefix("rtk ") + .is_some_and(|t| PHP_TOOL_NAMES.contains(&t)) + { + // Peel `php ` then a leading `./` (normalize_php_tool_command only + // strips `./` for paths that resolve to a Composer tool, so a plain + // `./bin/` would otherwise survive and miss the prefix match). + let unwrapped = strip_php_wrapper(cmd_part); + let unwrapped = unwrapped.strip_prefix("./").unwrap_or(unwrapped); + php_normalized = normalize_php_tool_command(unwrapped); + &php_normalized + } else { + cmd_part + }; + // Try each rewrite prefix (longest first) with word-boundary check for &prefix in rule.rewrite_prefixes { - if let Some(rest) = strip_word_prefix(cmd_part, prefix) { + if let Some(rest) = strip_word_prefix(strip_target, prefix) { let rewritten = if rest.is_empty() { format!("{}{}", rule.rtk_cmd, redirect_suffix) } else { @@ -884,6 +1155,86 @@ mod tests { super::rewrite_command(cmd, excluded, &[]) } + fn analyze_test_pipeline(cmd: &str) -> PipelineAnalysis { + let tokens = tokenize(cmd); + let first_pipe_offset = tokens + .iter() + .find(|token| matches!(token.kind, TokenKind::Pipe(_))) + .expect("test command must contain a pipe") + .offset; + + analyze_pipeline(cmd, &tokens, 0, first_pipe_offset) + } + + #[test] + fn test_analyze_pipeline_finds_final_stage() { + let cmd = "git log | grep feat | wc -l"; + let analysis = analyze_test_pipeline(cmd); + + assert_eq!(analysis.end_offset, cmd.len()); + assert_eq!(analysis.next_clause_offset, None); + assert_eq!( + cmd[analysis.final_stage_start.unwrap()..analysis.end_offset].trim(), + "wc -l" + ); + } + + #[test] + fn test_analyze_pipeline_rejects_stderr_pipe() { + let analysis = analyze_test_pipeline("cargo test |& grep FAILED"); + + assert_eq!(analysis.final_stage_start, None); + } + + #[test] + fn test_analyze_pipeline_rejects_empty_stage() { + let analysis = analyze_test_pipeline("cargo test | | grep FAILED"); + + assert_eq!(analysis.final_stage_start, None); + } + + #[test] + fn test_analyze_pipeline_stops_at_next_clause() { + let cmd = "cargo test | grep FAILED && git status"; + let analysis = analyze_test_pipeline(cmd); + let next_clause_offset = cmd.find("&&").unwrap(); + + assert_eq!(analysis.end_offset, next_clause_offset); + assert_eq!(analysis.next_clause_offset, Some(next_clause_offset)); + assert_eq!( + cmd[analysis.final_stage_start.unwrap()..analysis.end_offset].trim(), + "grep FAILED" + ); + } + + #[test] + fn test_pipeline_final_safe_rule_set() { + let safe_rules: Vec<_> = RULES + .iter() + .filter(|rule| rule.pipeline_final_safe) + .map(|rule| rule.rtk_cmd) + .collect(); + + assert_eq!(safe_rules, vec!["rtk grep", "rtk rg"]); + } + + #[test] + fn test_pipeline_final_search_pattern_file_is_unsafe() { + for command in [ + "grep -f patterns.txt input.txt", + "grep -rfpatterns.txt input", + "grep --file patterns.txt input.txt", + "grep --file=patterns.txt input.txt", + "rg -f patterns.txt input.txt", + "rg --file=patterns.txt input.txt", + ] { + assert!(search_uses_pattern_file(command), "{command}"); + } + + assert!(!search_uses_pattern_file("grep -- -f")); + assert!(!search_uses_pattern_file("grep -F pattern")); + } + #[test] fn test_classify_git_status() { assert_eq!( @@ -1233,6 +1584,14 @@ mod tests { ); } + #[test] + fn test_rewrite_git_checkout() { + assert_eq!( + rewrite_command_no_prefixes("git checkout main", &[]), + Some("rtk git checkout main".into()) + ); + } + #[test] fn test_rewrite_git_log() { assert_eq!( @@ -1353,6 +1712,90 @@ mod tests { assert_eq!(rewrite_command_no_prefixes("cd /tmp", &[]), None); } + #[test] + fn test_rewrite_toml_orphan_jj() { + assert_eq!( + rewrite_command_no_prefixes("jj log", &[]), + Some("rtk jj log".into()) + ); + } + + #[test] + fn test_rewrite_toml_orphan_jq() { + assert_eq!( + rewrite_command_no_prefixes("jq .", &[]), + Some("rtk jq .".into()) + ); + } + + #[test] + fn test_rewrite_toml_orphan_just() { + assert_eq!( + rewrite_command_no_prefixes("just build", &[]), + Some("rtk just build".into()) + ); + } + + #[test] + fn test_rewrite_toml_absolute_path() { + assert_eq!( + rewrite_command_no_prefixes("/usr/bin/jj log", &[]), + Some("rtk /usr/bin/jj log".into()) + ); + } + + #[test] + fn test_rewrite_toml_redirect_suffix_preserved() { + assert_eq!( + rewrite_command_no_prefixes("jj log 2>&1", &[]), + Some("rtk jj log 2>&1".into()) + ); + } + + #[test] + fn test_rewrite_toml_pipe_rewrites_only_safe_final() { + assert_eq!( + rewrite_command_no_prefixes("jj log | grep change", &[]), + Some("jj log | rtk grep change".into()) + ); + } + + #[test] + fn test_rewrite_toml_compound() { + assert_eq!( + rewrite_command_no_prefixes("jj diff && jq .", &[]), + Some("rtk jj diff && rtk jq .".into()) + ); + } + + #[test] + fn test_rewrite_toml_env_prefix() { + assert_eq!( + rewrite_command_no_prefixes("FOO=bar jj log", &[]), + Some("FOO=bar rtk jj log".into()) + ); + } + + #[test] + fn test_rewrite_toml_respects_exclude() { + let excluded = vec!["jj".to_string()]; + assert_eq!(rewrite_command_no_prefixes("jj log", &excluded), None); + } + + #[test] + fn test_rewrite_toml_exclude_matches_absolute_path() { + let excluded = vec!["jj".to_string()]; + assert_eq!( + rewrite_command_no_prefixes("/usr/bin/jj log", &excluded), + None + ); + } + + #[test] + fn test_rewrite_toml_unknown_command_still_none() { + assert_eq!(rewrite_command_no_prefixes("frobnicate xyz", &[]), None); + } + #[test] fn test_rewrite_with_env_prefix() { assert_eq!( @@ -1488,11 +1931,10 @@ mod tests { } #[test] - fn test_rewrite_pipe_first_only() { - // After a pipe, the filter command stays raw + fn test_rewrite_pipe_final_safe_stage_only() { assert_eq!( rewrite_command_no_prefixes("git log -10 | grep feat", &[]), - Some("rtk git log -10 | grep feat".into()) + Some("git log -10 | rtk grep feat".into()) ); } @@ -1507,13 +1949,57 @@ mod tests { } #[test] - fn test_rewrite_find_pipe_xargs_wc() { + fn test_rewrite_find_pipe_wc_stays_raw() { assert_eq!( rewrite_command_no_prefixes("find src -type f | wc -l", &[]), None ); } + #[test] + fn test_rewrite_multi_pipe_with_wc_final_stays_raw() { + assert_eq!( + rewrite_command_no_prefixes("git log | grep feat | wc -l", &[]), + None + ); + } + + #[test] + fn test_rewrite_pipe_unsafe_final_stage_stays_raw() { + assert_eq!( + rewrite_command_no_prefixes("cargo test | tail -50", &[]), + None + ); + assert_eq!( + rewrite_command_no_prefixes("find . | xargs grep TODO", &[]), + None + ); + assert_eq!( + rewrite_command_no_prefixes( + "printf 'src/main.rs\\n' | grep -f /dev/null src/main.rs", + &[] + ), + None + ); + assert_eq!( + rewrite_command_no_prefixes( + "printf 'src/main.rs\\n' | rg --file=/dev/null src/main.rs", + &[] + ), + None + ); + } + + #[test] + fn test_rewrite_malformed_pipeline_stays_raw() { + assert_eq!(rewrite_command_no_prefixes("| grep FAILED", &[]), None); + assert_eq!(rewrite_command_no_prefixes("cargo test |", &[]), None); + assert_eq!( + rewrite_command_no_prefixes("cargo test | | grep FAILED", &[]), + None + ); + } + #[test] fn test_rewrite_find_no_pipe_still_rewritten() { // find WITHOUT a pipe should still be rewritten @@ -1680,8 +2166,8 @@ mod tests { #[test] fn test_rewrite_redirect_2_gt_amp_1_with_pipe() { assert_eq!( - rewrite_command_no_prefixes("cargo test 2>&1 | head", &[]), - Some("rtk cargo test 2>&1 | head".into()) + rewrite_command_no_prefixes("cargo test 2>&1 | grep FAILED", &[]), + Some("cargo test 2>&1 | rtk grep FAILED".into()) ); } @@ -2367,18 +2853,18 @@ mod tests { } #[test] - fn test_rewrite_uv_run_options_are_not_parsed() { + fn test_rewrite_uv_run_options_are_passed_through() { assert_eq!( rewrite_command_no_prefixes("uv run --unknown pytest tests/", &[]), - None + Some("rtk uv run --unknown pytest tests/".into()) ); assert_eq!( rewrite_command_no_prefixes("uv run -m pytest -q", &[]), - None + Some("rtk uv run -m pytest -q".into()) ); assert_eq!( rewrite_command_no_prefixes("uv run --module pytest -q", &[]), - None + Some("rtk uv run --module pytest -q".into()) ); } @@ -2406,6 +2892,72 @@ mod tests { ); } + #[test] + fn test_classify_uv_run() { + let commands = vec![ + "uv run python script.py", + "uv run pytest", + "uv run ruff check", + "uv run --project backend --extra dev python script.py", + ]; + + for command in commands { + assert!( + matches!( + classify_command(command), + Classification::Supported { + rtk_equivalent: "rtk uv", + .. + } + ), + "Failed for command: {}", + command + ); + } + } + + #[test] + fn test_shell_keyword_prefix_does_not_fall_through_to_whole_string() { + // `rtk exec date` would be unspawnable, so an unfiltered inner command + // must drop the rewrite rather than re-test the prefixed string. + for cmd in [ + "exec somethingunfiltered", + "noglob somethingunfiltered", + "command somethingunfiltered", + "builtin somethingunfiltered", + "nocorrect somethingunfiltered", + ] { + assert_eq!( + rewrite_command_no_prefixes(cmd, &[]), + None, + "Failed for command: {}", + cmd + ); + } + } + + #[test] + fn test_rewrite_uv_run() { + let cases = vec![ + ("uv run pytest", "uv run rtk pytest"), + ("uv run ruff check", "uv run rtk ruff check"), + ("uv run python script.py", "rtk uv run python script.py"), + ( + "uv run --project backend --extra dev python script.py", + "rtk uv run --project backend --extra dev python script.py", + ), + ]; + + for (command, expected) in cases { + assert_eq!( + rewrite_command_no_prefixes(command, &[]), + Some(expected.to_string()), + "Failed for command: {}", + command + ); + } + } + // --- Go tooling --- #[test] @@ -3152,6 +3704,35 @@ mod tests { ); } + #[test] + fn test_rewrite_sbt_test_only() { + assert_eq!( + rewrite_command_no_prefixes("sbt testOnly com.example.MySpec", &[]), + Some("rtk sbt testOnly com.example.MySpec".into()) + ); + assert_eq!( + rewrite_command_no_prefixes(r#"sbt "testOnly com.example.MySpec""#, &[]), + Some(r#"rtk sbt "testOnly com.example.MySpec""#.into()) + ); + assert_eq!( + rewrite_command_no_prefixes(r#"sbt "testOnly *MySpec -- -z foo""#, &[]), + Some(r#"rtk sbt "testOnly *MySpec -- -z foo""#.into()) + ); + assert_eq!( + rewrite_command_no_prefixes("sbt testQuick", &[]), + Some("rtk sbt testQuick".into()) + ); + } + + #[test] + fn test_rewrite_sbt_does_not_match_unrelated_tasks() { + assert_eq!(rewrite_command_no_prefixes("sbt testify", &[]), None); + assert_eq!( + rewrite_command_no_prefixes(r#"sbt "test:compile""#, &[]), + None + ); + } + // --- Maven --- #[test] @@ -3302,10 +3883,10 @@ mod tests { #[test] fn test_rewrite_compound_pipe_raw_filter() { - // Pipe: rewrite first segment only, pass through rest unchanged + // Producers stay raw; only a pipeline-safe final stage is rewritten. assert_eq!( rewrite_command_no_prefixes("cargo test | grep FAILED", &[]), - Some("rtk cargo test | grep FAILED".into()) + Some("cargo test | rtk grep FAILED".into()) ); } @@ -3313,7 +3894,7 @@ mod tests { fn test_rewrite_compound_pipe_git_grep() { assert_eq!( rewrite_command_no_prefixes("git log -10 | grep feat", &[]), - Some("rtk git log -10 | grep feat".into()) + Some("git log -10 | rtk grep feat".into()) ); } @@ -4047,7 +4628,7 @@ mod tests { fn test_rewrite_pipe_then_and() { assert_eq!( rewrite_command_no_prefixes("git log | head -5 && git stash", &[]), - Some("rtk git log | head -5 && rtk git stash".into()) + Some("git log | head -5 && rtk git stash".into()) ); } @@ -4055,7 +4636,7 @@ mod tests { fn test_rewrite_pipe_then_semicolon() { assert_eq!( rewrite_command_no_prefixes("cargo test | head; git status", &[]), - Some("rtk cargo test | head; rtk git status".into()) + Some("cargo test | head; rtk git status".into()) ); } @@ -4063,7 +4644,7 @@ mod tests { fn test_rewrite_pipe_then_or() { assert_eq!( rewrite_command_no_prefixes("cargo test | grep FAIL || git stash", &[]), - Some("rtk cargo test | grep FAIL || rtk git stash".into()) + Some("cargo test | rtk grep FAIL || rtk git stash".into()) ); } @@ -4074,7 +4655,7 @@ mod tests { "RUST_BACKTRACE=1 cargo test 2>&1 | grep FAILED && git stash", &[] ), - Some("RUST_BACKTRACE=1 rtk cargo test 2>&1 | grep FAILED && rtk git stash".into()) + Some("RUST_BACKTRACE=1 cargo test 2>&1 | rtk grep FAILED && rtk git stash".into()) ); } @@ -4082,7 +4663,7 @@ mod tests { fn test_rewrite_and_then_pipe() { assert_eq!( rewrite_command_no_prefixes("git status && cargo test | grep FAIL", &[]), - Some("rtk git status && rtk cargo test | grep FAIL".into()) + Some("rtk git status && cargo test | rtk grep FAIL".into()) ); } @@ -4090,11 +4671,51 @@ mod tests { fn test_rewrite_multi_pipe_then_and() { assert_eq!( rewrite_command_no_prefixes("git log | head | tail && git status", &[]), - Some("rtk git log | head | tail && rtk git status".into()) + Some("git log | head | tail && rtk git status".into()) + ); + } + + #[test] + fn test_rewrite_pipeline_final_normalizes_prefixes() { + assert_eq!( + rewrite_command_no_prefixes("cargo test | FOO=1 command grep FAILED", &[]), + Some("cargo test | FOO=1 command rtk grep FAILED".into()) + ); + assert_eq!( + super::rewrite_command( + "cargo test | docker exec tools grep FAILED", + &[], + &["docker exec tools".into()] + ), + Some("cargo test | docker exec tools rtk grep FAILED".into()) + ); + } + + #[test] + fn test_rewrite_opaque_grouped_pipeline_stays_raw() { + assert_eq!( + rewrite_command_no_prefixes("echo x | { cat; git log; } | grep feat", &[]), + None + ); + } + + #[test] + fn test_rewrite_stderr_pipe_stays_raw() { + assert_eq!( + rewrite_command_no_prefixes("cargo test |& grep FAILED", &[]), + None + ); + assert_eq!( + rewrite_command_no_prefixes("cargo test | grep FAILED |& wc -l", &[]), + None + ); + assert_eq!( + rewrite_command_no_prefixes("cargo test |& grep FAILED && git status", &[]), + Some("cargo test |& grep FAILED && rtk git status".into()) ); } - // --- line-continuation handling (issue #1564) ------------------- + // --- line-continuation handling (issue #1564) --- #[test] fn test_rewrite_leading_backslash_newline() { @@ -4157,4 +4778,214 @@ mod tests { std::borrow::Cow::::Borrowed("git diff HEAD~1"), ); } + + // --- PHP tooling --- + + #[test] + fn test_classify_phpunit() { + assert!(matches!( + classify_command("phpunit tests/"), + Classification::Supported { + rtk_equivalent: "rtk phpunit", + .. + } + )); + } + + #[test] + fn test_classify_vendor_bin_phpunit() { + assert!(matches!( + classify_command("vendor/bin/phpunit --filter EmailTest"), + Classification::Supported { + rtk_equivalent: "rtk phpunit", + .. + } + )); + } + + #[test] + fn test_classify_php_vendor_bin_phpunit() { + assert!(matches!( + classify_command("php vendor/bin/phpunit tests/"), + Classification::Supported { + rtk_equivalent: "rtk phpunit", + .. + } + )); + } + + #[test] + fn test_rewrite_phpunit() { + assert_eq!( + rewrite_command_no_prefixes("phpunit tests/", &[]), + Some("rtk phpunit tests/".into()) + ); + } + + #[test] + fn test_rewrite_vendor_bin_phpunit() { + assert_eq!( + rewrite_command_no_prefixes("vendor/bin/phpunit --filter EmailTest", &[]), + Some("rtk phpunit --filter EmailTest".into()) + ); + } + + #[test] + fn test_rewrite_dotslash_vendor_bin() { + // `./vendor/bin/` is the common Laravel invocation form. classify + // normalizes the leading `./`, but the rewrite strips literal prefixes, + // so the `./vendor/bin/` prefix must be present or rewrite no-ops. + assert_eq!( + rewrite_command_no_prefixes("./vendor/bin/pint --test", &[]), + Some("rtk pint --test".into()) + ); + assert_eq!( + rewrite_command_no_prefixes("./vendor/bin/pest tests/", &[]), + Some("rtk pest tests/".into()) + ); + assert_eq!( + rewrite_command_no_prefixes("./vendor/bin/paratest", &[]), + Some("rtk paratest".into()) + ); + assert_eq!( + rewrite_command_no_prefixes("./vendor/bin/ecs check", &[]), + Some("rtk ecs check".into()) + ); + assert_eq!( + rewrite_command_no_prefixes("./vendor/bin/phpunit --filter EmailTest", &[]), + Some("rtk phpunit --filter EmailTest".into()) + ); + } + + #[test] + fn test_rewrite_php_tool_invocation_forms() { + // phpunit carries the full matrix: php wrapper, ./, plain bin/, vendor/bin. + // rewrite_segment_inner normalizes each to the same canonical rewrite. + for cmd in [ + "phpunit tests/", + "vendor/bin/phpunit tests/", + "./vendor/bin/phpunit tests/", + "bin/phpunit tests/", + "./bin/phpunit tests/", + "php vendor/bin/phpunit tests/", + "php phpunit tests/", + ] { + assert_eq!( + rewrite_command_no_prefixes(cmd, &[]), + Some("rtk phpunit tests/".into()), + "form: {cmd}" + ); + } + + // pest/pint/ecs/paratest use the simpler variant: ./ and vendor/bin only. + for cmd in ["pint", "vendor/bin/pint", "./vendor/bin/pint", "./pint"] { + assert_eq!( + rewrite_command_no_prefixes(cmd, &[]), + Some("rtk pint".into()), + "form: {cmd}" + ); + } + // Forms the simpler variant intentionally does not accept (no php + // wrapper, no plain bin/) โ€” must not rewrite rather than misfire. + assert_eq!( + rewrite_command_no_prefixes("php vendor/bin/pint", &[]), + None + ); + assert_eq!(rewrite_command_no_prefixes("bin/pint", &[]), None); + } + + #[test] + fn test_classify_phpstan() { + assert!(matches!( + classify_command("vendor/bin/phpstan analyse src/"), + Classification::Supported { + rtk_equivalent: "rtk phpstan", + .. + } + )); + } + + #[test] + fn test_classify_phpstan_direct() { + assert!(matches!( + classify_command("phpstan analyse --level=9"), + Classification::Supported { + rtk_equivalent: "rtk phpstan", + .. + } + )); + } + + #[test] + fn test_rewrite_phpstan_vendor_bin() { + assert_eq!( + rewrite_command_no_prefixes("vendor/bin/phpstan analyse src/", &[]), + Some("rtk phpstan analyse src/".into()) + ); + } + + #[test] + fn test_rewrite_phpstan_php_prefix() { + assert_eq!( + rewrite_command_no_prefixes("php vendor/bin/phpstan analyse", &[]), + Some("rtk phpstan analyse".into()) + ); + } + + #[test] + fn test_rewrite_phpstan_version_not_rewritten() { + assert_eq!(rewrite_command_no_prefixes("phpstan --version", &[]), None); + assert_eq!(rewrite_command_no_prefixes("phpstan list", &[]), None); + assert_eq!( + rewrite_command_no_prefixes("phpstan clear-result-cache", &[]), + None + ); + } + + #[test] + fn test_classify_pest() { + assert!(matches!( + classify_command("vendor/bin/pest tests/"), + Classification::Supported { + rtk_equivalent: "rtk pest", + .. + } + )); + } + + #[test] + fn test_classify_pint() { + assert!(matches!( + classify_command("vendor/bin/pint --test"), + Classification::Supported { + rtk_equivalent: "rtk pint", + .. + } + )); + } + + #[test] + fn test_php_artisan_rewrites() { + assert!(matches!( + classify_command("php artisan migrate"), + Classification::Supported { + rtk_equivalent: "rtk php", + .. + } + )); + } + + #[test] + fn test_normalize_php_tool_command_custom_bin_dir() { + use std::path::PathBuf; + let dirs = vec![PathBuf::from("tools/bin"), PathBuf::from("vendor/bin")]; + assert_eq!( + normalize_php_tool_command_with_dirs("tools/bin/phpunit tests/", &dirs), + "phpunit tests/" + ); + assert_eq!( + normalize_php_tool_command_with_dirs("./tools/bin/pest", &dirs), + "pest" + ); + } } diff --git a/src/discover/rules.rs b/src/discover/rules.rs index cb66e422f5..49c0ff740a 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -3,6 +3,8 @@ use super::report::RtkStatus; pub struct RtkRule { pub pattern: &'static str, pub rtk_cmd: &'static str, + /// Whether this command may be rewritten as the final pipeline stage. + pub pipeline_final_safe: bool, pub rewrite_prefixes: &'static [&'static str], pub category: &'static str, pub savings_pct: f64, @@ -10,9 +12,32 @@ pub struct RtkRule { pub subcmd_status: &'static [(&'static str, RtkStatus)], } +impl RtkRule { + // `Default::default()` isn't a const fn on stable, so `RULES` (a const array) can't call + // it via `..Default::default()` โ€” hence this associated const as the const-context + // workaround. Once `const_trait_impl` stabilizes, drop this and derive/impl + // `const Default` instead, then switch call sites back to `..RtkRule::default()`. + pub const DEFAULT: RtkRule = RtkRule { + pattern: "", + rtk_cmd: "", + pipeline_final_safe: false, + rewrite_prefixes: &[], + category: "", + savings_pct: 60.0, + subcmd_savings: &[], + subcmd_status: &[], + }; +} + +impl Default for RtkRule { + fn default() -> Self { + Self::DEFAULT + } +} + pub const RULES: &[RtkRule] = &[ RtkRule { - pattern: r"^(?:git|yadm)\s+(?:-[Cc]\s+\S+\s+)*(status|log|diff|show|add|commit|push|pull|branch|fetch|stash|worktree)", + pattern: r"^(?:git|yadm)\s+(?:-[Cc]\s+\S+\s+)*(status|log|diff|show|add|commit|checkout|push|pull|branch|fetch|stash|worktree)", rtk_cmd: "rtk git", rewrite_prefixes: &["git", "yadm"], category: "Git", @@ -23,7 +48,7 @@ pub const RULES: &[RtkRule] = &[ ("add", 59.0), ("commit", 59.0), ], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^gh\s+(pr|issue|run|repo|api|release)", @@ -32,7 +57,7 @@ pub const RULES: &[RtkRule] = &[ category: "GitHub", savings_pct: 82.0, subcmd_savings: &[("pr", 87.0), ("run", 82.0), ("issue", 80.0)], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^glab\s+(mr|issue|ci|pipeline|api|release)", @@ -41,7 +66,7 @@ pub const RULES: &[RtkRule] = &[ category: "GitLab", savings_pct: 82.0, subcmd_savings: &[("mr", 87.0), ("ci", 82.0), ("issue", 80.0)], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^cargo\s+(build|test|clippy|check|fmt|install)", @@ -51,6 +76,7 @@ pub const RULES: &[RtkRule] = &[ savings_pct: 80.0, subcmd_savings: &[("test", 90.0), ("check", 80.0)], subcmd_status: &[("fmt", RtkStatus::Passthrough)], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^pnpm\s+(exec|i|install|list|ls|outdated|run|run-script)", @@ -58,8 +84,7 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["pnpm"], category: "PackageManager", savings_pct: 80.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^npm\s+(exec|run|run-script|rum|urn|x)(\s|$)", @@ -67,8 +92,7 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["npm"], category: "PackageManager", savings_pct: 70.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^npx\s+", @@ -76,35 +100,32 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["npx"], category: "PackageManager", savings_pct: 70.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^(cat|head|tail)\s+", rtk_cmd: "rtk read", rewrite_prefixes: &["cat", "head", "tail"], category: "Files", - savings_pct: 60.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^grep\s+", rtk_cmd: "rtk grep", + pipeline_final_safe: true, rewrite_prefixes: &["grep"], category: "Files", savings_pct: 75.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^rg\s+", rtk_cmd: "rtk rg", + pipeline_final_safe: true, rewrite_prefixes: &["rg"], category: "Files", savings_pct: 75.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^ls(\s|$)", @@ -112,8 +133,7 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["ls"], category: "Files", savings_pct: 65.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^find\s+", @@ -121,8 +141,7 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["find"], category: "Files", savings_pct: 70.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^((p?np(m|x)|p?npm\s+(exec|run|run-script)|npm\s+(rum|urn|x)|pnpm\s+dlx)\s+)?tsc(\s|$)", @@ -146,8 +165,7 @@ pub const RULES: &[RtkRule] = &[ ], category: "Build", savings_pct: 83.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^((p?np(m|x)|p?npm\s+(exec|run|run-script)|npm\s+(rum|urn|x)|pnpm\s+dlx)\s+)?(biome|eslint|lint)(\s|$)", @@ -197,8 +215,7 @@ pub const RULES: &[RtkRule] = &[ ], category: "Build", savings_pct: 84.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^((p?np(m|x)|p?npm\s+(exec|run|run-script)|npm\s+(rum|urn|x)|pnpm\s+dlx)\s+)?prettier", @@ -222,8 +239,7 @@ pub const RULES: &[RtkRule] = &[ ], category: "Build", savings_pct: 70.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^((p?np(m|x)|p?npm\s+(exec|run|run-script)|npm\s+(rum|urn|x)|pnpm\s+dlx)\s+)?next\s+build", @@ -247,8 +263,7 @@ pub const RULES: &[RtkRule] = &[ ], category: "Build", savings_pct: 87.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^((p?np(m|x)|p?npm\s+(exec|run|run-script)|npm\s+(rum|urn|x)|pnpm\s+dlx)\s+)?jest(\s+run)?(\s|$)", @@ -287,8 +302,7 @@ pub const RULES: &[RtkRule] = &[ ], category: "Tests", savings_pct: 99.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^((p?np(m|x)|p?npm\s+(exec|run|run-script)|npm\s+(rum|urn|x)|pnpm\s+dlx)\s+)?vitest(\s+run)?(\s|$)", @@ -327,8 +341,7 @@ pub const RULES: &[RtkRule] = &[ ], category: "Tests", savings_pct: 99.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^((p?np(m|x)|p?npm\s+(exec|run|run-script)|npm\s+(rum|urn|x)|pnpm\s+dlx)\s+)?playwright", @@ -352,8 +365,7 @@ pub const RULES: &[RtkRule] = &[ ], category: "Tests", savings_pct: 94.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^((p?np(m|x)|p?npm\s+(exec|run|run-script)|npm\s+(rum|urn|x)|pnpm\s+dlx)\s+)?prisma", @@ -377,8 +389,7 @@ pub const RULES: &[RtkRule] = &[ ], category: "Build", savings_pct: 88.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^docker\s+(ps|images|logs|run|exec|build|compose\s+(ps|logs|build))", @@ -386,8 +397,7 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["docker"], category: "Infra", savings_pct: 85.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^kubectl\s+(get|logs|describe|apply)", @@ -395,8 +405,7 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["kubectl"], category: "Infra", savings_pct: 85.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^oc\s+(get|logs|describe|apply|status|adm)", @@ -404,8 +413,7 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["oc"], category: "Infra", savings_pct: 85.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^tree(\s|$)", @@ -413,17 +421,14 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["tree"], category: "Files", savings_pct: 70.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^diff\s+", rtk_cmd: "rtk diff", rewrite_prefixes: &["diff"], category: "Files", - savings_pct: 60.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^curl\s+", @@ -431,8 +436,7 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["curl"], category: "Network", savings_pct: 70.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^wget\s+", @@ -440,8 +444,7 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["wget"], category: "Network", savings_pct: 65.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^(python3?\s+-m\s+)?mypy(\s|$)", @@ -449,8 +452,7 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["python3 -m mypy", "python -m mypy", "mypy"], category: "Build", savings_pct: 80.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^ruff\s+(check|format)", @@ -459,7 +461,7 @@ pub const RULES: &[RtkRule] = &[ category: "Python", savings_pct: 80.0, subcmd_savings: &[("check", 80.0), ("format", 75.0)], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^(python[0-9.]*\s+-m\s+)?pytest(\s|$)", @@ -467,8 +469,7 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["python3 -m pytest", "python -m pytest", "pytest"], category: "Python", savings_pct: 90.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^(pip3?|uv\s+pip)\s+(list|outdated|install|show)", @@ -477,7 +478,15 @@ pub const RULES: &[RtkRule] = &[ category: "Python", savings_pct: 75.0, subcmd_savings: &[("list", 75.0), ("outdated", 80.0)], - subcmd_status: &[], + ..RtkRule::DEFAULT + }, + RtkRule { + pattern: r"^uv\s+run(?:\s|$)", + rtk_cmd: "rtk uv", + rewrite_prefixes: &["uv"], + category: "Python", + savings_pct: 70.0, + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^go\s+(test|build|vet)", @@ -486,7 +495,7 @@ pub const RULES: &[RtkRule] = &[ category: "Go", savings_pct: 85.0, subcmd_savings: &[("test", 90.0), ("build", 80.0), ("vet", 75.0)], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^(?:golangci-lint|golangci)\s+(run)(?:\s|$)", @@ -494,8 +503,17 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["golangci-lint run", "golangci run"], category: "Go", savings_pct: 85.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT + }, + // Scala/SBT + RtkRule { + pattern: r#"^sbt\s+["']?(testOnly|testQuick|test|compile|run|clean|assembly|package)(?:[\s"']|$)"#, + rtk_cmd: "rtk sbt", + rewrite_prefixes: &["sbt"], + category: "Build", + savings_pct: 80.0, + subcmd_savings: &[("test", 90.0), ("testOnly", 90.0), ("compile", 75.0)], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^bundle\s+(install|update)\b", @@ -503,8 +521,7 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["bundle"], category: "Ruby", savings_pct: 70.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^(?:bundle\s+exec\s+)?(?:bin/)?(?:rake|rails)\s+test", @@ -519,7 +536,7 @@ pub const RULES: &[RtkRule] = &[ category: "Ruby", savings_pct: 85.0, subcmd_savings: &[("test", 90.0)], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^(?:bundle\s+exec\s+)?rspec(?:\s|$)", @@ -527,8 +544,7 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["bundle exec rspec", "bin/rspec", "rspec"], category: "Tests", savings_pct: 65.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^(?:bundle\s+exec\s+)?rubocop(?:\s|$)", @@ -536,8 +552,76 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["bundle exec rubocop", "rubocop"], category: "Build", savings_pct: 65.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT + }, + // PHP tooling + RtkRule { + pattern: r"^php\s+artisan(?:\s|$)", + rtk_cmd: "rtk php", + rewrite_prefixes: &["php"], + category: "Build", + savings_pct: 70.0, + ..RtkRule::DEFAULT + }, + RtkRule { + pattern: r"^php\s+-l(?:\s|$)", + rtk_cmd: "rtk php", + rewrite_prefixes: &["php"], + category: "Build", + ..RtkRule::DEFAULT + }, + RtkRule { + pattern: r"^(?:php\s+)?(?:\./)?(?:(?:vendor/)?bin/)?phpunit(?:\s|$)", + rtk_cmd: "rtk phpunit", + // rewrite_segment_inner normalizes the php wrapper, `./`, vendor/bin and + // composer bin-dir before matching, so only the residual forms remain: + // a plain `bin/` (not a Composer dir, so it survives normalization) and + // the bare tool name. + rewrite_prefixes: &["bin/phpunit", "phpunit"], + category: "Tests", + savings_pct: 75.0, + ..RtkRule::DEFAULT + }, + RtkRule { + pattern: r"^(?:php\s+)?(?:\./)?(?:(?:vendor/)?bin/)?phpstan\s+analy[sz]e\b", + rtk_cmd: "rtk phpstan", + rewrite_prefixes: &["bin/phpstan", "phpstan"], + category: "Build", + savings_pct: 65.0, + subcmd_savings: &[("analyse", 65.0), ("analyze", 65.0)], + ..RtkRule::DEFAULT + }, + RtkRule { + pattern: r"^(?:\./)?(?:vendor/bin/)?pest(?:\s|$)", + rtk_cmd: "rtk pest", + rewrite_prefixes: &["pest"], + category: "Tests", + savings_pct: 80.0, + ..RtkRule::DEFAULT + }, + RtkRule { + pattern: r"^(?:\./)?(?:vendor/bin/)?paratest(?:\s|$)", + rtk_cmd: "rtk paratest", + rewrite_prefixes: &["paratest"], + category: "Tests", + savings_pct: 80.0, + ..RtkRule::DEFAULT + }, + RtkRule { + pattern: r"^(?:\./)?(?:vendor/bin/)?ecs(?:\s|$)", + rtk_cmd: "rtk ecs", + rewrite_prefixes: &["ecs"], + category: "Build", + savings_pct: 70.0, + ..RtkRule::DEFAULT + }, + RtkRule { + pattern: r"^(?:\./)?(?:vendor/bin/)?pint(?:\s|$)", + rtk_cmd: "rtk pint", + rewrite_prefixes: &["pint"], + category: "Build", + savings_pct: 70.0, + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^aws\s+", @@ -561,7 +645,7 @@ pub const RULES: &[RtkRule] = &[ ("sqs", 78.0), ("secretsmanager", 75.0), ], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^psql(\s|$)", @@ -569,8 +653,7 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["psql"], category: "Infra", savings_pct: 75.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^ansible-playbook\b", @@ -578,8 +661,7 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["ansible-playbook"], category: "Infra", savings_pct: 70.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^brew\s+(install|upgrade)\b", @@ -587,8 +669,7 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["brew"], category: "PackageManager", savings_pct: 65.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^composer\s+(install|update|require)\b", @@ -596,17 +677,14 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["composer"], category: "PackageManager", savings_pct: 65.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^df(\s|$)", rtk_cmd: "rtk df", rewrite_prefixes: &["df"], category: "System", - savings_pct: 60.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^dotnet\s+build\b", @@ -614,26 +692,21 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["dotnet"], category: "Build", savings_pct: 70.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^du\b", rtk_cmd: "rtk du", rewrite_prefixes: &["du"], category: "System", - savings_pct: 60.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^fail2ban-client\b", rtk_cmd: "rtk fail2ban-client", rewrite_prefixes: &["fail2ban-client"], category: "Infra", - savings_pct: 60.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^gcloud\b", @@ -641,8 +714,7 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["gcloud"], category: "Infra", savings_pct: 65.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^(?:\./gradlew|gradlew\.bat|gradlew|gradle)(?:\s+(test|build|clean|assemble\w*|install\w*|check|lint\w*|dependencies))?(\s|$)", @@ -651,7 +723,7 @@ pub const RULES: &[RtkRule] = &[ category: "Build", savings_pct: 75.0, subcmd_savings: &[("test", 90.0), ("build", 80.0), ("check", 80.0)], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^hadolint\b", @@ -659,8 +731,7 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["hadolint"], category: "Build", savings_pct: 65.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^helm\b", @@ -668,17 +739,14 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["helm"], category: "Infra", savings_pct: 65.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^iptables\b", rtk_cmd: "rtk iptables", rewrite_prefixes: &["iptables"], category: "Infra", - savings_pct: 60.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^make\b", @@ -686,8 +754,7 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["make"], category: "Build", savings_pct: 65.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^markdownlint\b", @@ -695,8 +762,7 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["markdownlint"], category: "Build", savings_pct: 65.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^mix\s+(compile|format)(\s|$)", @@ -704,8 +770,7 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["mix"], category: "Build", savings_pct: 65.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^(?:\./mvnw|mvnw\.cmd|mvnw|mvn)\b(?:\s+\S+)*?\s+(compile|test|integration-test|package|install|verify|deploy)\b", @@ -713,17 +778,14 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["./mvnw", "mvnw.cmd", "mvnw", "mvn"], category: "Build", savings_pct: 82.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^ping\b", rtk_cmd: "rtk ping", rewrite_prefixes: &["ping"], category: "Network", - savings_pct: 60.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^pio\s+run", @@ -731,8 +793,7 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["pio"], category: "Build", savings_pct: 65.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^poetry\s+(install|lock|update)\b", @@ -740,8 +801,7 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["poetry"], category: "Python", savings_pct: 65.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^pre-commit\b", @@ -749,17 +809,14 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["pre-commit"], category: "Build", savings_pct: 65.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^ps(\s|$)", rtk_cmd: "rtk ps", rewrite_prefixes: &["ps"], category: "System", - savings_pct: 60.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^pulumi\s+(preview|up|destroy|refresh|stack)(\s|$)", @@ -774,7 +831,7 @@ pub const RULES: &[RtkRule] = &[ ("preview", 25.0), ("stack", 29.0), ], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^quarto\s+render", @@ -782,8 +839,7 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["quarto"], category: "Build", savings_pct: 65.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^rsync\b", @@ -791,8 +847,7 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["rsync"], category: "Network", savings_pct: 65.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^shellcheck\b", @@ -800,8 +855,7 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["shellcheck"], category: "Build", savings_pct: 65.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^shopify\s+theme\s+(push|pull)", @@ -809,17 +863,14 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["shopify"], category: "Build", savings_pct: 65.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^sops\b", rtk_cmd: "rtk sops", rewrite_prefixes: &["sops"], category: "Infra", - savings_pct: 60.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^swift\s+(build|test)\b", @@ -828,7 +879,7 @@ pub const RULES: &[RtkRule] = &[ category: "Build", savings_pct: 65.0, subcmd_savings: &[("test", 90.0)], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^systemctl\s+status\b", @@ -836,8 +887,7 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["systemctl"], category: "System", savings_pct: 65.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^terraform\s+plan", @@ -845,8 +895,7 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["terraform"], category: "Infra", savings_pct: 70.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^tofu\s+(fmt|init|plan|validate)(\s|$)", @@ -854,8 +903,7 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["tofu"], category: "Infra", savings_pct: 70.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^trunk\s+build", @@ -863,8 +911,7 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["trunk"], category: "Build", savings_pct: 65.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^uv\s+(sync|pip\s+install)\b", @@ -872,8 +919,7 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["uv"], category: "Python", savings_pct: 65.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^yamllint\b", @@ -881,17 +927,14 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["yamllint"], category: "Build", savings_pct: 65.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^wc(\s|$)", rtk_cmd: "rtk wc", rewrite_prefixes: &["wc"], category: "Files", - savings_pct: 60.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^gt\s+", @@ -899,8 +942,7 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["gt"], category: "Git", savings_pct: 70.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, RtkRule { pattern: r"^liquibase(?:\s|$)", @@ -908,8 +950,7 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &["liquibase"], category: "Infra", savings_pct: 65.0, - subcmd_savings: &[], - subcmd_status: &[], + ..RtkRule::DEFAULT }, ]; diff --git a/src/filters/README.md b/src/filters/README.md index 226899cc20..5b9bbb946e 100644 --- a/src/filters/README.md +++ b/src/filters/README.md @@ -120,3 +120,23 @@ First match wins. A project filter with the same name as a built-in shadows the ``` [rtk] warning: filter 'make' is shadowing a built-in filter ``` + +## Custom filters and trust + +You can add your own filters in two places (both use the format above): + +- **Project-local** โ€” `.rtk/filters.toml` (committed with a repo, applies in that project) +- **User-global** โ€” `~/.config/rtk/filters.toml` (applies in every project) + +Because a filter can rewrite or hide the command output an agent sees, **custom filter files are not applied until you trust them**. An untrusted (or edited) filter file is skipped **silently** on the command path โ€” RTK never prints a warning around a rewritten command. You discover and enable untrusted filters through commands you run deliberately: + +```bash +rtk trust # lists each detected filter (labelled project/global) + a risk summary, then asks to confirm ([y/N], or --yes) +rtk untrust # revokes trust +``` + +- Trust is recorded as a SHA-256 of the file's contents, so **editing a trusted file requires re-running `rtk trust`** โ€” a content change invalidates trust. +- `rtk init` detects existing custom filters and lets you enable them โ€” an interactive `[y/N]`, or `--trust-filters` / `--no-trust-filters` for scripts. It stays silent for an empty template (comments only) and, when run non-interactively, leaves filters disabled. +- Built-in filters (this directory) are compiled into the binary and always trusted; only the on-disk project and user-global files are gated. + +> **Honest limitation:** this is consent + tamper-evidence, not a sandbox. An attacker who can write your filter file can usually also write the trust store (`~/.local/share/rtk/trusted_filters.json`) and bypass the gate. It defends the common case โ€” a filter dropped in by a script, a dotfile sync, or an untrusted repo โ€” not a same-user attacker who specifically targets RTK. diff --git a/src/hooks/README.md b/src/hooks/README.md index a0c76b76de..67a0bf3cc0 100644 --- a/src/hooks/README.md +++ b/src/hooks/README.md @@ -86,6 +86,7 @@ Rules are loaded from all Claude Code `settings.json` files (project + global, i |------|------------|-------------------| | Claude Code (rtk-rewrite.sh) | Yes | `permissionDecision: "ask"` โ€” user prompted | | Copilot VS Code (rtk hook copilot) | Yes | `permissionDecision: "ask"` โ€” user prompted | +| Cursor (rtk hook cursor) | Ready | `permission: "ask",` โ€” users will be prompted when Cursor enforces the permission; in the meantime, allow | | Gemini CLI (rtk hook gemini) | No (allow/deny only) | allow (limitation โ€” no ask mode in Gemini) | | Copilot CLI (rtk hook copilot) | No updatedInput | deny-with-suggestion (unchanged) | | Codex | ask parsed but no-op | allow (limitation โ€” fails open) | diff --git a/src/hooks/constants.rs b/src/hooks/constants.rs index 23d2e10899..4caaf94473 100644 --- a/src/hooks/constants.rs +++ b/src/hooks/constants.rs @@ -12,6 +12,8 @@ pub const BEFORE_TOOL_KEY: &str = "BeforeTool"; pub const CLAUDE_HOOK_COMMAND: &str = "rtk hook claude"; /// Native Rust hook command for Cursor (replaces rtk-rewrite.sh). pub const CURSOR_HOOK_COMMAND: &str = "rtk hook cursor"; +/// Native Rust hook command for Factory Droid. +pub const DROID_HOOK_COMMAND: &str = "rtk hook droid"; pub const CONFIG_DIR: &str = ".config"; pub const OPENCODE_SUBDIR: &str = "opencode"; @@ -34,6 +36,23 @@ pub const PI_EXTENSIONS_SUBDIR: &str = "extensions"; pub const PI_PLUGIN_FILE: &str = "rtk.ts"; pub const PI_CODING_AGENT_DIR_ENV: &str = "PI_CODING_AGENT_DIR"; +/// Factory Droid config directory, joined onto the resolved home directory. +pub const DROID_DIR: &str = ".factory"; +/// Canonical Droid hooks file (Droid's own /hooks UI reads and writes this). +pub const DROID_HOOKS_FILE: &str = "hooks.json"; +/// Legacy nested hooks location (`.factory/hooks/hooks.json`), still read by +/// Droid when the root `hooks.json` is absent. +pub const DROID_HOOKS_SUBDIR: &str = "hooks"; +/// Droid settings file. Its `hooks` key is a fallback config surface: Droid +/// merges `hooks.json` OVER it per event key, so a `PreToolUse` entry here is +/// silently ignored once `hooks.json` defines `PreToolUse`. +pub const DROID_SETTINGS_FILE: &str = "settings.json"; +/// Tool matcher used by Droid for shell command execution. +pub const DROID_EXECUTE_MATCHER: &str = "Execute"; +/// Environment variable Droid uses to override its HOME directory (the +/// `.factory` segment is appended to it): `$FACTORY_HOME_OVERRIDE/.factory`. +pub const DROID_HOME_ENV: &str = "FACTORY_HOME_OVERRIDE"; + pub const HERMES_DIR: &str = ".hermes"; pub const HERMES_PLUGINS_SUBDIR: &str = "plugins"; pub const HERMES_PLUGIN_NAME: &str = "rtk-rewrite"; diff --git a/src/hooks/hook_check.rs b/src/hooks/hook_check.rs index ca6faf517f..72904e8c16 100644 --- a/src/hooks/hook_check.rs +++ b/src/hooks/hook_check.rs @@ -1,9 +1,8 @@ //! Detects whether RTK hooks are installed and warns if they are outdated. -use super::constants::{ - CLAUDE_HOOK_COMMAND, HOOKS_SUBDIR, PRE_TOOL_USE_KEY, REWRITE_HOOK_FILE, SETTINGS_JSON, -}; +use super::constants::{HOOKS_SUBDIR, PRE_TOOL_USE_KEY, REWRITE_HOOK_FILE, SETTINGS_JSON}; use super::init::resolve_claude_dir; +use super::is_claude_hook_command; use crate::core::constants::RTK_DATA_DIR; use std::path::PathBuf; @@ -82,7 +81,7 @@ fn binary_hook_registered(claude_dir: &std::path::Path) -> bool { .filter_map(|entry| entry.get("hooks")?.as_array()) .flatten() .filter_map(|hook| hook.get("command")?.as_str()) - .any(|cmd| cmd == CLAUDE_HOOK_COMMAND) + .any(is_claude_hook_command) } /// Check if the installed hook is missing or outdated, warn once per day. @@ -211,6 +210,29 @@ mod tests { assert_eq!(s.clone(), HookStatus::Missing); } + #[test] + fn test_binary_hook_registered_accepts_absolute_rtk_path() { + let tmp = tempfile::tempdir().expect("tempdir"); + std::fs::write( + tmp.path().join(SETTINGS_JSON), + r#"{ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{ + "type": "command", + "command": "/opt/homebrew/bin/rtk hook claude", + "timeout": 5 + }] + }] + } + }"#, + ) + .expect("write settings"); + + assert!(binary_hook_registered(tmp.path())); + } + #[test] fn test_other_integration_none() { let tmp = tempfile::tempdir().expect("tempdir"); diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 953080fdfa..1b1562c62e 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -35,6 +35,9 @@ enum HookFormat { /// Carries the full parsed `toolArgs` object so we can rewrite `command` while preserving /// host-supplied metadata (description, initial_wait, mode, โ€ฆ) the tool requires. CopilotCli { command: String, args: Value }, + /// JetBrains Copilot IDE: only top-level deny decisions are honored, so + /// rewrites must be returned as deny-with-suggestion responses. + CopilotIde { command: String }, /// Non-bash tool, already uses rtk, or unknown format โ€” pass through silently. PassThrough, } @@ -62,6 +65,7 @@ pub fn run_copilot() -> Result<()> { match detect_format(&v) { HookFormat::VsCode { command } => handle_vscode(&command), HookFormat::CopilotCli { command, args } => handle_copilot_cli(&command, &args), + HookFormat::CopilotIde { command } => handle_copilot_ide(&command), HookFormat::PassThrough => Ok(()), } } @@ -85,7 +89,7 @@ fn detect_format(v: &Value) -> HookFormat { // Copilot CLI: camelCase keys, toolArgs is a JSON-encoded string if let Some(tool_name) = v.get("toolName").and_then(|t| t.as_str()) { - if tool_name == "bash" { + if matches!(tool_name, "bash" | "run_in_terminal") { if let Some(tool_args_str) = v.get("toolArgs").and_then(|t| t.as_str()) { if let Ok(tool_args) = serde_json::from_str::(tool_args_str) { if let Some(cmd) = tool_args @@ -93,9 +97,15 @@ fn detect_format(v: &Value) -> HookFormat { .and_then(|c| c.as_str()) .filter(|c| !c.is_empty()) { - return HookFormat::CopilotCli { - command: cmd.to_string(), - args: tool_args, + return if tool_name == "run_in_terminal" { + HookFormat::CopilotIde { + command: cmd.to_string(), + } + } else { + HookFormat::CopilotCli { + command: cmd.to_string(), + args: tool_args, + } }; } } @@ -127,7 +137,7 @@ fn get_rewritten(cmd: &str) -> Option { enum HookDecision { AllowRewrite(String), - AskRewrite(String), + AskRewrite { rewritten: String, explicit: bool }, Defer, Deny, } @@ -141,7 +151,10 @@ fn decide_from_verdict(cmd: &str, verdict: PermissionVerdict) -> HookDecision { } match get_rewritten(cmd) { Some(r) if verdict == PermissionVerdict::Allow => HookDecision::AllowRewrite(r), - Some(r) => HookDecision::AskRewrite(r), + Some(r) => HookDecision::AskRewrite { + rewritten: r, + explicit: verdict == PermissionVerdict::Ask, + }, None => HookDecision::Defer, } } @@ -158,7 +171,7 @@ fn handle_vscode(cmd: &str) -> Result<()> { } HookDecision::Defer => return Ok(()), HookDecision::AllowRewrite(r) => ("allow", r), - HookDecision::AskRewrite(r) => ("ask", r), + HookDecision::AskRewrite { rewritten: r, .. } => ("ask", r), }; audit_log("rewrite", cmd, &rewritten); @@ -182,6 +195,34 @@ fn handle_copilot_cli(cmd: &str, args: &Value) -> Result<()> { Ok(()) } +fn handle_copilot_ide(cmd: &str) -> Result<()> { + if let Some(response) = + copilot_ide_response_from_decision(decide_hook_action(cmd, permissions::Host::Claude), cmd) + { + let _ = writeln!(io::stdout(), "{response}"); + } + Ok(()) +} + +fn copilot_ide_response_from_decision(decision: HookDecision, cmd: &str) -> Option { + let reason = match decision { + HookDecision::Defer => return None, + HookDecision::Deny => { + audit_log("deny", cmd, ""); + "Blocked by RTK permission rule".to_string() + } + HookDecision::AllowRewrite(rewritten) | HookDecision::AskRewrite { rewritten, .. } => { + audit_log("rewrite", cmd, &rewritten); + format!("RTK token optimization: re-run this command as `{rewritten}` instead.") + } + }; + + Some(json!({ + "permissionDecision": "deny", + "permissionDecisionReason": reason, + })) +} + fn copilot_cli_response(cmd: &str, args: &Value) -> Option { copilot_cli_response_from_decision( args, @@ -202,7 +243,13 @@ fn copilot_cli_response_from_decision( } HookDecision::Defer => return None, HookDecision::AllowRewrite(r) => (r, true), - HookDecision::AskRewrite(r) => (r, false), + HookDecision::AskRewrite { + rewritten: r, + explicit, + } => { + let is_simple = crate::discover::lexer::split_for_permissions(cmd).len() <= 1; + (r, !explicit && is_simple) + } }; audit_log("rewrite", cmd, &rewritten); @@ -258,7 +305,7 @@ pub fn run_gemini() -> Result<()> { audit_log("rewrite", cmd, rewritten); print_gemini("allow", Some(rewritten)); } - HookDecision::AskRewrite(ref rewritten) => { + HookDecision::AskRewrite { ref rewritten, .. } => { audit_log("ask", cmd, rewritten); print_gemini("ask_user", Some(rewritten)); } @@ -363,7 +410,7 @@ fn process_claude_payload(v: &Value) -> PayloadAction { } } HookDecision::AllowRewrite(r) => (r, true), - HookDecision::AskRewrite(r) => (r, false), + HookDecision::AskRewrite { rewritten: r, .. } => (r, false), }; let updated_input = { @@ -487,6 +534,10 @@ pub fn run_cursor() -> Result<()> { audit_log("rewrite", &cmd, &rewritten); cursor_allow(&rewritten) } + HookDecision::AskRewrite { rewritten, .. } => { + audit_log("ask", &cmd, &rewritten); + cursor_ask(&rewritten) + } other => { if matches!(other, HookDecision::Deny) { audit_log("deny", &cmd, ""); @@ -507,6 +558,15 @@ fn cursor_allow(rewritten: &str) -> String { .to_string() } +fn cursor_ask(rewritten: &str) -> String { + json!({ + "continue": true, + "permission": "ask", + "updated_input": { "command": rewritten } + }) + .to_string() +} + #[cfg(test)] fn run_cursor_inner(input: &str) -> String { run_cursor_inner_with_rules(input, &[], &[], &[]) @@ -537,10 +597,116 @@ fn run_cursor_inner_with_rules( let verdict = permissions::check_command_with_rules(&cmd, deny_rules, ask_rules, allow_rules); match decide_from_verdict(&cmd, verdict) { HookDecision::AllowRewrite(rewritten) => cursor_allow(&rewritten), + HookDecision::AskRewrite { rewritten, .. } => cursor_ask(&rewritten), _ => "{}".to_string(), } } +// โ”€โ”€ Factory Droid PreToolUse hook โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// +// Payload is shaped like Claude Code's (docs.factory.ai/reference/hooks-reference); +// the shell tool is matched as `Execute`. RTK steps aside on Droid's explicit +// deny lists and otherwise rewrites via `updatedInput` with no +// `permissionDecision` โ€” the verdict stays with Droid's native flow. + +fn process_droid_payload(v: &Value) -> Option { + let cmd = droid_execute_command(v)?; + droid_response_from_decision(v, cmd, decide_hook_action(cmd, permissions::Host::Droid)) +} + +/// Extract the shell command when the payload targets Droid's Execute tool. +fn droid_execute_command(v: &Value) -> Option<&str> { + let tool_name = v.get("tool_name").and_then(|t| t.as_str()).unwrap_or(""); + // `Execute` is Droid's shell tool. The installed matcher already gates + // invocations to Execute; also tolerate a missing tool_name and accept + // `Bash` defensively for Claude-shaped payloads (Droid itself has no Bash + // tool โ€” verified against Droid v0.164.0). + if !matches!(tool_name, "Execute" | "Bash" | "") { + return None; + } + + v.pointer("/tool_input/command") + .and_then(|c| c.as_str()) + .filter(|c| !c.is_empty()) +} + +/// Build the Droid hook response for a decision from the shared flow. +/// +/// On `Deny` and `Defer`, stay silent so Droid handles the original command. +/// Rewrites land via `updatedInput` alone โ€” never a `permissionDecision`: +/// RTK can't reproduce the verdict Droid would emit for a command it renames +/// to `rtk โ€ฆ` (updatedInput-without-decision verified on Droid v0.140โ€“0.164). +fn droid_response_from_decision(v: &Value, cmd: &str, decision: HookDecision) -> Option { + let rewritten = match decision { + HookDecision::Deny => { + audit_log("deny", cmd, ""); + return None; + } + HookDecision::Defer => return None, + HookDecision::AllowRewrite(r) | HookDecision::AskRewrite { rewritten: r, .. } => r, + }; + + audit_log("rewrite", cmd, &rewritten); + + let updated_input = { + let mut ti = v.get("tool_input").cloned().unwrap_or_else(|| json!({})); + if let Some(obj) = ti.as_object_mut() { + obj.insert("command".into(), Value::String(rewritten)); + } + ti + }; + + Some(json!({ + "hookSpecificOutput": { + "hookEventName": PRE_TOOL_USE_KEY, + "permissionDecisionReason": "RTK auto-rewrite", + "updatedInput": updated_input + } + })) +} + +/// Run the Factory Droid PreToolUse hook natively. +pub fn run_droid() -> Result<()> { + let input = read_stdin_limited()?; + let input = strip_leading_bom(&input).trim(); + if input.is_empty() { + return Ok(()); + } + + let v: Value = match serde_json::from_str(input) { + Ok(v) => v, + Err(e) => { + let _ = writeln!(io::stderr(), "[rtk hook] Failed to parse JSON input: {e}"); + return Ok(()); + } + }; + + if let Some(output) = process_droid_payload(&v) { + let _ = writeln!(io::stdout(), "{output}"); + } + Ok(()) +} + +/// Hermetic test path: no Droid settings (empty rules). +#[cfg(test)] +fn run_droid_inner(input: &str) -> Option { + let (deny, ask, allow) = permissions::droid_rules_from_settings(&[]); + run_droid_inner_with_rules(input, &deny, &ask, &allow) +} + +#[cfg(test)] +fn run_droid_inner_with_rules( + input: &str, + deny_rules: &[String], + ask_rules: &[String], + allow_rules: &[String], +) -> Option { + let v: Value = serde_json::from_str(input).ok()?; + let cmd = droid_execute_command(&v)?; + let verdict = permissions::check_command_with_rules(cmd, deny_rules, ask_rules, allow_rules); + droid_response_from_decision(&v, cmd, decide_from_verdict(cmd, verdict)).map(|o| o.to_string()) +} + #[cfg(test)] mod tests { use super::*; @@ -563,6 +729,16 @@ mod tests { json!({ "toolName": "bash", "toolArgs": args }) } + fn copilot_ide_input(cmd: &str) -> Value { + let args = serde_json::to_string(&json!({ + "command": cmd, + "explanation": "Run command", + "isBackground": false + })) + .unwrap(); + json!({ "toolName": "run_in_terminal", "toolArgs": args }) + } + #[test] fn test_detect_vscode_bash() { assert!(matches!( @@ -587,6 +763,14 @@ mod tests { )); } + #[test] + fn test_detect_copilot_ide_run_in_terminal() { + assert!(matches!( + detect_format(&copilot_ide_input("git status")), + HookFormat::CopilotIde { .. } + )); + } + #[test] fn test_detect_non_bash_is_passthrough() { let v = json!({ "tool_name": "editFiles" }); @@ -644,16 +828,37 @@ mod tests { } #[test] - fn test_copilot_cli_ask_rewrite_omits_permission_decision() { + fn test_copilot_cli_default_ask_rewrite_sets_permission_allow() { + let r = copilot_cli_response_from_decision( + &cli_args("cargo test"), + HookDecision::AskRewrite { + rewritten: "rtk cargo test".into(), + explicit: false, + }, + "cargo test", + ) + .unwrap(); + assert_eq!( + r["permissionDecision"], "allow", + "Default AskRewrite must set permissionDecision to allow โ€” Copilot CLI 1.0.66+ prompts on every command without it" + ); + assert_eq!(r["modifiedArgs"]["command"], "rtk cargo test"); + } + + #[test] + fn test_copilot_cli_explicit_ask_rewrite_omits_permission_decision() { let r = copilot_cli_response_from_decision( &cli_args("cargo test"), - HookDecision::AskRewrite("rtk cargo test".into()), + HookDecision::AskRewrite { + rewritten: "rtk cargo test".into(), + explicit: true, + }, "cargo test", ) .unwrap(); assert!( r.get("permissionDecision").is_none(), - "AskRewrite must NOT set permissionDecision โ€” Copilot then runs its normal prompt flow on the rewritten command" + "Explicit AskRewrite must NOT auto-allow โ€” user deliberately configured ask for this command" ); assert_eq!(r["modifiedArgs"]["command"], "rtk cargo test"); } @@ -692,6 +897,57 @@ mod tests { .is_none()); } + #[test] + fn test_copilot_ide_rewrite_returns_deny_with_suggestion() { + let response = copilot_ide_response_from_decision( + HookDecision::AskRewrite { + rewritten: "rtk git status".into(), + explicit: false, + }, + "git status", + ) + .unwrap(); + assert_eq!(response["permissionDecision"], "deny"); + assert!(response["permissionDecisionReason"] + .as_str() + .unwrap() + .contains("rtk git status")); + assert!(response.get("modifiedArgs").is_none()); + } + + #[test] + fn test_copilot_ide_allow_rewrite_returns_deny_with_suggestion() { + // The IDE host ignores modifiedArgs, so an Allow-with-rewrite decision + // must still surface as a deny-with-suggestion, exactly like AskRewrite. + let response = copilot_ide_response_from_decision( + HookDecision::AllowRewrite("rtk git status".into()), + "git status", + ) + .unwrap(); + assert_eq!(response["permissionDecision"], "deny"); + assert!(response["permissionDecisionReason"] + .as_str() + .unwrap() + .contains("rtk git status")); + assert!(response.get("modifiedArgs").is_none()); + } + + #[test] + fn test_copilot_ide_permission_deny_is_enforced() { + let response = + copilot_ide_response_from_decision(HookDecision::Deny, "rm -rf /protected").unwrap(); + assert_eq!(response["permissionDecision"], "deny"); + assert_eq!( + response["permissionDecisionReason"], + "Blocked by RTK permission rule" + ); + } + + #[test] + fn test_copilot_ide_defer_is_silent() { + assert!(copilot_ide_response_from_decision(HookDecision::Defer, "htop").is_none()); + } + #[test] fn test_copilot_cli_passthrough_unsupported() { assert!(copilot_cli_response("htop", &cli_args("htop")).is_none()); @@ -731,7 +987,10 @@ mod tests { }); let r = copilot_cli_response_from_decision( &args, - HookDecision::AskRewrite("rtk cargo install ripgrep".into()), + HookDecision::AskRewrite { + rewritten: "rtk cargo install ripgrep".into(), + explicit: false, + }, "cargo install ripgrep", ) .unwrap(); @@ -998,6 +1257,17 @@ mod tests { assert_eq!(cmd, "rtk git add . && rtk cargo test"); } + #[test] + fn test_claude_pipeline_rewrites_only_safe_final_stage() { + let result = run_claude_inner(&claude_input("cargo test | grep FAILED")).unwrap(); + let v: Value = serde_json::from_str(&result).unwrap(); + let cmd = v + .pointer("/hookSpecificOutput/updatedInput/command") + .and_then(|c| c.as_str()) + .unwrap(); + assert_eq!(cmd, "cargo test | rtk grep FAILED"); + } + #[test] fn test_claude_json_output_structure() { let result = run_claude_inner(&claude_input("git status")).unwrap(); @@ -1046,8 +1316,14 @@ mod tests { } #[test] - fn test_cursor_no_allow_rule_defers() { - assert_eq!(run_cursor_inner(&cursor_input("git status")), "{}"); + fn test_cursor_default_verdict_rewrites() { + let result = run_cursor_inner(&cursor_input("git status")); + let v: Value = serde_json::from_str(&result).unwrap(); + assert_eq!(v["permission"], "ask"); + assert_eq!(v["updated_input"]["command"], "rtk git status"); + // `continue: true` keeps the Cursor preToolUse panel from collapsing + // to `Output: {}`; without it the rewrite is invisible to users. + assert_eq!(v["continue"], true); } #[test] @@ -1063,14 +1339,15 @@ mod tests { } #[test] - fn test_cursor_unallowed_segment_defers() { + fn test_cursor_unallowed_segment_asks() { let out = run_cursor_inner_with_rules( &cursor_input("git status && rm -rf /tmp/x"), &[], &[], &["git *".to_string()], ); - assert_eq!(out, "{}"); + let v: Value = serde_json::from_str(&out).unwrap(); + assert_eq!(v["permission"], "ask"); } #[test] @@ -1287,7 +1564,7 @@ mod tests { fn test_decide_ask_for_default_verdict() { assert!(matches!( decide_with_rules("git status", &[], &[], &[]), - HookDecision::AskRewrite(_) + HookDecision::AskRewrite { .. } )); } @@ -1345,7 +1622,7 @@ mod tests { r#"{"decision":"deny","reason":"Blocked by RTK permission rule"}"#.to_string() } HookDecision::AllowRewrite(r) => gemini_json("allow", Some(&r)), - HookDecision::AskRewrite(r) => gemini_json("ask_user", Some(&r)), + HookDecision::AskRewrite { rewritten: r, .. } => gemini_json("ask_user", Some(&r)), HookDecision::Defer => gemini_json("ask_user", None), } } @@ -1391,4 +1668,218 @@ mod tests { .unwrap(); assert_eq!(v["decision"], "deny"); } + + // --- Factory Droid hook --- + + fn droid_input(tool: &str, cmd: &str) -> String { + json!({ + "session_id": "abc123", + "hook_event_name": "PreToolUse", + "tool_name": tool, + "tool_input": { "command": cmd } + }) + .to_string() + } + + #[test] + fn test_droid_rewrites_execute_tool() { + // Rewrites land via `updatedInput` with no decision โ€” Droid's native + // flow decides on the rewritten command. + let input = droid_input("Execute", "git status"); + let out = run_droid_inner(&input).expect("rewrite expected"); + let v: Value = serde_json::from_str(&out).unwrap(); + let updated = v + .pointer("/hookSpecificOutput/updatedInput/command") + .and_then(|c| c.as_str()) + .unwrap_or(""); + assert!( + updated.starts_with("rtk "), + "expected rtk-prefixed rewrite, got `{updated}`" + ); + assert_eq!( + v.pointer("/hookSpecificOutput/hookEventName") + .and_then(|c| c.as_str()), + Some("PreToolUse") + ); + assert!( + v.pointer("/hookSpecificOutput/permissionDecision") + .is_none(), + "RTK must never assert a permission decision for Droid" + ); + } + + #[test] + fn test_droid_unlisted_command_omits_decision() { + // Not on any Droid list โ†’ rewrite lands via Droid's "updated input + // result" path with NO decision, leaving Droid's native prompt and + // other hooks' deny/ask in control. + let input = droid_input("Execute", "cargo build"); + let out = run_droid_inner(&input).expect("rewrite expected"); + let v: Value = serde_json::from_str(&out).unwrap(); + assert!( + v.pointer("/hookSpecificOutput/updatedInput/command") + .and_then(|c| c.as_str()) + .is_some_and(|c| c.starts_with("rtk ")), + "expected rtk-prefixed rewrite" + ); + assert!( + v.pointer("/hookSpecificOutput/permissionDecision") + .is_none(), + "unlisted command must not force a permission decision" + ); + } + + #[test] + fn test_droid_denylisted_command_steps_aside() { + // A commandDenylist match must produce NO output: rewriting would + // dodge Droid's own pattern match (`rtk git log` no longer matches a + // `git log` denylist entry), silently dropping the user's + // always-confirm rule. Stepping aside keeps Droid's native + // confirmation on the original command. + let settings = json!({ "commandDenylist": ["git log"] }); + let (deny, ask, allow) = permissions::droid_rules_from_settings(&[settings]); + let input = droid_input("Execute", "git log --oneline"); + assert!( + run_droid_inner_with_rules(&input, &deny, &ask, &allow).is_none(), + "denylisted command must step aside (no output)" + ); + } + + #[test] + fn test_droid_blocklisted_command_steps_aside() { + // Same contract for commandBlocklist (never runs): step aside so + // Droid's Execute-level block fires on the original command. + let settings = json!({ "commandBlocklist": ["git status"] }); + let (deny, ask, allow) = permissions::droid_rules_from_settings(&[settings]); + let input = droid_input("Execute", "git status"); + assert!( + run_droid_inner_with_rules(&input, &deny, &ask, &allow).is_none(), + "blocklisted command must step aside (no output)" + ); + } + + #[test] + fn test_droid_allowlist_never_auto_allows() { + // Even an allowlisted command gets no decision โ€” RTK can't reproduce + // Droid's allow once the program is renamed to `rtk`. + let settings = json!({ "commandAllowlist": ["git status"] }); + let (deny, ask, allow) = permissions::droid_rules_from_settings(&[settings]); + let input = droid_input("Execute", "git status"); + let out = run_droid_inner_with_rules(&input, &deny, &ask, &allow) + .expect("rewrite still expected"); + let v: Value = serde_json::from_str(&out).unwrap(); + assert!( + v.pointer("/hookSpecificOutput/permissionDecision") + .is_none(), + "allowlisted command must not auto-allow" + ); + } + + #[test] + fn test_droid_project_scope_deny_steps_aside() { + // A deny entry in any scope (here: project) must step aside โ€” + // global-only reads would let the rewrite dodge it. + let user = json!({}); + let project = json!({ "commandDenylist": ["git log"] }); + let (deny, ask, allow) = permissions::droid_rules_from_settings(&[user, project]); + let input = droid_input("Execute", "git log --oneline"); + assert!( + run_droid_inner_with_rules(&input, &deny, &ask, &allow).is_none(), + "project-scope deny entry must step aside (no output)" + ); + } + + #[test] + fn test_droid_ignores_non_execute_tool() { + // Droid fires PreToolUse for many tools (Edit, Create, Readโ€ฆ); we must + // only touch Execute (or legacy Bash) so other tools pass through. + let input = droid_input("Edit", "git status"); + assert!( + run_droid_inner(&input).is_none(), + "non-Execute tools must not produce output" + ); + } + + #[test] + fn test_droid_bash_tool_name_accepted_defensively() { + // Droid has no Bash tool, but Claude-shaped payloads are accepted + // defensively; the installed matcher gates invocations to Execute. + let input = droid_input("Bash", "git status"); + assert!( + run_droid_inner(&input).is_some(), + "Bash tool name should still rewrite" + ); + } + + #[test] + fn test_droid_deny_steps_aside() { + // A denied command must produce NO output so Droid's native deny + // handling fires โ€” matching Claude/Cursor/Copilot. RTK must not emit + // its own `permissionDecision: deny` block. Decision is injected + // because decide_hook_action loads ambient rules that aren't present + // in the test environment. + let v: Value = serde_json::from_str(&droid_input("Execute", "git push --force")).unwrap(); + assert!( + droid_response_from_decision(&v, "git push --force", HookDecision::Deny).is_none(), + "deny must step aside (no output), not emit an RTK block" + ); + } + + #[test] + fn test_droid_allow_decision_emits_no_permission_decision() { + // Defensive: even an AllowRewrite decision carries the rewrite only. + let v: Value = serde_json::from_str(&droid_input("Execute", "git status")).unwrap(); + let out = droid_response_from_decision( + &v, + "git status", + HookDecision::AllowRewrite("rtk git status".to_string()), + ) + .expect("rewrite expected"); + assert!( + out.pointer("/hookSpecificOutput/permissionDecision") + .is_none(), + "no permission decision may be emitted" + ); + assert_eq!( + out.pointer("/hookSpecificOutput/updatedInput/command") + .and_then(|c| c.as_str()), + Some("rtk git status") + ); + } + + #[test] + fn test_droid_substitution_defers() { + // Commands with substitution can't be attested โ€” the shared decision + // flow defers so Droid runs the original command unchanged. + for cmd in ["git status `rm -rf /tmp/x`", "git status $(rm -rf /tmp/x)"] { + let input = droid_input("Execute", cmd); + assert!( + run_droid_inner(&input).is_none(), + "substitution must defer (no output) for {cmd}" + ); + } + } + + #[test] + fn test_droid_file_redirect_defers() { + let input = droid_input("Execute", "git log > /tmp/out.txt"); + assert!( + run_droid_inner(&input).is_none(), + "file redirects must defer (no output)" + ); + } + + #[test] + fn test_droid_empty_command_passthrough() { + let input = droid_input("Execute", ""); + assert!(run_droid_inner(&input).is_none()); + } + + #[test] + fn test_droid_no_rewrite_passthrough() { + // Commands rtk doesn't know about should not generate a hookSpecificOutput + // so Droid runs them unchanged. + let input = droid_input("Execute", "definitely-not-a-real-binary --foo"); + assert!(run_droid_inner(&input).is_none()); + } } diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 28363f4ce6..b71c6288c7 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -13,13 +13,15 @@ use crate::hooks::constants::{ }; use super::constants::{ - BEFORE_TOOL_KEY, CLAUDE_DIR, CLAUDE_HOOK_COMMAND, CODEX_DIR, CURSOR_HOOK_COMMAND, - GEMINI_HOOK_FILE, HERMES_DIR, HERMES_PLUGINS_SUBDIR, HERMES_PLUGIN_INIT_FILE, - HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, HOOKS_JSON, HOOKS_SUBDIR, - PI_CODING_AGENT_DIR_ENV, PI_DIR, PI_EXTENSIONS_SUBDIR, PI_LOCAL_DIR, PI_PLUGIN_FILE, - PRE_TOOL_USE_KEY, REWRITE_HOOK_FILE, SETTINGS_JSON, + BEFORE_TOOL_KEY, CLAUDE_DIR, CLAUDE_HOOK_COMMAND, CODEX_DIR, CURSOR_HOOK_COMMAND, DROID_DIR, + DROID_EXECUTE_MATCHER, DROID_HOME_ENV, DROID_HOOKS_FILE, DROID_HOOKS_SUBDIR, + DROID_HOOK_COMMAND, DROID_SETTINGS_FILE, GEMINI_HOOK_FILE, HERMES_DIR, HERMES_PLUGINS_SUBDIR, + HERMES_PLUGIN_INIT_FILE, HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, HOOKS_JSON, + HOOKS_SUBDIR, PI_CODING_AGENT_DIR_ENV, PI_DIR, PI_EXTENSIONS_SUBDIR, PI_LOCAL_DIR, + PI_PLUGIN_FILE, PRE_TOOL_USE_KEY, REWRITE_HOOK_FILE, SETTINGS_JSON, }; use super::integrity; +use super::is_claude_hook_command; // Embedded OpenCode plugin (auto-rewrite) const OPENCODE_PLUGIN: &str = include_str!("../../hooks/opencode/rtk.ts"); @@ -79,6 +81,14 @@ pub enum PatchMode { Skip, // --no-patch: manual instructions } +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum FilterTrust { + #[default] + Ask, + Trust, + Skip, +} + /// Result of settings.json patching operation #[derive(Debug, Clone, Copy, PartialEq)] pub enum PatchResult { @@ -183,6 +193,7 @@ rtk pnpm install # Compact install output (90%) rtk npm run