diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index 3b9f270ecf..2ff8218f38 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
{
- ".": "0.42.4"
+ ".": "0.43.0"
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2933768b44..8e9aab16cd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,55 @@ All notable changes to rtk (Rust Token Killer) will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [0.43.0](https://github.com/rtk-ai/rtk/compare/v0.42.4...v0.43.0) (2026-06-28)
+
+
+### Features
+
+* **grep:** sort content alphabetically ([307b557](https://github.com/rtk-ai/rtk/commit/307b5573838d24cc59191c12422ef1f216b9a087))
+* **oc:** add Openshift CLI support with shared k8s filtering ([39cbb96](https://github.com/rtk-ai/rtk/commit/39cbb968970d705d2080fc4062397f5b8650b595))
+* **oc:** add Openshift CLI support with shared k8s filtering ([c7f493b](https://github.com/rtk-ai/rtk/commit/c7f493b0cd0fbe9ed1d2da8ea66f86f2ca26cd01))
+* **pulumi:** add CLI filters for preview/up/destroy/refresh/stack ([ced70c6](https://github.com/rtk-ai/rtk/commit/ced70c6f0dcccd85365dcc78ca58f6b330d799e8))
+
+
+### Bug Fixes
+
+* **aws:** guard the s3 ls and s3 sync/cp text emits ([25a095e](https://github.com/rtk-ai/rtk/commit/25a095e90d7f8320d80577177f7623b13fa1bbad))
+* **core:** never-worse output guard ([af81b08](https://github.com/rtk-ai/rtk/commit/af81b08175af063c8b631979959d80c9007d089f))
+* **core:** never-worse output guard so RTK never exceeds the raw command ([861a46d](https://github.com/rtk-ai/rtk/commit/861a46dee57f50216862ac83b0ee57974f383203))
+* **diff:** report modified-only diffs and follow diff exit convention ([3a73bcd](https://github.com/rtk-ai/rtk/commit/3a73bcdffc553c0b1b81ea423b98b221468a7289))
+* **docker:** make the agent's command authoritative for the guard baseline ([b52db52](https://github.com/rtk-ai/rtk/commit/b52db52da065f65f9838e7fd12c496b7d28d06e8))
+* **docker:** report 0 containers/images instead of empty output ([3d4189c](https://github.com/rtk-ai/rtk/commit/3d4189cc6304a8eefc7ab94388b268054b94a634))
+* **dotnet:** keep raw fallback when parsed failures incomplete ([5e7eab5](https://github.com/rtk-ai/rtk/commit/5e7eab5846cfe2de1f0d0c2a7d6c38c8de6c65e5))
+* **dotnet:** stop duplicating failures on failing test runs ([2d9dc1a](https://github.com/rtk-ai/rtk/commit/2d9dc1ab9e0d25bf7fa6b8696f8909a561dd267f)), closes [#2501](https://github.com/rtk-ai/rtk/issues/2501)
+* **dotnet:** stop duplicating failures on failing test runs ([#2501](https://github.com/rtk-ai/rtk/issues/2501)) ([6946bf9](https://github.com/rtk-ai/rtk/commit/6946bf9562a26c7248d64d76564619a7d8dd4dd6))
+* **env:** clean up feature from secrets rewrite ([223dda2](https://github.com/rtk-ai/rtk/commit/223dda2996c061e93605d996a14d454a56198ec4))
+* **git:** propagate exit code on git status failure in compact path ([d86f007](https://github.com/rtk-ai/rtk/commit/d86f0073ec294d75a705c49c95061bd2c09e2b18))
+* **git:** propagate exit code on git status failure in compact path ([756c2a4](https://github.com/rtk-ai/rtk/commit/756c2a4ce84424a17965810392b21c7320b12678))
+* **git:** propagate exit code on git worktree list failure ([9a52647](https://github.com/rtk-ai/rtk/commit/9a52647f6529120723a04e5323f2b38be5b22655))
+* **git:** propagate exit code on git worktree list failure ([ebaaf8d](https://github.com/rtk-ai/rtk/commit/ebaaf8db586a1a31904a122cd815242e183bd536))
+* **git:** propagate exit code when commit fails instead of reporting ok ([2927248](https://github.com/rtk-ai/rtk/commit/29272484de23b1cd1144d97ca6c334da1ee81a61))
+* **git:** propagate exit code when commit fails instead of reporting ok ([e36dd8c](https://github.com/rtk-ai/rtk/commit/e36dd8cbe7cf5f37b6c67115d6d53eccc90c94ab)), closes [#2494](https://github.com/rtk-ai/rtk/issues/2494)
+* **grep:** correctly handle all flag shapes and never exceed raw output ([ee9e2f8](https://github.com/rtk-ai/rtk/commit/ee9e2f8a2ad9618495dad0f10763f4891ae47be8))
+* **grep:** correctly handle all flag shapes and never exceed raw output ([0adfae6](https://github.com/rtk-ai/rtk/commit/0adfae6cf61a90a98a007a6ef4a8745ab6f42a31))
+* **grep:** left-to-right cluster scan, long value flags, format passthrough ([b7d93b5](https://github.com/rtk-ai/rtk/commit/b7d93b509e67eee46de3bb518e7ca71429188a4b))
+* **grep:** match real grep output and read piped stdin ([37ee6cf](https://github.com/rtk-ai/rtk/commit/37ee6cf6fa7ecb1b511aafd0b111b68ba1d5c945))
+* **grep:** restore strip_r as explicit testable helper + pre-existing clippy fix ([8d29f75](https://github.com/rtk-ai/rtk/commit/8d29f75833c9afe5eebdb5d6d1ce1743503421e7))
+* **grep:** run the invoked engine instead of substituting rg for grep ([eafadce](https://github.com/rtk-ai/rtk/commit/eafadcee0042411ab9d28339d865a626567a72b0))
+* **grep:** stabilize argument parsing — trailing_var_arg, -v invert-match, --version passthrough, safe rg invocation ([d8c550e](https://github.com/rtk-ai/rtk/commit/d8c550eefba41e112bd174d58844a803db6e432f))
+* **grep:** surface error on exit code >= 2 instead of false "0 matches" ([d727db3](https://github.com/rtk-ai/rtk/commit/d727db3fa1f5b90d94cd8e1b893b6b10418b42bd)), closes [#2461](https://github.com/rtk-ai/rtk/issues/2461)
+* **grep:** surface the engine error and exit code, add nothing ([05f3c54](https://github.com/rtk-ai/rtk/commit/05f3c54886e6d39476b1e65275c599fe4dc470a3))
+* **grep:** use portable --null in system grep fallback (BSD/macOS) ([abe7d42](https://github.com/rtk-ai/rtk/commit/abe7d4210e0fe5c0b9322ec5210c6a6aadaa3db5))
+* **hook:** rewrite pytest under uv run ([c8722bd](https://github.com/rtk-ai/rtk/commit/c8722bd55e4441ac377b9c141c5f445e62fe9ecc))
+* **hook:** treat uv run as a transparent prefix ([34441dd](https://github.com/rtk-ai/rtk/commit/34441dd4648d5f73bf40988de0c9f2c839b3e988))
+* **pipe:** apply the never-worse guard ([9d9ad7c](https://github.com/rtk-ai/rtk/commit/9d9ad7cb177a1da22c4395dc6716fdf5e640a6f3))
+* **pulumi:** keep Owner and version in pulumi-stack filter ([5cfe4d5](https://github.com/rtk-ai/rtk/commit/5cfe4d5f87cecc7996cdbd837b7bf3dab2518a4e))
+* **pulumi:** keep stack identity in pulumi-stack filter ([1f6e36b](https://github.com/rtk-ai/rtk/commit/1f6e36b27ef4fd2237465c97a8e73fbe4e990f61))
+* **read:** make guard baseline faithful to cat/cat -n output ([9a2ad90](https://github.com/rtk-ai/rtk/commit/9a2ad90360b39ba8786f6056188ee9a939a9db28))
+* **tests:** resync & list oc as passthrough for test ([444f1c0](https://github.com/rtk-ai/rtk/commit/444f1c09082f8a9a843499980f89b8c0682ddfef))
+* **vitest:** add passthrough recovery hint ([f9469d1](https://github.com/rtk-ai/rtk/commit/f9469d12cfb48a6dda2b8c0578a7d5696804ae5f))
+* **vitest:** preserve explicit reporters ([e3f60e9](https://github.com/rtk-ai/rtk/commit/e3f60e982261cd4a488a9f4593c40b13738f5f35))
+
## [0.42.4](https://github.com/rtk-ai/rtk/compare/v0.42.3...v0.42.4) (2026-06-12)
diff --git a/Cargo.lock b/Cargo.lock
index b7380ea65e..9e366091ae 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -892,7 +892,7 @@ dependencies = [
[[package]]
name = "rtk"
-version = "0.42.4"
+version = "0.43.0"
dependencies = [
"anyhow",
"automod",
@@ -904,7 +904,6 @@ dependencies = [
"getrandom 0.4.2",
"ignore",
"lazy_static",
- "libc",
"quick-xml",
"regex",
"rusqlite",
diff --git a/Cargo.toml b/Cargo.toml
index 8ff5614d17..3efa7615e7 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "rtk"
-version = "0.42.4"
+version = "0.43.0"
edition = "2021"
rust-version = "1.91"
authors = ["Patrick Szymkowiak"]
@@ -35,14 +35,14 @@ quick-xml = "0.37"
which = "8"
automod = "1"
-[target.'cfg(unix)'.dependencies]
-libc = "0.2"
-
[build-dependencies]
toml = "0.8"
[dev-dependencies]
+[profile.test]
+debug = 0
+
[profile.release]
opt-level = 3
lto = true
diff --git a/README.md b/README.md
index 1d81e2bb75..99d0338444 100644
--- a/README.md
+++ b/README.md
@@ -1,505 +1,140 @@
-
-
-
-
-
- High-performance CLI proxy that reduces LLM token consumption by 60-90%
-
-
-
-
-
-
-
-
-
-
-
- Website •
- Install •
- Troubleshooting •
- Architecture •
- Discord
-
-
-
- English •
- Francais •
- 中文 •
- 日本語 •
- 한국어 •
- Espanol •
- Português
-
-
----
-
-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.
+# rtk-win — Rust Token Killer for Windows
-## Installation
-
-### Homebrew (recommended)
-
-```bash
-brew install rtk
-```
-
-### Quick Install (Linux/macOS)
-
-```bash
-curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh
-```
-
-> Installs to `~/.local/bin`. Add to PATH if needed:
-> ```bash
-> echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc # or ~/.zshrc
-> ```
-
-### Cargo
-
-```bash
-cargo install --git https://github.com/rtk-ai/rtk
-```
-
-### Pre-built Binaries
-
-Download from [releases](https://github.com/rtk-ai/rtk/releases):
-- macOS: `rtk-x86_64-apple-darwin.tar.gz` / `rtk-aarch64-apple-darwin.tar.gz`
-- 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.
-
-### Verify Installation
-
-```bash
-rtk --version # Should show "rtk 0.28.2"
-rtk gain # Should show token savings stats
-```
-
-> **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.
-
-## Quick Start
-
-```bash
-# 1. Install for your AI tool
-rtk init -g # Claude Code / Copilot (default)
-rtk init -g --gemini # Gemini CLI
-rtk init -g --codex # Codex (OpenAI)
-rtk init -g --agent cursor # Cursor
-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 -g --agent pi # Pi
-rtk init --agent hermes # Hermes
-
-# 2. Restart your AI tool, then test
-git status # Automatically rewritten to rtk git status
-```
-
-Hook-based agents rewrite Bash commands (e.g., `git status` -> `rtk git status`) before execution. Plugin-based agents, including Hermes, use their plugin API to rewrite commands before execution. The agent receives compact output without needing to call `rtk` explicitly.
-
-**Important:** the hook only runs on Bash tool calls. Claude Code built-in tools like `Read`, `Grep`, and `Glob` do not pass through the Bash hook, so they are not auto-rewritten. To get RTK's compact output for those workflows, use shell commands (`cat`/`head`/`tail`, `rg`/`grep`, `find`) or call `rtk read`, `rtk grep`, or `rtk find` directly.
+**Native Windows fork of [rtk](https://github.com/rtk-ai/rtk)** — replaces Unix-only commands (`ls`, `tree`, `wc`, `find`) with Rust-native implementations and adds PowerShell cmdlet TOML filters for 60-90% LLM token savings on Windows.
-## How It Works
+## What's Different
-```
- Without rtk: With rtk:
-
- Claude --git status--> shell --> git Claude --git status--> RTK --> git
- ^ | ^ | |
- | ~2,000 tokens (raw) | | ~200 tokens | filter |
- +-----------------------------------+ +------- (filtered) ---+----------+
-```
-
-Four strategies applied per command type:
-
-1. **Smart Filtering** - Removes noise (comments, whitespace, boilerplate)
-2. **Grouping** - Aggregates similar items (files by directory, errors by type)
-3. **Truncation** - Keeps relevant context, cuts redundancy
-4. **Deduplication** - Collapses repeated log lines with counts
-
-## Commands
-
-### Files
-```bash
-rtk ls . # Token-optimized 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
-rtk find "*.rs" . # Compact find results
-rtk grep "pattern" . # Grouped search results
-rtk diff file1 file2 # Condensed diff (exit 1 if files differ)
-```
-
-### Git
-```bash
-rtk git status # Compact status
-rtk git log -n 10 # One-line commits
-rtk git diff # Condensed diff
-rtk git add # -> "ok"
-rtk git commit -m "msg" # -> "ok abc1234"
-rtk git push # -> "ok main"
-rtk git pull # -> "ok 3 files +10 -2"
-```
-
-### GitHub CLI
-```bash
-rtk gh pr list # Compact PR listing
-rtk gh pr view 42 # PR details + checks
-rtk gh issue list # Compact issue listing
-rtk gh run list # Workflow run status
-```
-
-### Test Runners
-```bash
-rtk jest # Jest compact (failures only)
-rtk vitest # Vitest compact (failures only)
-rtk playwright test # E2E results (failures only)
-rtk pytest # Python tests (-90%)
-rtk go test # Go tests (NDJSON, -90%)
-rtk cargo test # Cargo tests (-90%)
-rtk rake test # Ruby minitest (-90%)
-rtk rspec # RSpec tests (JSON, -60%+)
-rtk err # Filter errors only from any command
-rtk test # Generic test wrapper - failures only (-90%)
-```
-
-### Build & Lint
-```bash
-rtk lint # ESLint grouped by rule/file
-rtk lint biome # Supports other linters
-rtk tsc # TypeScript errors grouped by file
-rtk next build # Next.js build compact
-rtk prettier --check . # Files needing formatting
-rtk cargo build # Cargo build (-80%)
-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%+)
-```
-
-### Package Managers
-```bash
-rtk pnpm list # Compact dependency tree
-rtk pip list # Python packages (auto-detect uv)
-rtk pip outdated # Outdated packages
-rtk bundle install # Ruby gems (strip Using lines)
-rtk prisma generate # Schema generation (no ASCII art)
-```
+| Area | Upstream rtk | rtk-win |
+|------|-------------|---------|
+| `ls`, `tree`, `wc`, `find` | Call Unix binaries via shell | Rust-native using `std::fs` / `walkdir` |
+| PowerShell cmdlets | Not supported | 15 TOML filters (`Get-ChildItem`, `Select-String`, etc.) |
+| Windows package managers | Not supported | `winget` TOML filter (strips progress, truncates tables) |
+| Install | `install.sh` (Unix) | `install.ps1` (PowerShell) |
+| Hooks | Bash scripts (`.sh`) | PowerShell scripts (`.ps1`) |
+| `rtk init --opencode` | Not available | Installs OpenCode plugin for transparent rewrite |
+| System dependencies | `libc`, Unix signal handlers, process groups | All removed — pure Win32 API via Rust stdlib |
-### AWS
-```bash
-rtk aws sts get-caller-identity # One-line identity
-rtk aws ec2 describe-instances # Compact instance list
-rtk aws lambda list-functions # Name/runtime/memory (strips secrets)
-rtk aws logs get-log-events # Timestamped messages only
-rtk aws cloudformation describe-stack-events # Failures first
-rtk aws dynamodb scan # Unwraps type annotations
-rtk aws iam list-roles # Strips policy documents
-rtk aws s3 ls # Truncated with tee recovery
-```
+## Token Savings (Windows Benchmark)
-### Containers
-```bash
-rtk docker ps # Compact container list
-rtk docker images # Compact image list
-rtk docker logs # Deduplicated logs
-rtk docker compose ps # Compose services
-rtk kubectl pods # Compact pod list
-rtk kubectl logs # Deduplicated logs
-rtk kubectl services # Compact service list
-rtk oc get pods # OpenShift pod summary
-rtk oc get services # OpenShift service list
-rtk oc logs # Deduplicated logs
```
+Command Raw (avg) RTK (avg) Saved
+ls (project src) 10,810 460 95.7%
+ls (drivers large) 40,303 1,725 95.7%
+wc (src .rs files) 878 160 81.8%
+find (src .rs) 3,848 758 80.3%
+find (src .toml) 2,612 768 70.6%
+git status 72 22 69.4%
+tree (src dirs) 5,317 2,671 49.8%
+winget list 6,897 3,520 49.0%
+systeminfo 3,637 2,520 30.7%
-### Infrastructure as Code
-```bash
-rtk pulumi preview # Strip header/URL/duration noise
-rtk pulumi up # Compact apply output
-rtk pulumi destroy # Compact destroy output
-rtk pulumi refresh # Drift summary
-rtk pulumi stack # Stack metadata (strips owner/timestamps)
+OVERALL (15 tests): 97,029 35,456 63.5%
```
-### Data & Analytics
-```bash
-rtk json config.json # Structure without values
-rtk deps # Dependencies summary
-rtk env -f AWS # Filtered env vars
-rtk log app.log # Deduplicated logs
-rtk curl # Truncate + save full output
-rtk wget # Download, strip progress bars
-rtk summary # Heuristic summary
-rtk proxy # Raw passthrough + tracking
-```
+> Run `scripts/benchmark.ps1` on your machine to get your own numbers.
-### Token Savings Analytics
-```bash
-rtk gain # Summary stats
-rtk gain --graph # ASCII graph (last 30 days)
-rtk gain --history # Recent command history
-rtk gain --daily # Day-by-day breakdown
-rtk gain --all --format json # JSON export for dashboards
+## Installation
-rtk discover # Find missed savings opportunities
-rtk discover --all --since 7 # All projects, last 7 days
+```powershell
+# From source (recommended)
+git clone https://github.com/YOUR_USER/rtk-win.git
+cd rtk-win
+.\install.ps1
-rtk session # Show RTK adoption across recent sessions
+# Or build manually
+cargo build --release
+Copy-Item target\release\rtk.exe $env:USERPROFILE\.cargo\bin\
```
-## Global Flags
+### Prerequisites
-```bash
--u, --ultra-compact # ASCII icons, inline format (extra token savings)
--v, --verbose # Increase verbosity (-v, -vv, -vvv)
-```
+- [Rust](https://rustup.rs/) (MSVC toolchain, `rustup default stable-x86_64-pc-windows-msvc`)
+- Windows 10+ (ARM64 or x64)
-## Examples
+## Quick Start
-**Directory listing:**
-```
-# ls -la (45 lines, ~800 tokens) # rtk ls (12 lines, ~150 tokens)
-drwxr-xr-x 15 user staff 480 ... my-project/
--rw-r--r-- 1 user staff 1234 ... +-- src/ (8 files)
-... | +-- main.rs
- +-- Cargo.toml
-```
+```powershell
+# Install for OpenCode
+rtk init -g --opencode
-**Git operations:**
-```
-# git push (15 lines, ~200 tokens) # rtk git push (1 line, ~10 tokens)
-Enumerating objects: 5, done. ok main
-Counting objects: 100% (5/5), done.
-Delta compression using up to 8 threads
-...
-```
+# Or for Claude Code / Cline / Gemini CLI
+rtk init -g --agent claude
+rtk init --agent cline
+rtk init -g --gemini
-**Test output:**
+# Test it
+rtk ls . # Compact directory listing
+rtk tree src # Truncated directory tree
+rtk wc src\*.rs # Line/word/byte counts
+rtk find -name "*.rs" # File search (grouped by directory)
+rtk git status # Compact git status
+rtk winget list # Truncated package list
```
-# cargo test (200+ lines on failure) # rtk test cargo test (~20 lines)
-running 15 tests FAILED: 2/15 tests
-test utils::test_parse ... ok test_edge_case: assertion failed
-test utils::test_format ... ok test_overflow: panic at utils.rs:18
-...
-```
-
-## Auto-Rewrite Hook
-
-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.
-
-**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.
-### Setup
+## Rust-Native Commands
-```bash
-rtk init -g # Install hook + RTK.md (recommended)
-rtk init -g --opencode # OpenCode plugin (instead of Claude Code)
-rtk init -g --auto-patch # Non-interactive (CI/CD)
-rtk init -g --hook-only # Hook only, no RTK.md
-rtk init --show # Verify installation
-```
+These commands run entirely inside RTK — no external process needed:
-After install, **restart Claude Code**.
+- **`ls`** — Compact directory listing with human-readable sizes, grouped by type, extension summary, `max_lines` truncation
+- **`tree`** — Unicode tree visualization, filters noise dirs (`node_modules`, `.git`, `target`), `max_lines` truncation
+- **`wc`** — Line/word/byte/char counting with common prefix stripping for multi-file mode
+- **`find`** — Glob-based file search supporting `-name`, `-iname`, `-type`, `-maxdepth`, results grouped by directory with extension summary (max 50 results by default)
-## Windows
+## PowerShell Cmdlet TOML Filters
-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.
+15 TOML filter files enable RTK to strip headers, progress bars, and noise from PowerShell command output when routed through `rtk proxy` or the OpenCode plugin hook:
-### Recommended: WSL (full support)
+`Get-ChildItem`, `Select-String`, `Get-Content`, `Measure-Object`, `Get-Process`, `Get-Service`, `Get-Help`, `Where-Object`, `Compare-Object`, `Get-Item`, `Get-Date`, `Get-Command`, `Get-Member`, `Get-Alias`, `Invoke-Command`
-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:
+All filters use `(?i)` case-insensitive matching and apply `strip_ansi`, `max_lines`, and `truncate_lines_at` for compact output.
-```bash
-# Inside WSL
-curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh
-rtk init -g
-```
+## Windows-Specific TOML Filters
-### Native Windows (limited support)
+- **`winget`** — Strips leading progress noise, ANSI spinner garbage, truncates long ARP paths, limits to 40 rows
+- **`tasklist`** — Limits to 40 processes, truncates long lines at 80 chars
+- **`systeminfo`** — Strips blank lines, limits to 30 key-value pairs
-On native Windows (cmd.exe / PowerShell), RTK filters work but the hook does not auto-rewrite commands:
+## Build & Test
```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)
-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 |
-|---------|-----|----------------|
-| Filters (cargo, git, etc.) | Full | Full |
-| Auto-rewrite hook | Yes | No (CLAUDE.md fallback) |
-| `rtk init -g` | Hook mode | CLAUDE.md 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.
-
-| Tool | Install | Method |
-|------|---------|--------|
-| **Claude Code** | `rtk init -g` | PreToolUse hook (bash) |
-| **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) |
-| **Gemini CLI** | `rtk init -g --gemini` | BeforeTool hook |
-| **Codex** | `rtk init -g --codex` | AGENTS.md + RTK.md instructions |
-| **Windsurf** | `rtk init -g --agent windsurf` | .windsurfrules (project-scoped) |
-| **Cline / Roo Code** | `rtk init --agent cline` | .clinerules (project-scoped) |
-| **OpenCode** | `rtk init -g --opencode` | Plugin TS (tool.execute.before) |
-| **OpenClaw** | `openclaw plugins install ./openclaw` | Plugin TS (before_tool_call) |
-| **Pi** | `rtk init -g --agent pi` (global) | TypeScript extension (tool_call) |
-| **Hermes** | `rtk init --agent hermes` | Python plugin adapter (terminal command mutation via `rtk rewrite`) |
-| **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) |
-
-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/`.
-
-## Configuration
-
-`~/.config/rtk/config.toml` (macOS: `~/Library/Application Support/rtk/config.toml`):
-
-```toml
-[hooks]
-exclude_commands = ["curl", "playwright"] # skip rewrite for these
-
-[tee]
-enabled = true # save raw output on failure (default: true)
-mode = "failures" # "failures", "always", or "never"
-```
-
-When a command fails, RTK saves the full unfiltered output so the LLM can read it without re-executing:
-
-```
-FAILED: 2/15 tests
-[full output: ~/.local/share/rtk/tee/1707753600_cargo_test.log]
+cargo build --release
+cargo fmt --all
+cargo clippy --all-targets
+cargo test # 2222+ pass, 0 fail
+.\scripts\benchmark.ps1 # Run Windows benchmark
```
-For the full config reference (all sections, env vars, per-project filters), see the [Configuration guide](https://www.rtk-ai.app/guide/getting-started/configuration).
+> **Note**: Full `cargo test` requires `[profile.test] debug = 0` in `Cargo.toml` to avoid OOM during debug test compilation on Windows.
-### Uninstall
+## Architecture
-```bash
-rtk init -g --uninstall # Remove hook, RTK.md, settings.json entry
-cargo uninstall rtk # Remove binary
-brew uninstall rtk # If installed via Homebrew
```
-
-## Documentation
-
-- **[rtk-ai.app/guide](https://www.rtk-ai.app/guide)** — full user guide (installation, supported agents, what gets optimized, analytics, configuration, troubleshooting)
-- **[INSTALL.md](INSTALL.md)** — detailed installation reference
-- **[ARCHITECTURE.md](docs/contributing/ARCHITECTURE.md)** — system design and technical decisions
-- **[CONTRIBUTING.md](CONTRIBUTING.md)** — contribution guide
-- **[SECURITY.md](SECURITY.md)** — security policy
-
-## Privacy & Telemetry
-
-RTK can collect **anonymous, aggregate usage metrics** once per day. Telemetry is **disabled by default** and requires **explicit opt-in consent** (GDPR Art. 6, 7) during `rtk init` or via `rtk telemetry enable`. This data helps us build a better product: identifying which commands need filters, which filters need improvement, and how much value RTK delivers. For the full list of fields, data handling, and contributor guidelines, see **[docs/TELEMETRY.md](docs/TELEMETRY.md)**.
-
-**What is collected and why:**
-
-| Category | Data | Why |
-|----------|------|-----|
-| 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 |
-| 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 |
-
-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.
-
-**What is NOT collected:** source code, file paths, command arguments, secrets, environment variables, personal data, or repository contents.
-
-**Manage telemetry:**
-```bash
-rtk telemetry status # Check current consent state
-rtk telemetry enable # Give consent (interactive prompt)
-rtk telemetry disable # Withdraw consent — stops all collection immediately
-rtk telemetry forget # Withdraw consent + delete all local data + request server-side erasure
-```
-
-**Override via environment:**
-```bash
-export RTK_TELEMETRY_DISABLED=1 # Blocks telemetry regardless of consent
+CLI request → main.rs (Clap dispatch)
+ ├── Rust-native handler (ls, tree, wc, find, git, cargo, ...)
+ └── run_fallback()
+ └── TOML filter match
+ └── execute real command + apply filter pipeline
+ (strip_ansi → replace → strip_lines →
+ truncate → head/tail → max_lines → on_empty)
```
-## Star History
-
-
-
-
-
-
-
-
-
-## StarMapper
+For full details, see [ARCHITECTURE.md](docs/contributing/ARCHITECTURE.md) and [CONTRIBUTING.md](CONTRIBUTING.md) (upstream docs, mostly applicable).
-
-
-
-
-
-
-
+## Differences from Upstream v0.43.0
-## Core team
-
-- **Patrick Szymkowiak** — Founder
- [GitHub](https://github.com/pszymkowiak) · [LinkedIn](https://www.linkedin.com/in/patrick-szymkowiak/)
-- **Florian Bruniaux** — Core contributor
- [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/)
-
-## Contributing
-
-Contributions welcome! Please open an issue or PR on [GitHub](https://github.com/rtk-ai/rtk).
-
-Join the community on [Discord](https://discord.gg/RySmvNF5kF).
+- Removed all `#[cfg(unix)]` blocks across 6 files: `main.rs`, `core/utils.rs`, `core/stream.rs`, `core/telemetry.rs`, `hooks/integrity.rs`, `hooks/init.rs`
+- Removed `libc` dependency from `Cargo.toml`
+- Added `/STACK:8388608` linker flag in `build.rs` for Windows stack size
+- Replaced `ls.rs`, `tree.rs`, `wc_cmd.rs` with Rust-native implementations
+- Added `tool_exists` check in `search.rs` with Windows-friendly error message
+- Gemini hook changed from `.sh` to `.ps1` extension
+- Added 81 built-in TOML filters (41 upstream + 22 upstream additions + 15 PowerShell + winget + systeminfo + tasklist)
+- Added `install.ps1` for Windows-native installation
+- Added `scripts/benchmark.ps1` for Windows benchmarking
+- Added `[profile.test] debug = 0` to prevent OOM during test compilation
## License
-Apache License 2.0 - see [LICENSE](LICENSE) for details.
-
-## Disclaimer
+Apache License 2.0 — see [LICENSE](LICENSE).
-See [DISCLAIMER.md](DISCLAIMER.md).
+Forked from [rtk-ai/rtk](https://github.com/rtk-ai/rtk) v0.43.0.
diff --git a/hooks/cursor/rtk-rewrite.sh b/hooks/cursor/rtk-rewrite.sh
index 44028502d9..4b80b260cd 100644
--- a/hooks/cursor/rtk-rewrite.sh
+++ b/hooks/cursor/rtk-rewrite.sh
@@ -39,28 +39,16 @@ if [ -z "$CMD" ]; then
fi
# Delegate all rewrite logic to the Rust binary.
-# Exit codes: 0 = allow rewrite, 1 = no rewrite (passthrough),
-# 2 = deny, 3 = ask.
-REWRITTEN=$(rtk rewrite "$CMD" 2>/dev/null)
-RC=$?
-if [ "$RC" -eq 1 ] || [ "$RC" -eq 2 ]; then
- echo '{}'
- exit 0
-fi
+# rtk rewrite exits 1 when there's no rewrite — hook passes through silently.
+REWRITTEN=$(rtk rewrite "$CMD" 2>/dev/null) || { echo '{}'; exit 0; }
# No change — nothing to do.
-if [ -z "$REWRITTEN" ] || [ "$CMD" = "$REWRITTEN" ]; then
+if [ "$CMD" = "$REWRITTEN" ]; then
echo '{}'
exit 0
fi
-# 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" '{
- "permission": $perm,
+jq -n --arg cmd "$REWRITTEN" '{
+ "permission": "allow",
"updated_input": { "command": $cmd }
}'
diff --git a/install.ps1 b/install.ps1
new file mode 100644
index 0000000000..49bb1f2c83
--- /dev/null
+++ b/install.ps1
@@ -0,0 +1,82 @@
+# rtk-win installer - https://github.com/rtk-ai/rtk
+# Usage: powershell -c "irm https://raw.githubusercontent.com/rtk-ai/rtk/master/install.ps1 | iex"
+
+param(
+ [string]$InstallDir = "$env:USERPROFILE\.cargo\bin",
+ [switch]$SkipBuild
+)
+
+$ErrorActionPreference = "Stop"
+$BinaryName = "rtk.exe"
+
+function Write-Info($msg) { Write-Host "[INFO] $msg" -ForegroundColor Green }
+function Write-Warn($msg) { Write-Host "[WARN] $msg" -ForegroundColor Yellow }
+function Write-Error($msg) { Write-Host "[ERROR] $msg" -ForegroundColor Red; exit 1 }
+
+# Check for cargo
+if (-not (Get-Command "cargo" -ErrorAction SilentlyContinue)) {
+ Write-Error "cargo not found. Install Rust from https://rustup.rs"
+}
+
+Write-Info "Installing rtk to: $InstallDir"
+
+if (-not $SkipBuild) {
+ $repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
+ if (-not $repoRoot) { $repoRoot = Get-Location }
+
+ $binaryPath = Join-Path $repoRoot "target\release\$BinaryName"
+ $needsBuild = $true
+
+ if (Test-Path $binaryPath) {
+ $binaryTime = (Get-Item $binaryPath).LastWriteTime
+ $sourceTime = (Get-Item (Join-Path $repoRoot "Cargo.toml")).LastWriteTime
+ $lockTime = (Get-Item (Join-Path $repoRoot "Cargo.lock")).LastWriteTime
+ if ($binaryTime -gt $sourceTime -and $binaryTime -gt $lockTime) {
+ $needsBuild = $false
+ }
+ }
+
+ if ($needsBuild) {
+ Write-Info "Building rtk (release)..."
+ Push-Location $repoRoot
+ try {
+ cargo build --release
+ if ($LASTEXITCODE -ne 0) { Write-Error "Build failed" }
+ } finally {
+ Pop-Location
+ }
+ } else {
+ Write-Info "Binary is up to date"
+ }
+}
+
+# Ensure install directory exists
+New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
+
+# Copy binary
+$source = if (-not $SkipBuild) {
+ Join-Path $repoRoot "target\release\$BinaryName"
+} else {
+ # When SkipBuild is set, expect binary in current directory or download it
+ ".\$BinaryName"
+}
+
+if (-not (Test-Path $source)) {
+ Write-Error "Binary not found at: $source"
+}
+
+Copy-Item -Path $source -Destination (Join-Path $InstallDir $BinaryName) -Force
+Write-Info "Installed: $InstallDir\$BinaryName"
+
+# Add to PATH if not already present
+$userPath = [Environment]::GetEnvironmentVariable("PATH", "User")
+if ($userPath -notlike "*$InstallDir*") {
+ $newPath = "$InstallDir;$userPath"
+ [Environment]::SetEnvironmentVariable("PATH", $newPath, "User")
+ Write-Info "Added $InstallDir to user PATH"
+ Write-Warn "Restart your terminal for PATH changes to take effect"
+} else {
+ Write-Info "$InstallDir is already in PATH"
+}
+
+Write-Info "Installation complete! Run 'rtk --help' to get started."
diff --git a/scripts/benchmark.ps1 b/scripts/benchmark.ps1
new file mode 100644
index 0000000000..89bb4ace48
--- /dev/null
+++ b/scripts/benchmark.ps1
@@ -0,0 +1,161 @@
+# rtk-win Real-World Benchmark (Windows/PowerShell)
+# Measures token savings on large-output commands
+# Usage: .\scripts\benchmark.ps1 [-ProjectPath ]
+
+param([string]$ProjectPath = (Get-Location).Path)
+
+$ErrorActionPreference = "Stop"
+$results = @()
+
+function Write-Step($msg) { Write-Host "[*] $msg" -ForegroundColor Cyan }
+
+function Measure-Savings($name, [scriptblock]$rawBlock, [string[]]$rtkArgs) {
+ # warmup
+ & $rawBlock 2>$null | Out-Null
+ & rtk @rtkArgs 2>$null | Out-Null
+
+ $raw = & $rawBlock 2>&1 | Out-String
+ $filtered = & rtk @rtkArgs 2>&1 | Out-String
+
+ $rawLen = $raw.Length
+ $filteredLen = $filtered.Length
+ $savings = if ($rawLen -gt 0) { [math]::Round(($rawLen - $filteredLen) / $rawLen * 100, 1) } else { 0 }
+
+ $script:results += [PSCustomObject]@{Name=$name; Raw=$rawLen; Rtk=$filteredLen; Savings=$savings}
+
+ $color = if ($savings -ge 70) {"Green"} elseif ($savings -ge 30) {"Yellow"} else {"Red"}
+ $savingsStr = if ($savings -ge 0) { " $savings%" } else { " x" }
+ Write-Host " $("$name".PadRight(24)) $($rawLen.ToString('N0').PadLeft(8)) -> $($filteredLen.ToString('N0').PadLeft(8)) $savingsStr" -ForegroundColor $color
+}
+
+function Try-Measure($name, [scriptblock]$rawBlock, [string[]]$rtkArgs) {
+ try { Measure-Savings $name $rawBlock $rtkArgs }
+ catch { Write-Host " $("$name".PadRight(24)) [SKIP] $_" -ForegroundColor DarkGray }
+}
+
+Write-Host "========================================================" -ForegroundColor Cyan
+Write-Host " rtk-win Real-World Benchmark (Windows)" -ForegroundColor Cyan
+Write-Host " Project: $ProjectPath" -ForegroundColor Cyan
+Write-Host " $(Get-Date -Format 'yyyy-MM-dd HH:mm')" -ForegroundColor Cyan
+Write-Host "========================================================" -ForegroundColor Cyan
+Write-Host ""
+
+# ── 1. ls: native RTK vs raw dir ──
+Write-Step "1. ls — Rust-native vs PowerShell Get-ChildItem"
+
+Try-Measure "ls (project src)" `
+ { Get-ChildItem $ProjectPath\src -Recurse -File | Select-Object Name, Length, LastWriteTime | Out-String } `
+ @('ls', $ProjectPath + '\src')
+
+Try-Measure "ls (drivers large)" `
+ { Get-ChildItem C:\Windows\System32\drivers -Recurse -File | Select-Object Name, Length | Out-String } `
+ @('ls', 'C:\Windows\System32\drivers')
+
+Try-Measure "ls (drivers wc)" `
+ { Get-ChildItem C:\Windows\System32\drivers\etc\* | Select-Object Name | Out-String } `
+ @('ls', 'C:\Windows\System32\drivers\etc')
+
+Write-Host ""
+
+# ── 2. wc: files count ──
+Write-Step "2. wc — Rust-native vs PowerShell"
+
+Try-Measure "wc (src .rs files)" `
+ { Get-ChildItem $ProjectPath\src\*.rs -Recurse | Select-Object Length | Out-String } `
+ @('wc', $ProjectPath + '\src\*.rs')
+
+Try-Measure "wc (drivers folder)" `
+ { Get-ChildItem C:\Windows\System32\drivers\etc\* | Select-Object Length | Out-String } `
+ @('wc', 'C:\Windows\System32\drivers\etc\*')
+
+Write-Host ""
+
+# ── 3. find: search ──
+Write-Step "3. find — Rust-native vs PowerShell"
+
+Try-Measure "find (src .rs)" `
+ { Get-ChildItem $ProjectPath -Recurse -Filter *.rs -Name | Out-String } `
+ @('find', $ProjectPath, '-name', '*.rs')
+
+Try-Measure "find (src .toml)" `
+ { Get-ChildItem $ProjectPath -Recurse -Filter *.toml -Name | Out-String } `
+ @('find', $ProjectPath, '-name', '*.toml')
+
+Write-Host ""
+
+# ── 4. tree ──
+Write-Step "4. tree — Rust-native vs cmd tree /F"
+
+Try-Measure "tree (src dirs)" `
+ { cmd /c "tree $ProjectPath\src /F 2>nul" | Out-String } `
+ @('tree', $ProjectPath + '\src')
+
+Write-Host ""
+
+# ── 5. Git operations ──
+if (Get-Command git -ErrorAction SilentlyContinue) {
+ Write-Step "5. Git — raw vs RTK-filtered"
+
+ Try-Measure "git status" `
+ { git -C $ProjectPath status 2>&1 | Out-String } `
+ @('git', '-C', $ProjectPath, 'status')
+
+ Try-Measure "git log (all)" `
+ { git -C $ProjectPath log --oneline --all 2>&1 | Out-String } `
+ @('git', '-C', $ProjectPath, 'log', '--oneline', '--all')
+
+ Try-Measure "git diff (HEAD~1)" `
+ { git -C $ProjectPath diff HEAD~1..HEAD --stat 2>&1 | Out-String } `
+ @('git', '-C', $ProjectPath, 'diff', 'HEAD~1..HEAD', '--stat')
+ Write-Host ""
+}
+
+# ── 6. Package managers ──
+Write-Step "6. Package managers — raw vs RTK-filtered"
+
+if (Get-Command winget -ErrorAction SilentlyContinue) {
+ Try-Measure "winget list" `
+ { winget list --accept-source-agreements 2>$null | Out-String } `
+ @('winget', 'list')
+}
+
+if (Get-Command cargo -ErrorAction SilentlyContinue) {
+ Try-Measure "cargo tree" `
+ { cargo tree --manifest-path "$ProjectPath\Cargo.toml" 2>&1 | Out-String } `
+ @('cargo', 'tree', '--manifest-path', "$ProjectPath\Cargo.toml")
+}
+Write-Host ""
+
+# ── 7. Passthrough (no filter) ──
+Write-Step "7. Passthrough — raw vs RTK (TOML-filtered)"
+
+Try-Measure "systeminfo" `
+ { systeminfo 2>$null | Out-String } `
+ @('systeminfo')
+
+Try-Measure "env vars" `
+ { Get-ChildItem Env: | Out-String } `
+ @('cmd', '/c', 'set')
+
+Write-Host ""
+
+# ── Summary ──
+Write-Host "========================================================" -ForegroundColor Cyan
+Write-Host " RESULTS" -ForegroundColor Cyan
+Write-Host "========================================================" -ForegroundColor Cyan
+
+$totalRaw = ($results | Measure-Object Raw -Sum).Sum
+$totalRtk = ($results | Measure-Object Rtk -Sum).Sum
+$overall = if ($totalRaw -gt 0) { [math]::Round(($totalRaw - $totalRtk) / $totalRaw * 100, 1) } else { 0 }
+
+$results | Sort-Object Savings -Descending | Format-Table @{L='Command';E={$_.Name};A='Left';W=24},
+ @{L='Raw';E={$_.Raw.ToString('N0')};A='Right';W=10},
+ @{L='RTK';E={$_.Rtk.ToString('N0')};A='Right';W=10},
+ @{L='Saved';E={if ($_.Savings -ge 0) {"$($_.Savings)%"} else {"(overhead)"}};A='Right';W=14} -AutoSize
+
+Write-Host ""
+Write-Host " OVERALL: $($totalRaw.ToString('N0')) chars -> $($totalRtk.ToString('N0')) chars | $overall% saved" -ForegroundColor White
+Write-Host ""
+Write-Host " Key: Green >=70%, Yellow 30-69%, Red <30% or overhead" -ForegroundColor Gray
+Write-Host " (overhead) means RTK adds more chars than it removes" -ForegroundColor Gray
+Write-Host ""
diff --git a/src/cmds/system/ls.rs b/src/cmds/system/ls.rs
index 98eb364479..df55c2d12b 100644
--- a/src/cmds/system/ls.rs
+++ b/src/cmds/system/ls.rs
@@ -1,32 +1,31 @@
-//! Filters directory listings into a compact tree format.
-
use super::constants::NOISE_DIRS;
-use crate::core::runner::{self, RunOptions};
+use crate::core::guard::never_worse;
+use crate::core::tracking;
use crate::core::truncate::{reduced, CAP_WARNINGS};
-use crate::core::utils::resolved_command;
-use anyhow::Result;
-use lazy_static::lazy_static;
-use regex::Regex;
+use anyhow::{Context, Result};
+use std::collections::HashMap;
+use std::fs;
use std::io::IsTerminal;
-lazy_static! {
- /// Matches the date+time portion in `ls -la` output, which serves as a
- /// stable anchor regardless of owner/group column width.
- /// E.g.: " Mar 31 16:18 " or " Dec 25 2024 "
- static ref LS_DATE_RE: Regex = Regex::new(
- r"\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2}\s+(?:\d{4}|\d{2}:\d{2})\s+"
- )
- .unwrap();
+const DEFAULT_MAX_LINES: usize = 80;
+
+fn truncate_lines(s: &str, max: usize) -> String {
+ let total: Vec<&str> = s.lines().collect();
+ if total.len() <= max {
+ return s.to_string();
+ }
+ let omit = total.len() - max;
+ let mut out: String = total[..max].join("\n");
+ out.push_str(&format!("\n... ({} lines truncated)", omit));
+ out
}
pub fn run(args: &[String], verbose: u8) -> Result {
+ let timer = tracking::TimedExecution::start();
+
let show_all = args
.iter()
.any(|a| (a.starts_with('-') && !a.starts_with("--") && a.contains('a')) || a == "--all");
-
- // Per `man ls`, the long listing is triggered by `-l` and also implied by
- // `-g`, `-n`, `-o`, `--full-time` or GNU `--format=long` and `--format=verbose`.
- // In any of those cases we preserve permission info as octal.
let show_long = args.iter().any(|a| {
if a == "--full-time" || a == "--format=long" || a == "--format=verbose" {
return true;
@@ -37,96 +36,53 @@ pub fn run(args: &[String], verbose: u8) -> Result {
false
});
- let flags: Vec<&str> = args
- .iter()
- .filter(|a| a.starts_with('-'))
- .map(|s| s.as_str())
- .collect();
let paths: Vec<&str> = args
.iter()
.filter(|a| !a.starts_with('-'))
.map(|s| s.as_str())
.collect();
+ let target = if paths.is_empty() { "." } else { paths[0] };
- let mut cmd = resolved_command("ls");
- cmd.env("LC_ALL", "C");
- cmd.arg("-la");
- for flag in &flags {
- if flag.starts_with("--") {
- if *flag != "--all" {
- cmd.arg(flag);
- }
- } else {
- let stripped = flag.trim_start_matches('-');
- let extra: String = stripped
- .chars()
- .filter(|c| *c != 'l' && *c != 'a' && *c != 'h')
- .collect();
- if !extra.is_empty() {
- cmd.arg(format!("-{}", extra));
- }
- }
+ if verbose > 0 {
+ eprintln!("Listing: {} (filter: {})", target, if show_long { "long" } else { "short" });
}
- if paths.is_empty() {
- cmd.arg(".");
- } else {
- for p in &paths {
- cmd.arg(p);
- }
- }
+ let (entries, summary) = compact_dir(target, show_all, show_long)
+ .with_context(|| format!("Failed to list directory: {}", target))?;
- let target_display = if paths.is_empty() {
- ".".to_string()
+ let is_tty = std::io::stdout().is_terminal();
+ let output = if is_tty {
+ format!("{}{}", entries, summary)
} else {
- paths.join(" ")
+ entries
};
- runner::run_filtered(
- cmd,
- "ls",
- &format!("-la {}", target_display),
- |raw| {
- let (entries, summary, parsed_count) = compact_ls(raw, show_all, show_long);
-
- // If no lines were parsed (e.g., unrecognized locale), fall back to raw output.
- // This is safer than returning "(empty)" for a non-empty directory.
- let has_real_content = raw
- .lines()
- .any(|l| !l.starts_with("total ") && !l.is_empty() && !is_dotdir(l));
- if parsed_count == 0 && has_real_content {
- return raw.to_string();
- }
-
- // Only show summary in interactive mode (not when piped)
- let is_tty = std::io::stdout().is_terminal();
- let filtered = if is_tty {
- format!("{}{}", entries, summary)
+ let trunk = truncate_lines(&output, DEFAULT_MAX_LINES);
+ let shown = never_worse(&output, &trunk);
+ print!("{}", shown);
+
+ if verbose > 0 {
+ eprintln!(
+ "Chars: {} → {} ({}% reduction)",
+ output.len(),
+ shown.len(),
+ if !output.is_empty() {
+ 100 - (shown.len() * 100 / output.len())
} else {
- entries
- };
-
- if verbose > 0 {
- eprintln!(
- "Chars: {} → {} ({}% reduction)",
- raw.len(),
- filtered.len(),
- if !raw.is_empty() {
- 100 - (filtered.len() * 100 / raw.len())
- } else {
- 0
- }
- );
+ 0
}
- filtered
- },
- RunOptions::stdout_only()
- .early_exit_on_failure()
- .no_trailing_newline(),
- )
+ );
+ }
+
+ timer.track(
+ &format!("ls {}", target),
+ "rtk ls",
+ &output,
+ shown,
+ );
+ Ok(0)
}
-/// Format bytes into human-readable size
fn human_size(bytes: u64) -> String {
if bytes >= 1_048_576 {
format!("{:.1}M", bytes as f64 / 1_048_576.0)
@@ -137,173 +93,76 @@ fn human_size(bytes: u64) -> String {
}
}
-/// Parse a single `ls -la` line, returning `(file_type_char, perms, size, name)`.
-///
-/// `perms` is the raw 10-char string from ls (e.g. `-rw-r--r--`); use
-/// [`perms_to_octal`] to render it.
-///
-/// Uses the date field as a stable anchor — the date format in `ls -la` is
-/// always three tokens (`Mon DD HH:MM` or `Mon DD YYYY`), so we locate it
-/// with a regex, then extract size (rightmost number before the date) and
-/// filename (everything after the date). This handles owner/group names that
-/// contain spaces, which break the old fixed-column approach.
-fn parse_ls_line(line: &str) -> Option<(char, String, u64, String)> {
- // Skip . and .. entries before date parsing (works for non-English locales too)
- if is_dotdir(line) {
- return None;
- }
-
- let date_match = LS_DATE_RE.find(line)?;
- let name = line[date_match.end()..].to_string();
-
- let before_date = &line[..date_match.start()];
- let before_parts: Vec<&str> = before_date.split_whitespace().collect();
- if before_parts.len() < 4 {
- return None;
- }
-
- let perms = before_parts[0].to_string();
- let file_type = perms.chars().next()?;
-
- // Size is the rightmost parseable number before the date.
- // nlinks is also numeric but appears earlier; scanning from the end
- // guarantees we hit the size field first.
- let mut size: u64 = 0;
- for part in before_parts.iter().rev() {
- if let Ok(s) = part.parse::() {
- size = s;
- break;
- }
+fn get_file_type_char(metadata: &fs::Metadata) -> char {
+ if metadata.is_dir() {
+ 'd'
+ } else if metadata.file_type().is_symlink() {
+ 'l'
+ } else {
+ '-'
}
-
- Some((file_type, perms, size, name))
-}
-
-/// Returns true if the line represents a . or .. directory entry.
-///
-/// POSIX.1-2017 (IEEE Std 1003.1) specifies that each directory contains
-/// entries for "." (the directory itself) and ".." (its parent). These entries
-/// always appear in `ls -la` output and are skipped during parsing since they
-/// carry no meaningful content for token reduction.
-fn is_dotdir(line: &str) -> bool {
- line.trim().ends_with('.') || line.trim().ends_with("..")
}
-/// Convert an `ls`-style permission string (e.g. `-rw-r--r--`, `drwxr-xr-x`,
-/// `-rwsr-xr-t`) into octal notation (e.g. `644`, `755`, `4755`).
-///
-/// Returns `None` if the input does not look like a permission field.
-/// Special bits (setuid/setgid/sticky) are encoded as a leading 4th digit when
-/// any are set; otherwise we emit a 3-digit value to stay compact.
-fn perms_to_octal(perms: &str) -> Option {
- if perms.len() < 10 || !perms.is_ascii() {
- return None;
- }
- let b = perms.as_bytes();
-
- fn perm_value(read: bool, write: bool, exec: bool) -> u32 {
- ((read as u32) << 2) | ((write as u32) << 1) | (exec as u32)
- }
-
- let owner_x = matches!(b[3], b'x' | b's');
- let group_x = matches!(b[6], b'x' | b's');
- let other_x = matches!(b[9], b'x' | b't');
-
- let owner = perm_value(b[1] == b'r', b[2] == b'w', owner_x);
- let group = perm_value(b[4] == b'r', b[5] == b'w', group_x);
- let other = perm_value(b[7] == b'r', b[8] == b'w', other_x);
-
- let setuid = matches!(b[3], b's' | b'S');
- let setgid = matches!(b[6], b's' | b'S');
- let sticky = matches!(b[9], b't' | b'T');
- let special = perm_value(setuid, setgid, sticky);
-
- if special > 0 {
- Some(format!("{}{}{}{}", special, owner, group, other))
+fn get_perms_octal(metadata: &fs::Metadata) -> Option {
+ let perms = metadata.permissions();
+ if metadata.is_dir() {
+ Some("755".to_string())
+ } else if perms.readonly() {
+ Some("444".to_string())
} else {
- Some(format!("{}{}{}", owner, group, other))
+ Some("644".to_string())
}
}
-/// Parse ls -la output into compact format.
-///
-/// Without `show_long`:
-/// name/ (dirs)
-/// name size (files)
-///
-/// With `show_long` (user passed `-l`):
-/// 755 name/ (dirs)
-/// 644 name size (files)
-///
-/// Returns (entries, summary, parsed_count) so caller can suppress summary when piped.
-/// parsed_count tracks how many non-header lines were successfully parsed.
-/// If parsed_count == 0 but raw had content, caller should fall back to raw output.
-fn compact_ls(raw: &str, show_all: bool, show_long: bool) -> (String, String, usize) {
- use std::collections::HashMap;
-
- let mut dirs: Vec<(String, Option)> = Vec::new(); // (name, octal_perms)
- let mut files: Vec<(String, String, Option)> = Vec::new(); // (name, size, octal_perms)
+fn compact_dir(target: &str, show_all: bool, show_long: bool) -> Result<(String, String)> {
+ let mut dirs: Vec<(String, Option)> = Vec::new();
+ let mut files: Vec<(String, String, Option)> = Vec::new();
let mut by_ext: HashMap = HashMap::new();
- let mut lines_seen: usize = 0;
- let mut parsed_count: usize = 0;
- let mut dotdirs: usize = 0;
- for line in raw.lines() {
- if line.starts_with("total ") || line.is_empty() {
- continue;
- }
- lines_seen += 1;
+ let read_dir = fs::read_dir(target)
+ .with_context(|| format!("Failed to read directory: {}", target))?;
- let Some((file_type, perms, size, name)) = parse_ls_line(line) else {
- if is_dotdir(line) {
- dotdirs += 1;
- }
+ for entry in read_dir {
+ let entry = entry?;
+ let name = entry.file_name().to_string_lossy().to_string();
+
+ if !show_all && name.starts_with('.') {
continue;
- };
- parsed_count += 1;
+ }
- // Filter noise dirs unless -a
if !show_all && NOISE_DIRS.iter().any(|noise| name == *noise) {
continue;
}
- // Only parse perms when the user actually wants the long listing —
- // skip the work otherwise.
- let octal = if show_long {
- perms_to_octal(&perms)
- } else {
- None
- };
+ let metadata = fs::symlink_metadata(entry.path())
+ .with_context(|| format!("Failed to get metadata for: {}", name))?;
- if file_type == 'd' {
+ let ft = get_file_type_char(&metadata);
+ let octal = if show_long { get_perms_octal(&metadata) } else { None };
+
+ if ft == 'd' {
dirs.push((name, octal));
} else {
- // Regular files, symlinks, character/block devices, pipes, sockets
let ext = if let Some(pos) = name.rfind('.') {
name[pos..].to_string()
} else {
"no ext".to_string()
};
*by_ext.entry(ext).or_insert(0) += 1;
+ let size = metadata.len();
files.push((name, human_size(size), octal));
}
}
+ dirs.sort_by(|a, b| a.0.cmp(&b.0));
+ files.sort_by(|a, b| a.0.cmp(&b.0));
+
if dirs.is_empty() && files.is_empty() {
- if lines_seen > 0 && parsed_count == 0 {
- if dotdirs == lines_seen {
- // Only . and .. entries (empty directory)
- return ("(empty)\n".to_string(), String::new(), 0);
- }
- // Real content that couldn't be parsed (e.g., non-English locale)
- return (String::new(), String::new(), 0);
- }
- return ("(empty)\n".to_string(), String::new(), 0);
+ return Ok(("(empty)\n".to_string(), String::new()));
}
let mut entries = String::new();
- // Dirs first, compact
for (name, octal) in &dirs {
if let Some(octal) = octal {
entries.push_str(octal);
@@ -313,7 +172,6 @@ fn compact_ls(raw: &str, show_all: bool, show_long: bool) -> (String, String, us
entries.push_str("/\n");
}
- // Files with size
for (name, size, octal) in &files {
if let Some(octal) = octal {
entries.push_str(octal);
@@ -325,10 +183,8 @@ fn compact_ls(raw: &str, show_all: bool, show_long: bool) -> (String, String, us
entries.push('\n');
}
- // Summary line (separate so caller can suppress when piped)
let mut summary = format!("\nSummary: {} files, {} dirs", files.len(), dirs.len());
if !by_ext.is_empty() {
- // inline single-line summary — fewer entries to avoid wrapping.
const MAX_EXT_SUMMARY: usize = reduced(CAP_WARNINGS, 5);
let mut ext_counts: Vec<_> = by_ext.iter().collect();
ext_counts.sort_by(|a, b| b.1.cmp(a.1));
@@ -346,43 +202,39 @@ fn compact_ls(raw: &str, show_all: bool, show_long: bool) -> (String, String, us
}
summary.push('\n');
- (entries, summary, parsed_count)
+ Ok((entries, summary))
}
#[cfg(test)]
mod tests {
use super::*;
+ use tempfile::TempDir;
#[test]
fn test_compact_basic() {
- let input = "total 48\n\
- drwxr-xr-x 2 user staff 64 Jan 1 12:00 .\n\
- drwxr-xr-x 2 user staff 64 Jan 1 12:00 ..\n\
- drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n\
- -rw-r--r-- 1 user staff 1234 Jan 1 12:00 Cargo.toml\n\
- -rw-r--r-- 1 user staff 5678 Jan 1 12:00 README.md\n";
- let (entries, _summary, _parsed) = compact_ls(input, false, false);
+ let temp = TempDir::new().unwrap();
+ fs::create_dir(temp.path().join("src")).unwrap();
+ fs::write(temp.path().join("Cargo.toml"), "a".repeat(1234)).unwrap();
+ fs::write(temp.path().join("README.md"), "b".repeat(5678)).unwrap();
+
+ let (entries, _summary) = compact_dir(temp.path().to_str().unwrap(), false, false).unwrap();
assert!(entries.contains("src/"));
assert!(entries.contains("Cargo.toml"));
assert!(entries.contains("README.md"));
- assert!(entries.contains("1.2K")); // 1234 bytes
- assert!(entries.contains("5.5K")); // 5678 bytes
- assert!(!entries.contains("drwx")); // no permissions
- assert!(!entries.contains("staff")); // no group
- assert!(!entries.contains("total")); // no total
- assert!(!entries.contains("\n.\n")); // no . entry
- assert!(!entries.contains("\n..\n")); // no .. entry
+ assert!(entries.contains("1.2K"));
+ assert!(entries.contains("5.5K"));
}
#[test]
fn test_compact_filters_noise() {
- let input = "total 8\n\
- drwxr-xr-x 2 user staff 64 Jan 1 12:00 node_modules\n\
- drwxr-xr-x 2 user staff 64 Jan 1 12:00 .git\n\
- drwxr-xr-x 2 user staff 64 Jan 1 12:00 target\n\
- drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n\
- -rw-r--r-- 1 user staff 100 Jan 1 12:00 main.rs\n";
- let (entries, _summary, _parsed) = compact_ls(input, false, false);
+ let temp = TempDir::new().unwrap();
+ fs::create_dir(temp.path().join("node_modules")).unwrap();
+ fs::create_dir(temp.path().join(".git")).unwrap();
+ fs::create_dir(temp.path().join("target")).unwrap();
+ fs::create_dir(temp.path().join("src")).unwrap();
+ fs::write(temp.path().join("main.rs"), "fn main() {}").unwrap();
+
+ let (entries, _summary) = compact_dir(temp.path().to_str().unwrap(), false, false).unwrap();
assert!(!entries.contains("node_modules"));
assert!(!entries.contains(".git"));
assert!(!entries.contains("target"));
@@ -392,55 +244,22 @@ mod tests {
#[test]
fn test_compact_show_all() {
- let input = "total 8\n\
- drwxr-xr-x 2 user staff 64 Jan 1 12:00 .git\n\
- drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n";
- let (entries, _summary, _parsed) = compact_ls(input, true, false);
+ let temp = TempDir::new().unwrap();
+ fs::create_dir(temp.path().join(".git")).unwrap();
+ fs::create_dir(temp.path().join("src")).unwrap();
+ fs::write(temp.path().join(".hidden"), "secret").unwrap();
+
+ let (entries, _summary) = compact_dir(temp.path().to_str().unwrap(), true, false).unwrap();
assert!(entries.contains(".git/"));
assert!(entries.contains("src/"));
+ assert!(entries.contains(".hidden"));
}
#[test]
fn test_compact_empty() {
- let input = "total 0\n";
- let (entries, summary, _parsed) = compact_ls(input, false, false);
+ let temp = TempDir::new().unwrap();
+ let (entries, _summary) = compact_dir(temp.path().to_str().unwrap(), false, false).unwrap();
assert_eq!(entries, "(empty)\n");
- assert!(summary.is_empty());
- }
-
- #[test]
- fn test_compact_empty_chinese_locale() {
- let input = "total 8\n\
- drwxr-xr-x 2 user user 4096 1月 1 12:00 .\n\
- drwxr-xr-x 16 user user 20480 1月 1 12:00 ..\n";
- let (entries, summary, parsed_count) = compact_ls(input, false, false);
- assert_eq!(parsed_count, 0);
- assert_eq!(entries, "(empty)\n");
- assert!(summary.is_empty());
- }
-
- #[test]
- fn test_compact_empty_english_locale() {
- let input = "total 0\n\
- drwxr-xr-x 2 lumin wheel 64 Apr 23 00:37 .\n\
- drwxr-xr-x 16 root wheel 164576 Apr 23 00:37 ..\n";
- let (entries, summary, parsed_count) = compact_ls(input, false, false);
- assert_eq!(parsed_count, 0);
- assert_eq!(entries, "(empty)\n");
- assert!(summary.is_empty());
- }
-
- #[test]
- fn test_compact_summary() {
- let input = "total 48\n\
- drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n\
- -rw-r--r-- 1 user staff 1234 Jan 1 12:00 main.rs\n\
- -rw-r--r-- 1 user staff 5678 Jan 1 12:00 lib.rs\n\
- -rw-r--r-- 1 user staff 100 Jan 1 12:00 Cargo.toml\n";
- let (_entries, summary, _parsed) = compact_ls(input, false, false);
- assert!(summary.contains("Summary: 3 files, 1 dirs"));
- assert!(summary.contains(".rs"));
- assert!(summary.contains(".toml"));
}
#[test]
@@ -454,262 +273,38 @@ mod tests {
}
#[test]
- fn test_compact_handles_filenames_with_spaces() {
- let input = "total 8\n\
- -rw-r--r-- 1 user staff 1234 Jan 1 12:00 my file.txt\n";
- let (entries, _summary, _parsed) = compact_ls(input, false, false);
- assert!(entries.contains("my file.txt"));
- }
-
- #[test]
- fn test_compact_symlinks() {
- let input = "total 8\n\
- lrwxr-xr-x 1 user staff 10 Jan 1 12:00 link -> target\n";
- let (entries, _summary, _parsed) = compact_ls(input, false, false);
- assert!(entries.contains("link -> target"));
- }
-
- #[test]
- fn test_entries_no_summary() {
- // Entries should never contain the summary line
- let input = "total 48\n\
- drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n\
- -rw-r--r-- 1 user staff 1234 Jan 1 12:00 main.rs\n";
- let (entries, summary, _parsed) = compact_ls(input, false, false);
- assert!(
- !entries.contains("Summary:"),
- "entries must not contain summary"
- );
- assert!(
- summary.contains("Summary:"),
- "summary must contain the icon"
- );
- }
-
- #[test]
- fn test_pipe_line_count() {
- // Simulates: rtk ls | wc -l
- // Entries should have exactly 1 line per file/dir, no extra blank or summary
- let input = "total 48\n\
- drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n\
- -rw-r--r-- 1 user staff 1234 Jan 1 12:00 main.rs\n\
- -rw-r--r-- 1 user staff 5678 Jan 1 12:00 lib.rs\n";
- let (entries, _summary, _parsed) = compact_ls(input, false, false);
- let line_count = entries.lines().count();
- assert_eq!(
- line_count, 3,
- "pipe should see exactly 3 lines (1 dir + 2 files), got {}",
- line_count
- );
- }
-
- // Regression test for #948: owner/group with spaces breaks fixed-column parsing
- #[test]
- fn test_compact_multiline_group() {
- let input = "total 8\n\
- -rw-r--r-- 1 fjeanne utilisa. du domaine 0 Mar 31 16:18 empty.txt\n\
- -rw-r--r-- 1 fjeanne utilisa. du domaine 1234 Mar 31 16:18 data.json\n";
- let (entries, _summary, _parsed) = compact_ls(input, false, false);
- assert!(
- entries.contains("empty.txt"),
- "should contain 'empty.txt', got: {entries}"
- );
- assert!(
- entries.contains("data.json"),
- "should contain 'data.json', got: {entries}"
- );
- assert!(
- !entries.contains("16:18"),
- "time should not leak into filename, got: {entries}"
- );
- assert!(
- entries.contains("0B"),
- "empty.txt should show 0B, got: {entries}"
- );
- assert!(
- entries.contains("1.2K"),
- "data.json should show 1.2K (1234 bytes), got: {entries}"
- );
- }
-
- #[test]
- fn test_compact_year_format_date() {
- // Some systems show year instead of time for old files
- let input = "total 8\n\
- -rw-r--r-- 1 user staff 5678 Dec 25 2024 archive.tar\n";
- let (entries, _summary, _parsed) = compact_ls(input, false, false);
- assert!(
- entries.contains("archive.tar"),
- "should contain filename, got: {entries}"
- );
- assert!(entries.contains("5.5K"), "should show 5.5K, got: {entries}");
- }
-
- #[test]
- fn test_parse_ls_line_basic() {
- let (ft, perms, size, name) =
- parse_ls_line("-rw-r--r-- 1 user staff 1234 Jan 1 12:00 file.txt").unwrap();
- assert_eq!(ft, '-');
- assert_eq!(perms, "-rw-r--r--");
- assert_eq!(size, 1234);
- assert_eq!(name, "file.txt");
- }
-
- #[test]
- fn test_parse_ls_line_multiline_group() {
- let (ft, perms, size, name) =
- parse_ls_line("-rw-r--r-- 1 fjeanne utilisa. du domaine 0 Mar 31 16:18 empty.txt")
- .unwrap();
- assert_eq!(ft, '-');
- assert_eq!(perms, "-rw-r--r--");
- assert_eq!(size, 0);
- assert_eq!(name, "empty.txt");
- }
-
- #[test]
- fn test_parse_ls_line_dir_with_space_in_group() {
- let (ft, perms, size, name) =
- parse_ls_line("drwxr-xr-x 2 fjeanne utilisa. du domaine 64 Mar 31 16:18 my dir")
- .unwrap();
- assert_eq!(ft, 'd');
- assert_eq!(perms, "drwxr-xr-x");
- assert_eq!(size, 64);
- assert_eq!(name, "my dir");
- }
-
- #[test]
- fn test_parse_ls_line_symlink() {
- let (ft, perms, size, name) =
- parse_ls_line("lrwxr-xr-x 1 user staff 10 Jan 1 12:00 link -> target").unwrap();
- assert_eq!(ft, 'l');
- assert_eq!(perms, "lrwxr-xr-x");
- assert_eq!(size, 10);
- assert_eq!(name, "link -> target");
- }
-
- #[test]
- fn test_compact_device_files() {
- // Regression test for #844: `rtk ls /dev/ttyACM*` returned "(empty)"
- // because character devices (type 'c') were not handled by compact_ls.
- let input = "crw-rw---- 1 root dialout 166, 0 Apr 22 09:46 /dev/ttyACM0\n";
- let (entries, _summary, _parsed) = compact_ls(input, false, false);
- assert!(
- entries.contains("/dev/ttyACM0"),
- "should contain device file, got: {entries}"
- );
- assert!(!entries.contains("(empty)"), "should not be empty");
- }
-
- #[test]
- fn test_compact_device_files_macos_hex_size() {
- // macOS shows device major/minor as hex (e.g. 0x2000000)
- let input = "crw-rw-rw- 1 root wheel 0x2000000 Mar 31 19:25 /dev/tty\n";
- let (entries, _summary, _parsed) = compact_ls(input, false, false);
- assert!(
- entries.contains("/dev/tty"),
- "should contain device file, got: {entries}"
- );
- }
-
- #[test]
- fn test_compact_block_device() {
- let input = "brw-rw---- 1 root disk 8, 0 Apr 22 09:46 /dev/sda\n";
- let (entries, _summary, _parsed) = compact_ls(input, false, false);
- assert!(
- entries.contains("/dev/sda"),
- "should contain block device, got: {entries}"
- );
- }
-
- #[test]
- fn test_parse_ls_line_returns_none_for_total() {
- assert!(parse_ls_line("total 48").is_none());
- }
-
- #[test]
- fn test_parse_ls_line_year_format() {
- let (ft, perms, size, name) =
- parse_ls_line("-rw-r--r-- 1 user staff 5678 Dec 25 2024 old.tar.gz").unwrap();
- assert_eq!(ft, '-');
- assert_eq!(perms, "-rw-r--r--");
- assert_eq!(size, 5678);
- assert_eq!(name, "old.tar.gz");
- }
-
- #[test]
- fn test_perms_to_octal_common() {
- assert_eq!(perms_to_octal("-rw-r--r--").as_deref(), Some("644"));
- assert_eq!(perms_to_octal("-rwxr-xr-x").as_deref(), Some("755"));
- assert_eq!(perms_to_octal("drwxr-xr-x").as_deref(), Some("755"));
- assert_eq!(perms_to_octal("-rw-------").as_deref(), Some("600"));
- assert_eq!(perms_to_octal("-rwxrwxrwx").as_deref(), Some("777"));
- assert_eq!(perms_to_octal("----------").as_deref(), Some("000"));
- assert_eq!(perms_to_octal("lrwxr-xr-x").as_deref(), Some("755"));
- }
-
- #[test]
- fn test_perms_to_octal_special_bits() {
- // setuid + 755 -> 4755
- assert_eq!(perms_to_octal("-rwsr-xr-x").as_deref(), Some("4755"));
- // setuid without execute -> 4644
- assert_eq!(perms_to_octal("-rwSr--r--").as_deref(), Some("4644"));
- // setgid + 755 -> 2755
- assert_eq!(perms_to_octal("-rwxr-sr-x").as_deref(), Some("2755"));
- // sticky bit on /tmp-style dir -> 1777
- assert_eq!(perms_to_octal("drwxrwxrwt").as_deref(), Some("1777"));
- // setuid + setgid + sticky
- assert_eq!(perms_to_octal("-rwsrwsrwt").as_deref(), Some("7777"));
- }
+ fn test_compact_long_format_includes_octal() {
+ let temp = TempDir::new().unwrap();
+ fs::create_dir(temp.path().join("src")).unwrap();
+ fs::write(temp.path().join("Cargo.toml"), "[package]").unwrap();
- #[test]
- fn test_perms_to_octal_garbage() {
- assert_eq!(perms_to_octal(""), None);
- assert_eq!(perms_to_octal("short"), None);
+ let (entries, _summary) = compact_dir(temp.path().to_str().unwrap(), false, true).unwrap();
+ assert!(entries.contains("755 src/"));
+ assert!(entries.contains("644 Cargo.toml"));
}
#[test]
- fn test_compact_long_format_includes_octal() {
- let input = "total 48\n\
- drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n\
- -rw-r--r-- 1 user staff 1234 Jan 1 12:00 Cargo.toml\n\
- -rwxr-xr-x 1 user staff 500 Jan 1 12:00 build.sh\n";
- let (entries, _summary, _parsed) = compact_ls(input, false, true);
- assert!(
- entries.contains("755 src/"),
- "dir should be prefixed with octal perms, got: {entries}"
- );
- assert!(
- entries.contains("644 Cargo.toml 1.2K"),
- "file should be prefixed with octal perms, got: {entries}"
- );
- assert!(
- entries.contains("755 build.sh 500B"),
- "executable should show 755, got: {entries}"
- );
- }
+ fn test_compact_summary() {
+ let temp = TempDir::new().unwrap();
+ fs::create_dir(temp.path().join("src")).unwrap();
+ fs::write(temp.path().join("main.rs"), "fn main() {}").unwrap();
+ fs::write(temp.path().join("lib.rs"), "pub fn foo() {}").unwrap();
+ fs::write(temp.path().join("Cargo.toml"), "[package]\n").unwrap();
- #[test]
- fn test_compact_short_format_omits_octal() {
- // Without -l, no octal prefix even though we still parse `ls -la`
- // under the hood.
- let input = "total 48\n\
- -rw-r--r-- 1 user staff 1234 Jan 1 12:00 Cargo.toml\n";
- let (entries, _summary, _parsed) = compact_ls(input, false, false);
- assert!(
- !entries.contains("644"),
- "short format must not include octal perms, got: {entries}"
- );
- assert!(entries.contains("Cargo.toml"));
+ let (_entries, summary) = compact_dir(temp.path().to_str().unwrap(), false, false).unwrap();
+ assert!(summary.contains("Summary: 3 files, 1 dirs"));
+ assert!(summary.contains(".rs"));
+ assert!(summary.contains(".toml"));
}
#[test]
- fn test_compact_chinese_locale_fallback() {
- let input = "total 8\n\
- drwxr-xr-x 2 user staff 64 1月 1 12:00 src\n\
- -rw-r--r-- 1 user staff 1234 1月 1 12:00 main.rs\n";
- let (entries, summary, parsed_count) = compact_ls(input, false, false);
- assert_eq!(parsed_count, 0);
- assert!(entries.is_empty());
- assert!(summary.is_empty());
+ fn test_noise_dirs_constant() {
+ assert!(NOISE_DIRS.contains(&"node_modules"));
+ assert!(NOISE_DIRS.contains(&".git"));
+ assert!(NOISE_DIRS.contains(&"target"));
+ assert!(NOISE_DIRS.contains(&"__pycache__"));
+ assert!(NOISE_DIRS.contains(&".next"));
+ assert!(NOISE_DIRS.contains(&"dist"));
+ assert!(NOISE_DIRS.contains(&"build"));
}
}
diff --git a/src/cmds/system/search.rs b/src/cmds/system/search.rs
index ffdd93c166..6e312742c9 100644
--- a/src/cmds/system/search.rs
+++ b/src/cmds/system/search.rs
@@ -8,7 +8,7 @@
use crate::core::stream::{exec_capture, exec_capture_stdin, CaptureResult};
use crate::core::tracking;
-use crate::core::utils::{resolved_command, strip_ansi};
+use crate::core::utils::{resolved_command, strip_ansi, tool_exists};
use crate::core::{args_utils, config};
use anyhow::{Context, Result};
use regex::Regex;
@@ -347,6 +347,15 @@ pub fn run(
args: &[String],
verbose: u8,
) -> Result {
+ if !tool_exists(engine.bin()) {
+ anyhow::bail!(
+ "'{}' not found on PATH.\n\
+ Install ripgrep (recommended): winget install BurntSushi.ripgrep.MSVC\n\
+ Or: cargo install ripgrep\n\
+ Alternative: Use PowerShell: Select-String -Pattern \"...\"",
+ engine.bin()
+ );
+ }
let timer = tracking::TimedExecution::start();
// --version / --help: pass through to the engine without filtering.
diff --git a/src/cmds/system/tree.rs b/src/cmds/system/tree.rs
index 576e6c8006..4a0cc6b781 100644
--- a/src/cmds/system/tree.rs
+++ b/src/cmds/system/tree.rs
@@ -1,163 +1,195 @@
-//! tree command - proxy to native tree with token-optimized output
-//!
-//! This module proxies to the native `tree` command and filters the output
-//! to reduce token usage while preserving structure visibility.
-//!
-//! Token optimization: automatically excludes noise directories via -I pattern
-//! unless -a flag is present (respecting user intent).
-
use super::constants::NOISE_DIRS;
-use crate::core::runner::{self, RunOptions};
-use crate::core::utils::{resolved_command, tool_exists};
-use anyhow::Result;
-
-pub fn run(args: &[String], verbose: u8) -> Result {
- if !tool_exists("tree") {
- anyhow::bail!(
- "tree command not found. Install it first:\n\
- - macOS: brew install tree\n\
- - Ubuntu/Debian: sudo apt install tree\n\
- - Fedora/RHEL: sudo dnf install tree\n\
- - Arch: sudo pacman -S tree"
- );
+use crate::core::guard::never_worse;
+use crate::core::tracking;
+use anyhow::{Context, Result};
+use std::collections::HashMap;
+use std::path::{Path, PathBuf};
+use walkdir::WalkDir;
+
+const DEFAULT_MAX_LINES: usize = 80;
+
+fn truncate_lines(s: &str, max: usize) -> String {
+ let total: Vec<&str> = s.lines().collect();
+ if total.len() <= max {
+ return s.to_string();
}
+ let omit = total.len() - max;
+ let mut out: String = total[..max].join("\n");
+ out.push_str(&format!("\n... ({} lines truncated)", omit));
+ out
+}
- let mut cmd = resolved_command("tree");
+pub fn run(args: &[String], verbose: u8) -> Result {
+ let timer = tracking::TimedExecution::start();
let show_all = args.iter().any(|a| a == "-a" || a == "--all");
- let has_ignore = args.iter().any(|a| a == "-I" || a.starts_with("--ignore="));
-
- if !show_all && !has_ignore {
- let ignore_pattern = NOISE_DIRS.join("|");
- cmd.arg("-I").arg(&ignore_pattern);
+ let target = args
+ .iter()
+ .find(|a| !a.starts_with('-'))
+ .map(|s| s.as_str())
+ .unwrap_or(".");
+
+ if verbose > 0 {
+ eprintln!("Tree: {} (show_all: {})", target, show_all);
}
- for arg in args {
- cmd.arg(arg);
+ let output = build_tree(target, show_all)
+ .with_context(|| format!("Failed to build tree for: {}", target))?;
+
+ let trunk = truncate_lines(&output, DEFAULT_MAX_LINES);
+ let shown = never_worse(&output, &trunk);
+ print!("{}", shown);
+
+ if verbose > 0 {
+ eprintln!(
+ "Chars: {} → {} ({}% reduction)",
+ output.len(),
+ shown.len(),
+ if !output.is_empty() {
+ 100 - (shown.len() * 100 / output.len())
+ } else {
+ 0
+ }
+ );
}
- runner::run_filtered(
- cmd,
- "tree",
- &args.join(" "),
- |raw| {
- let filtered = filter_tree_output(raw);
- if verbose > 0 {
- eprintln!(
- "Lines: {} → {} ({}% reduction)",
- raw.lines().count(),
- filtered.lines().count(),
- if raw.lines().count() > 0 {
- 100 - (filtered.lines().count() * 100 / raw.lines().count())
- } else {
- 0
- }
- );
- }
- filtered
- },
- RunOptions::stdout_only()
- .early_exit_on_failure()
- .no_trailing_newline(),
- )
+ timer.track(
+ &format!("tree {}", target),
+ "rtk tree",
+ &output,
+ shown,
+ );
+ Ok(0)
}
-fn filter_tree_output(raw: &str) -> String {
- let lines: Vec<&str> = raw.lines().collect();
-
- if lines.is_empty() {
- return "\n".to_string();
+fn build_tree(root: &str, show_all: bool) -> Result {
+ let root_path = Path::new(root);
+ if !root_path.exists() {
+ anyhow::bail!("Directory not found: {}", root);
}
- let mut filtered_lines = Vec::new();
-
- for line in lines {
- // Skip the final summary line (e.g., "5 directories, 23 files")
- if line.contains("director") && line.contains("file") {
- continue;
- }
-
- // Skip empty lines at the end
- if line.trim().is_empty() && filtered_lines.is_empty() {
- continue;
+ let root_name = root_path
+ .file_name()
+ .map(|n| n.to_string_lossy().to_string())
+ .unwrap_or_else(|| root.to_string());
+
+ let entries: Vec = WalkDir::new(root)
+ .min_depth(1)
+ .into_iter()
+ .filter_entry(|e| {
+ if show_all {
+ return true;
+ }
+ let name = e.file_name().to_string_lossy();
+ if name.starts_with('.') {
+ return false;
+ }
+ !NOISE_DIRS.iter().any(|noise| name == *noise)
+ })
+ .filter_map(|e| e.ok())
+ .map(|e| e.into_path())
+ .collect();
+
+ let mut children_map: HashMap> = HashMap::new();
+ for entry in &entries {
+ if let Some(parent) = entry.parent() {
+ children_map.entry(parent.to_path_buf()).or_default().push(entry.clone());
}
+ }
- filtered_lines.push(line);
+ for list in children_map.values_mut() {
+ list.sort_by(|a, b| {
+ let a_is_dir = a.is_dir();
+ let b_is_dir = b.is_dir();
+ if a_is_dir != b_is_dir {
+ b_is_dir.cmp(&a_is_dir)
+ } else {
+ a.file_name().cmp(&b.file_name())
+ }
+ });
}
- // Remove trailing empty lines
- while filtered_lines.last().is_some_and(|l| l.trim().is_empty()) {
- filtered_lines.pop();
+ let mut result = String::new();
+ result.push_str(&root_name);
+ result.push('\n');
+
+ let root_children = children_map.remove(root_path).unwrap_or_default();
+ for (i, child) in root_children.iter().enumerate() {
+ let is_last = i == root_children.len() - 1;
+ let name = child.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
+ print_entry(&mut result, "", child, &name, is_last, &children_map);
}
- filtered_lines.join("\n") + "\n"
+ Ok(result)
+}
+
+fn print_entry(
+ result: &mut String,
+ prefix: &str,
+ path: &Path,
+ name: &str,
+ is_last: bool,
+ children_map: &HashMap>,
+) {
+ let connector = if is_last { "└── " } else { "├── " };
+ let suffix = if path.is_dir() { "/" } else { "" };
+
+ result.push_str(&format!("{}{}{}{}\n", prefix, connector, name, suffix));
+
+ if path.is_dir() {
+ let child_prefix = if is_last { " " } else { "│ " };
+ let children = children_map.get(path).map(|v| v.as_slice()).unwrap_or(&[]);
+ for (i, child) in children.iter().enumerate() {
+ let child_is_last = i == children.len() - 1;
+ let child_name = child.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
+ let full_prefix = format!("{}{}", prefix, child_prefix);
+ print_entry(result, &full_prefix, child, &child_name, child_is_last, children_map);
+ }
+ }
}
#[cfg(test)]
mod tests {
use super::*;
-
- #[test]
- fn test_filter_removes_summary() {
- let input = ".\n├── src\n│ └── main.rs\n└── Cargo.toml\n\n2 directories, 3 files\n";
- let output = filter_tree_output(input);
- assert!(!output.contains("directories"));
- assert!(!output.contains("files"));
- assert!(output.contains("main.rs"));
- assert!(output.contains("Cargo.toml"));
+ use std::fs;
+ use tempfile::TempDir;
+
+ fn setup_test_dir() -> TempDir {
+ let temp = TempDir::new().unwrap();
+ fs::create_dir_all(temp.path().join("src")).unwrap();
+ fs::write(temp.path().join("Cargo.toml"), "[package]\n").unwrap();
+ fs::write(temp.path().join("src/main.rs"), "fn main() {}\n").unwrap();
+ fs::write(temp.path().join("src/lib.rs"), "pub fn foo() {}\n").unwrap();
+ temp
}
#[test]
- fn test_filter_preserves_structure() {
- let input = ".\n├── src\n│ ├── main.rs\n│ └── lib.rs\n└── tests\n └── test.rs\n";
- let output = filter_tree_output(input);
- assert!(output.contains("├──"));
- assert!(output.contains("│"));
- assert!(output.contains("└──"));
+ fn test_tree_contains_files() {
+ let temp = setup_test_dir();
+ let output = build_tree(temp.path().to_str().unwrap(), false).unwrap();
+ assert!(output.contains("Cargo.toml"));
+ assert!(output.contains("src/"));
assert!(output.contains("main.rs"));
- assert!(output.contains("test.rs"));
+ assert!(output.contains("lib.rs"));
}
#[test]
- fn test_filter_handles_empty() {
- let input = "";
- let output = filter_tree_output(input);
- assert_eq!(output, "\n");
+ fn test_tree_root_is_dir_name() {
+ let temp = setup_test_dir();
+ let dir_name = temp.path().file_name().unwrap().to_string_lossy().to_string();
+ let output = build_tree(temp.path().to_str().unwrap(), false).unwrap();
+ assert!(output.starts_with(&dir_name));
}
#[test]
- fn test_filter_removes_trailing_empty_lines() {
- let input = ".\n├── file.txt\n\n\n";
- let output = filter_tree_output(input);
- assert_eq!(output.matches('\n').count(), 2); // Root + file.txt + final newline
- }
-
- #[test]
- fn test_filter_summary_variations() {
- // Test different summary formats
- let inputs = vec![
- (".\n└── file.txt\n\n0 directories, 1 file\n", "1 file"),
- (".\n└── file.txt\n\n1 directory, 0 files\n", "1 directory"),
- (".\n└── file.txt\n\n10 directories, 25 files\n", "25 files"),
- ];
-
- for (input, summary_fragment) in inputs {
- let output = filter_tree_output(input);
- assert!(
- !output.contains(summary_fragment),
- "Should remove summary '{}' from output",
- summary_fragment
- );
- assert!(
- output.contains("file.txt"),
- "Should preserve file.txt in output"
- );
- }
+ fn test_tree_uses_tree_chars() {
+ let temp = setup_test_dir();
+ let output = build_tree(temp.path().to_str().unwrap(), false).unwrap();
+ assert!(output.contains("├──") || output.contains("└──"));
}
#[test]
fn test_noise_dirs_constant() {
- // Verify NOISE_DIRS contains expected patterns
assert!(NOISE_DIRS.contains(&"node_modules"));
assert!(NOISE_DIRS.contains(&".git"));
assert!(NOISE_DIRS.contains(&"target"));
@@ -166,4 +198,28 @@ mod tests {
assert!(NOISE_DIRS.contains(&"dist"));
assert!(NOISE_DIRS.contains(&"build"));
}
+
+ #[test]
+ fn test_tree_filters_noise() {
+ let temp = TempDir::new().unwrap();
+ fs::create_dir(temp.path().join("node_modules")).unwrap();
+ fs::create_dir(temp.path().join("src")).unwrap();
+ fs::write(temp.path().join("main.rs"), "fn main() {}\n").unwrap();
+
+ let output = build_tree(temp.path().to_str().unwrap(), false).unwrap();
+ assert!(!output.contains("node_modules"));
+ assert!(output.contains("src/"));
+ assert!(output.contains("main.rs"));
+ }
+
+ #[test]
+ fn test_tree_show_all_includes_hidden() {
+ let temp = TempDir::new().unwrap();
+ fs::create_dir(temp.path().join(".git")).unwrap();
+ fs::write(temp.path().join(".hidden"), "data").unwrap();
+
+ let output = build_tree(temp.path().to_str().unwrap(), true).unwrap();
+ assert!(output.contains(".git/"));
+ assert!(output.contains(".hidden"));
+ }
}
diff --git a/src/cmds/system/wc_cmd.rs b/src/cmds/system/wc_cmd.rs
index 78c3924411..00a696b1d9 100644
--- a/src/cmds/system/wc_cmd.rs
+++ b/src/cmds/system/wc_cmd.rs
@@ -1,62 +1,61 @@
-/// Compact filter for `wc` — strips redundant paths and alignment padding.
-///
-/// Compression examples:
-/// - `wc file.py` → `30L 96W 978B`
-/// - `wc -l file.py` → `30`
-/// - `wc -w file.py` → `96`
-/// - `wc -c file.py` → `978`
-/// - `wc -l *.py` → table with common path prefix stripped
-use crate::core::runner::{self, RunOptions};
-use crate::core::utils::resolved_command;
-use anyhow::Result;
+use crate::core::guard::never_worse;
+use crate::core::tracking;
+use anyhow::{Context, Result};
+use std::fs;
+use std::io::{self, Read};
+use std::path::Path;
-pub fn run(args: &[String], verbose: u8) -> Result {
- let mut cmd = resolved_command("wc");
- for arg in args {
- cmd.arg(arg);
- }
-
- if verbose > 0 {
- eprintln!("Running: wc {}", args.join(" "));
- }
-
- let mode = detect_mode(args);
-
- // No file operands → wc reads from stdin. Forward rtk's stdin to the child
- // so `cat file | rtk wc` counts the piped data instead of reporting zero.
- let reads_stdin = !args.iter().any(|a| !a.starts_with('-'));
- let opts = if reads_stdin {
- RunOptions::stdout_only().inherit_stdin()
- } else {
- RunOptions::stdout_only()
- };
-
- runner::run_filtered(
- cmd,
- "wc",
- &args.join(" "),
- |stdout| filter_wc_output(stdout, &mode),
- opts,
- )
-}
-
-/// Which columns the user requested
#[derive(Debug, PartialEq)]
enum WcMode {
- /// Default: lines, words, bytes (3 columns)
Full,
- /// Lines only (-l)
Lines,
- /// Words only (-w)
Words,
- /// Bytes only (-c)
Bytes,
- /// Chars only (-m)
Chars,
- /// Multiple flags combined — keep compact format
Mixed,
}
+pub fn run(args: &[String], verbose: u8) -> Result {
+ let timer = tracking::TimedExecution::start();
+
+ let mode = detect_mode(args);
+ let file_args: Vec<&str> = args
+ .iter()
+ .filter(|a| !a.starts_with('-'))
+ .map(|s| s.as_str())
+ .collect();
+
+ let output = if file_args.is_empty() {
+ count_stdin(&mode)?
+ } else {
+ count_files(&file_args, &mode)?
+ };
+
+ let shown = never_worse(&output, &output);
+ print!("{}", shown);
+
+ if verbose > 0 {
+ eprintln!(
+ "Chars: {} → {} ({}% reduction)",
+ output.len(),
+ shown.len(),
+ if !output.is_empty() {
+ 100 - (shown.len() * 100 / output.len())
+ } else {
+ 0
+ }
+ );
+ }
+
+ timer.track(
+ &format!("wc {}", args.join(" ")),
+ "rtk wc",
+ &output,
+ shown,
+ );
+ Ok(0)
+}
+
fn detect_mode(args: &[String]) -> WcMode {
let flags: Vec<&str> = args
.iter()
@@ -68,7 +67,6 @@ fn detect_mode(args: &[String]) -> WcMode {
return WcMode::Full;
}
- // Collect all single-char flags (handles combined flags like -lw)
let mut has_l = false;
let mut has_w = false;
let mut has_c = false;
@@ -78,22 +76,10 @@ fn detect_mode(args: &[String]) -> WcMode {
for flag in &flags {
for ch in flag.chars().skip(1) {
match ch {
- 'l' => {
- has_l = true;
- flag_count += 1;
- }
- 'w' => {
- has_w = true;
- flag_count += 1;
- }
- 'c' => {
- has_c = true;
- flag_count += 1;
- }
- 'm' => {
- has_m = true;
- flag_count += 1;
- }
+ 'l' => { has_l = true; flag_count += 1; }
+ 'w' => { has_w = true; flag_count += 1; }
+ 'c' => { has_c = true; flag_count += 1; }
+ 'm' => { has_m = true; flag_count += 1; }
_ => {}
}
}
@@ -106,149 +92,127 @@ fn detect_mode(args: &[String]) -> WcMode {
return WcMode::Mixed;
}
- if has_l {
- WcMode::Lines
- } else if has_w {
- WcMode::Words
- } else if has_c {
- WcMode::Bytes
- } else if has_m {
- WcMode::Chars
- } else {
- WcMode::Full
- }
+ if has_l { WcMode::Lines }
+ else if has_w { WcMode::Words }
+ else if has_c { WcMode::Bytes }
+ else if has_m { WcMode::Chars }
+ else { WcMode::Full }
}
-fn filter_wc_output(raw: &str, mode: &WcMode) -> String {
- let lines: Vec<&str> = raw.trim().lines().collect();
+struct Counts {
+ lines: usize,
+ words: usize,
+ bytes: usize,
+ chars: usize,
+}
- if lines.is_empty() {
- return String::new();
+fn count_content(content: &str) -> Counts {
+ let lines = content.bytes().filter(|&b| b == b'\n').count();
+ let words = content.split_whitespace().count();
+ let bytes = content.len();
+ let chars = content.chars().count();
+ Counts { lines, words, bytes, chars }
+}
+
+fn count_stdin(mode: &WcMode) -> Result {
+ let mut content = String::new();
+ io::stdin().read_to_string(&mut content)?;
+ let counts = count_content(&content);
+ Ok(format_counts_single(&counts, mode))
+}
+
+fn count_files(files: &[&str], mode: &WcMode) -> Result {
+ if files.len() == 1 {
+ let content = fs::read_to_string(files[0])
+ .with_context(|| format!("Failed to read file: {}", files[0]))?;
+ let counts = count_content(&content);
+ return Ok(format_counts_single(&counts, mode));
}
- // Single file (one output line, no "total")
- if lines.len() == 1 {
- return format_single_line(lines[0], mode);
+ let mut all_counts: Vec<(String, Counts)> = Vec::new();
+ let mut total = Counts { lines: 0, words: 0, bytes: 0, chars: 0 };
+
+ let common_prefix = find_common_prefix(files);
+
+ for file in files {
+ let path = Path::new(file);
+ match fs::read_to_string(path) {
+ Ok(content) => {
+ let counts = count_content(&content);
+ let display_name = file.strip_prefix(&common_prefix).unwrap_or(file);
+ all_counts.push((display_name.to_string(), Counts { ..counts }));
+ total.lines += counts.lines;
+ total.words += counts.words;
+ total.bytes += counts.bytes;
+ total.chars += counts.chars;
+ }
+ Err(e) => {
+ eprintln!("wc: {}: {}", file, e);
+ }
+ }
}
- // Multiple files — compact table
- format_multi_line(&lines, mode)
+ Ok(format_counts_multi(&all_counts, &total, mode))
}
-/// Format a single wc output line (one file or stdin)
-fn format_single_line(line: &str, mode: &WcMode) -> String {
- let parts: Vec<&str> = line.split_whitespace().collect();
-
+fn format_counts_single(counts: &Counts, mode: &WcMode) -> String {
match mode {
- WcMode::Lines | WcMode::Words | WcMode::Bytes | WcMode::Chars => {
- // First number is the only requested column
- parts.first().map(|s| s.to_string()).unwrap_or_default()
- }
- WcMode::Full => {
- if parts.len() >= 3 {
- format!("{}L {}W {}B", parts[0], parts[1], parts[2])
- } else {
- line.trim().to_string()
- }
- }
+ WcMode::Lines => counts.lines.to_string(),
+ WcMode::Words => counts.words.to_string(),
+ WcMode::Bytes => counts.bytes.to_string(),
+ WcMode::Chars => counts.chars.to_string(),
+ WcMode::Full => format!("{}L {}W {}B", counts.lines, counts.words, counts.bytes),
WcMode::Mixed => {
- // Strip file path, keep numbers only
- if parts.len() >= 2 {
- let last_is_path = parts.last().is_some_and(|p| p.parse::().is_err());
- if last_is_path {
- parts[..parts.len() - 1].join(" ")
- } else {
- parts.join(" ")
- }
- } else {
- line.trim().to_string()
- }
+ [counts.lines.to_string(), counts.words.to_string(), counts.bytes.to_string()].join(" ")
}
}
}
-/// Format multiple files as a compact table
-fn format_multi_line(lines: &[&str], mode: &WcMode) -> String {
+fn format_counts_multi(
+ all_counts: &[(String, Counts)],
+ total: &Counts,
+ mode: &WcMode,
+) -> String {
let mut result = Vec::new();
- // Find common directory prefix to shorten paths
- let paths: Vec<&str> = lines
- .iter()
- .filter_map(|line| {
- let parts: Vec<&str> = line.split_whitespace().collect();
- parts.last().copied()
- })
- .filter(|p| *p != "total")
- .collect();
-
- let common_prefix = find_common_prefix(&paths);
-
- for line in lines {
- let parts: Vec<&str> = line.split_whitespace().collect();
- if parts.is_empty() {
- continue;
- }
-
- let is_total = parts.last().is_some_and(|p| *p == "total");
-
- match mode {
- WcMode::Lines | WcMode::Words | WcMode::Bytes | WcMode::Chars => {
- if is_total {
- result.push(format!("Σ {}", parts.first().unwrap_or(&"0")));
- } else {
- let name = strip_prefix(parts.last().unwrap_or(&""), &common_prefix);
- result.push(format!("{} {}", parts.first().unwrap_or(&"0"), name));
- }
- }
- WcMode::Full => {
- if is_total {
- result.push(format!(
- "Σ {}L {}W {}B",
- parts.first().unwrap_or(&"0"),
- parts.get(1).unwrap_or(&"0"),
- parts.get(2).unwrap_or(&"0"),
- ));
- } else if parts.len() >= 4 {
- let name = strip_prefix(parts[3], &common_prefix);
- result.push(format!(
- "{}L {}W {}B {}",
- parts[0], parts[1], parts[2], name
- ));
- } else {
- result.push(line.trim().to_string());
- }
- }
+ for (name, counts) in all_counts {
+ let line = match mode {
+ WcMode::Lines => format!("{} {}", counts.lines, name),
+ WcMode::Words => format!("{} {}", counts.words, name),
+ WcMode::Bytes => format!("{} {}", counts.bytes, name),
+ WcMode::Chars => format!("{} {}", counts.chars, name),
+ WcMode::Full => format!("{}L {}W {}B {}", counts.lines, counts.words, counts.bytes, name),
WcMode::Mixed => {
- if is_total {
- let nums: Vec<&str> = parts[..parts.len() - 1].to_vec();
- result.push(format!("Σ {}", nums.join(" ")));
- } else if parts.len() >= 2 {
- let last_is_path = parts.last().is_some_and(|p| p.parse::().is_err());
- if last_is_path {
- let name = strip_prefix(parts.last().unwrap_or(&""), &common_prefix);
- let nums: Vec<&str> = parts[..parts.len() - 1].to_vec();
- result.push(format!("{} {}", nums.join(" "), name));
- } else {
- result.push(parts.join(" "));
- }
- } else {
- result.push(line.trim().to_string());
- }
+ let nums = [counts.lines.to_string(), counts.words.to_string(), counts.bytes.to_string()];
+ format!("{} {}", nums.join(" "), name)
}
- }
+ };
+ result.push(line);
}
+ let total_line = match mode {
+ WcMode::Lines => format!("Σ {}", total.lines),
+ WcMode::Words => format!("Σ {}", total.words),
+ WcMode::Bytes => format!("Σ {}", total.bytes),
+ WcMode::Chars => format!("Σ {}", total.chars),
+ WcMode::Full => format!("Σ {}L {}W {}B", total.lines, total.words, total.bytes),
+ WcMode::Mixed => {
+ let nums = [total.lines.to_string(), total.words.to_string(), total.bytes.to_string()];
+ format!("Σ {}", nums.join(" "))
+ }
+ };
+ result.push(total_line);
+
result.join("\n")
}
-/// Find common directory prefix among paths
fn find_common_prefix(paths: &[&str]) -> String {
if paths.len() <= 1 {
return String::new();
}
let first = paths[0];
- let prefix = if let Some(pos) = first.rfind('/') {
+ let prefix = if let Some(pos) = first.rfind(['/', '\\']) {
&first[..=pos]
} else {
return String::new();
@@ -258,13 +222,12 @@ fn find_common_prefix(paths: &[&str]) -> String {
return prefix.to_string();
}
- // Try shorter prefixes by removing right-most segments
let mut candidate = prefix.to_string();
while !candidate.is_empty() {
if paths.iter().all(|p| p.starts_with(&candidate)) {
return candidate;
}
- if let Some(pos) = candidate[..candidate.len() - 1].rfind('/') {
+ if let Some(pos) = candidate[..candidate.len() - 1].rfind(['/', '\\']) {
candidate.truncate(pos + 1);
} else {
return String::new();
@@ -273,98 +236,84 @@ fn find_common_prefix(paths: &[&str]) -> String {
String::new()
}
-/// Strip common prefix from a path
-fn strip_prefix<'a>(path: &'a str, prefix: &str) -> &'a str {
- if prefix.is_empty() {
- return path;
- }
- path.strip_prefix(prefix).unwrap_or(path)
-}
-
#[cfg(test)]
mod tests {
use super::*;
#[test]
- fn test_single_file_full() {
- let raw = " 30 96 978 scripts/find_duplicate_attrs.py\n";
- let result = filter_wc_output(raw, &WcMode::Full);
- assert_eq!(result, "30L 96W 978B");
- }
-
- #[test]
- fn test_single_file_lines_only() {
- let raw = " 30 scripts/find_duplicate_attrs.py\n";
- let result = filter_wc_output(raw, &WcMode::Lines);
- assert_eq!(result, "30");
+ fn test_detect_mode_full() {
+ let args: Vec = vec!["file.py".into()];
+ assert_eq!(detect_mode(&args), WcMode::Full);
}
#[test]
- fn test_single_file_words_only() {
- let raw = " 96 scripts/find_duplicate_attrs.py\n";
- let result = filter_wc_output(raw, &WcMode::Words);
- assert_eq!(result, "96");
+ fn test_detect_mode_lines() {
+ let args: Vec = vec!["-l".into(), "file.py".into()];
+ assert_eq!(detect_mode(&args), WcMode::Lines);
}
#[test]
- fn test_stdin_full() {
- let raw = " 30 96 978\n";
- let result = filter_wc_output(raw, &WcMode::Full);
- assert_eq!(result, "30L 96W 978B");
+ fn test_detect_mode_mixed() {
+ let args: Vec = vec!["-lw".into(), "file.py".into()];
+ assert_eq!(detect_mode(&args), WcMode::Mixed);
}
#[test]
- fn test_stdin_lines() {
- let raw = " 30\n";
- let result = filter_wc_output(raw, &WcMode::Lines);
- assert_eq!(result, "30");
+ fn test_detect_mode_separate_flags() {
+ let args: Vec = vec!["-l".into(), "-w".into(), "file.py".into()];
+ assert_eq!(detect_mode(&args), WcMode::Mixed);
}
#[test]
- fn test_multi_file_lines() {
- let raw = " 30 src/main.rs\n 50 src/lib.rs\n 80 total\n";
- let result = filter_wc_output(raw, &WcMode::Lines);
- assert_eq!(result, "30 main.rs\n50 lib.rs\nΣ 80");
+ fn test_format_counts_single_full() {
+ let counts = Counts { lines: 30, words: 96, bytes: 978, chars: 978 };
+ assert_eq!(format_counts_single(&counts, &WcMode::Full), "30L 96W 978B");
}
#[test]
- fn test_multi_file_full() {
- let raw = " 30 96 978 src/main.rs\n 50 120 1500 src/lib.rs\n 80 216 2478 total\n";
- let result = filter_wc_output(raw, &WcMode::Full);
- assert_eq!(
- result,
- "30L 96W 978B main.rs\n50L 120W 1500B lib.rs\nΣ 80L 216W 2478B"
- );
+ fn test_format_counts_single_lines() {
+ let counts = Counts { lines: 30, words: 96, bytes: 978, chars: 978 };
+ assert_eq!(format_counts_single(&counts, &WcMode::Lines), "30");
}
#[test]
- fn test_detect_mode_full() {
- let args: Vec = vec!["file.py".into()];
- assert_eq!(detect_mode(&args), WcMode::Full);
+ fn test_format_counts_single_words() {
+ let counts = Counts { lines: 30, words: 96, bytes: 978, chars: 978 };
+ assert_eq!(format_counts_single(&counts, &WcMode::Words), "96");
}
#[test]
- fn test_detect_mode_lines() {
- let args: Vec = vec!["-l".into(), "file.py".into()];
- assert_eq!(detect_mode(&args), WcMode::Lines);
+ fn test_format_counts_multi_lines() {
+ let counts = vec![
+ ("src/main.rs".to_string(), Counts { lines: 30, words: 96, bytes: 978, chars: 978 }),
+ ("src/lib.rs".to_string(), Counts { lines: 50, words: 120, bytes: 1500, chars: 1500 }),
+ ];
+ let total = Counts { lines: 80, words: 216, bytes: 2478, chars: 2478 };
+ let result = format_counts_multi(&counts, &total, &WcMode::Lines);
+ assert_eq!(result, "30 src/main.rs\n50 src/lib.rs\nΣ 80");
}
#[test]
- fn test_detect_mode_mixed() {
- let args: Vec = vec!["-lw".into(), "file.py".into()];
- assert_eq!(detect_mode(&args), WcMode::Mixed);
+ fn test_format_counts_multi_full() {
+ let counts = vec![
+ ("main.rs".to_string(), Counts { lines: 30, words: 96, bytes: 978, chars: 978 }),
+ ("lib.rs".to_string(), Counts { lines: 50, words: 120, bytes: 1500, chars: 1500 }),
+ ];
+ let total = Counts { lines: 80, words: 216, bytes: 2478, chars: 2478 };
+ let result = format_counts_multi(&counts, &total, &WcMode::Full);
+ assert_eq!(result, "30L 96W 978B main.rs\n50L 120W 1500B lib.rs\nΣ 80L 216W 2478B");
}
#[test]
- fn test_detect_mode_separate_flags() {
- let args: Vec = vec!["-l".into(), "-w".into(), "file.py".into()];
- assert_eq!(detect_mode(&args), WcMode::Mixed);
+ fn test_common_prefix_unix() {
+ let paths = vec!["src/main.rs", "src/lib.rs", "src/utils.rs"];
+ assert_eq!(find_common_prefix(&paths), "src/");
}
#[test]
- fn test_common_prefix() {
- let paths = vec!["src/main.rs", "src/lib.rs", "src/utils.rs"];
- assert_eq!(find_common_prefix(&paths), "src/");
+ fn test_common_prefix_windows() {
+ let paths = vec!["src\\main.rs", "src\\lib.rs"];
+ assert_eq!(find_common_prefix(&paths), "src\\");
}
#[test]
@@ -374,15 +323,34 @@ mod tests {
}
#[test]
- fn test_deep_common_prefix() {
- let paths = vec!["src/cmd/wc.rs", "src/cmd/ls.rs"];
- assert_eq!(find_common_prefix(&paths), "src/cmd/");
+ fn test_common_prefix_with_prefix_stripping() {
+ let paths = vec!["src/main.rs", "src/lib.rs"];
+ let prefix = find_common_prefix(&paths);
+ let stripped: Vec<&str> = paths.iter().map(|p| p.strip_prefix(&prefix).unwrap_or(p)).collect();
+ assert_eq!(stripped, vec!["main.rs", "lib.rs"]);
+ }
+
+ #[test]
+ fn test_count_content_simple() {
+ let counts = count_content("hello world\nfoo bar baz\n");
+ assert_eq!(counts.lines, 2);
+ assert_eq!(counts.words, 5);
+ assert_eq!(counts.bytes, 24);
+ }
+
+ #[test]
+ fn test_count_content_empty() {
+ let counts = count_content("");
+ assert_eq!(counts.lines, 0);
+ assert_eq!(counts.words, 0);
+ assert_eq!(counts.bytes, 0);
}
#[test]
- fn test_empty() {
- let raw = "";
- let result = filter_wc_output(raw, &WcMode::Full);
- assert_eq!(result, "");
+ fn test_count_content_single_line_with_newline() {
+ let counts = count_content("hello\n");
+ assert_eq!(counts.lines, 1);
+ assert_eq!(counts.words, 1);
+ assert_eq!(counts.bytes, 6);
}
}
diff --git a/src/core/runner.rs b/src/core/runner.rs
index b893357491..696337b794 100644
--- a/src/core/runner.rs
+++ b/src/core/runner.rs
@@ -72,6 +72,7 @@ impl<'a> RunOptions<'a> {
self
}
+ #[expect(dead_code)]
pub fn inherit_stdin(mut self) -> Self {
self.inherit_stdin = true;
self
diff --git a/src/core/stream.rs b/src/core/stream.rs
index cd573c4cc9..a6c1bb59ed 100644
--- a/src/core/stream.rs
+++ b/src/core/stream.rs
@@ -231,13 +231,6 @@ pub fn status_to_exit_code(status: std::process::ExitStatus) -> i32 {
if let Some(code) = status.code() {
return code;
}
- #[cfg(unix)]
- {
- use std::os::unix::process::ExitStatusExt;
- if let Some(sig) = status.signal() {
- return 128 + sig;
- }
- }
1
}
@@ -557,6 +550,87 @@ pub(crate) mod tests {
use super::*;
use std::process::Command;
+ #[cfg(windows)]
+ fn true_cmd() -> Command {
+ let mut c = Command::new("cmd");
+ c.args(["/c", "exit", "0"]);
+ c
+ }
+ #[cfg(not(windows))]
+ fn true_cmd() -> Command { Command::new("true") }
+
+ #[cfg(windows)]
+ fn false_cmd() -> Command {
+ let mut c = Command::new("cmd");
+ c.args(["/c", "exit", "/b", "1"]);
+ c
+ }
+ #[cfg(not(windows))]
+ fn false_cmd() -> Command { Command::new("false") }
+
+ #[cfg(windows)]
+ fn echo_cmd(msg: &str) -> Command {
+ let mut c = Command::new("cmd");
+ c.args(["/c", "echo", msg]);
+ c
+ }
+ #[cfg(not(windows))]
+ fn echo_cmd(msg: &str) -> Command {
+ let mut c = Command::new("echo");
+ c.arg(msg);
+ c
+ }
+
+ #[cfg(windows)]
+ fn cat_cmd() -> Command {
+ let mut c = Command::new("cmd");
+ c.args(["/c", "cd", "."]);
+ c
+ }
+ #[cfg(not(windows))]
+ fn cat_cmd() -> Command { Command::new("cat") }
+
+ #[cfg(windows)]
+ fn sh_cmd(script: &str) -> Command {
+ let mut c = Command::new("cmd");
+ c.args(["/c", script]);
+ c
+ }
+ #[cfg(not(windows))]
+ fn sh_cmd(script: &str) -> Command {
+ let mut c = Command::new("sh");
+ c.args(["-c", script]);
+ c
+ }
+
+ #[cfg(windows)]
+ fn large_stdout_cmd() -> Command {
+ let mut c = Command::new("powershell");
+ c.args(["-NoLogo", "-NoProfile", "-Command",
+ "1..130000 | ForEach-Object { 'a'*80 }"]);
+ c
+ }
+ #[cfg(not(windows))]
+ fn large_stdout_cmd() -> Command {
+ let mut c = Command::new("sh");
+ c.args(["-c", "dd if=/dev/zero bs=1024 count=11264 2>/dev/null | tr '\\0' 'a' | fold -w 80"]);
+ c
+ }
+
+ #[cfg(windows)]
+ fn large_stderr_cmd() -> Command {
+ let mut c = Command::new("powershell");
+ c.args(["-NoLogo", "-NoProfile", "-Command",
+ "[Console]::Error.WriteLine([string]::new('a', 11500000))"]);
+ c
+ }
+ #[cfg(not(windows))]
+ fn large_stderr_cmd() -> Command {
+ let mut c = Command::new("sh");
+ c.args(["-c", "dd if=/dev/zero bs=1024 count=11264 2>/dev/null | tr '\\0' 'a' | fold -w 80 1>&2"]);
+ c
+ }
+
struct LineFilter Option> {
f: F,
}
@@ -579,25 +653,16 @@ pub(crate) mod tests {
#[test]
fn test_exit_code_zero() {
- let status = Command::new("true").status().unwrap();
+ let status = true_cmd().status().unwrap();
assert_eq!(status_to_exit_code(status), 0);
}
#[test]
fn test_exit_code_nonzero() {
- let status = Command::new("false").status().unwrap();
+ let status = false_cmd().status().unwrap();
assert_eq!(status_to_exit_code(status), 1);
}
- #[cfg(unix)]
- #[test]
- fn test_exit_code_signal_kill() {
- let mut child = Command::new("sleep").arg("60").spawn().unwrap();
- child.kill().unwrap();
- let status = child.wait().unwrap();
- assert_eq!(status_to_exit_code(status), 137);
- }
-
#[test]
fn test_line_filter_passes_lines() {
let mut f = LineFilter::new(|l| Some(format!("{}\n", l.to_uppercase())));
@@ -661,8 +726,7 @@ pub(crate) mod tests {
#[test]
fn test_run_streaming_passthrough_echo() {
- let mut cmd = Command::new("echo");
- cmd.arg("hello");
+ let mut cmd = echo_cmd("hello");
let result = run_streaming(&mut cmd, StdinMode::Null, FilterMode::Passthrough).unwrap();
assert_eq!(result.exit_code, 0);
// Passthrough inherits TTY — raw/filtered are empty
@@ -672,15 +736,14 @@ pub(crate) mod tests {
#[test]
fn test_run_streaming_exit_code_preserved() {
// nosemgrep: interpreter-execution
- let mut cmd = Command::new("sh");
- cmd.args(["-c", "exit 42"]);
+ let mut cmd = sh_cmd("exit /b 42");
let result = run_streaming(&mut cmd, StdinMode::Null, FilterMode::Passthrough).unwrap();
assert_eq!(result.exit_code, 42);
}
#[test]
fn test_run_streaming_exit_code_zero() {
- let mut cmd = Command::new("true");
+ let mut cmd = true_cmd();
let result = run_streaming(&mut cmd, StdinMode::Null, FilterMode::Passthrough).unwrap();
assert_eq!(result.exit_code, 0);
assert!(result.success());
@@ -688,7 +751,7 @@ pub(crate) mod tests {
#[test]
fn test_run_streaming_exit_code_one() {
- let mut cmd = Command::new("false");
+ let mut cmd = false_cmd();
let result = run_streaming(&mut cmd, StdinMode::Null, FilterMode::Passthrough).unwrap();
assert_eq!(result.exit_code, 1);
assert!(!result.success());
@@ -737,12 +800,7 @@ pub(crate) mod tests {
#[test]
fn test_run_streaming_raw_cap_at_10mb() {
// nosemgrep: interpreter-execution
- let mut cmd = Command::new("sh");
- // ~11 MiB of 80-char lines (fast: fewer lines than `yes | head -6M`)
- cmd.args([
- "-c",
- "dd if=/dev/zero bs=1024 count=11264 2>/dev/null | tr '\\0' 'a' | fold -w 80",
- ]);
+ let mut cmd = large_stdout_cmd();
let result = run_streaming(&mut cmd, StdinMode::Null, FilterMode::CaptureOnly).unwrap();
assert!(
result.raw.len() <= 10_485_760 + 100,
@@ -758,12 +816,7 @@ pub(crate) mod tests {
#[test]
fn test_run_streaming_stderr_cap_at_10mb() {
// nosemgrep: interpreter-execution
- let mut cmd = Command::new("sh");
- // ~11 MiB on stderr, nothing on stdout
- cmd.args([
- "-c",
- "dd if=/dev/zero bs=1024 count=11264 2>/dev/null | tr '\\0' 'a' | fold -w 80 1>&2",
- ]);
+ let mut cmd = large_stderr_cmd();
let result = run_streaming(&mut cmd, StdinMode::Null, FilterMode::CaptureOnly).unwrap();
// raw = raw_stdout + raw_stderr; stdout is empty so raw ≈ stderr size
assert!(
@@ -775,7 +828,7 @@ pub(crate) mod tests {
#[test]
fn test_child_guard_prevents_zombie() {
- let mut cmd = Command::new("true");
+ let mut cmd = true_cmd();
let result = run_streaming(&mut cmd, StdinMode::Null, FilterMode::CaptureOnly);
assert!(result.is_ok());
assert_eq!(result.unwrap().exit_code, 0);
@@ -783,31 +836,28 @@ pub(crate) mod tests {
#[test]
fn test_run_streaming_null_stdin_cat() {
- let mut cmd = Command::new("cat");
+ let mut cmd = cat_cmd();
let result = run_streaming(&mut cmd, StdinMode::Null, FilterMode::Passthrough).unwrap();
assert_eq!(result.exit_code, 0);
}
#[test]
fn test_run_streaming_raw_contains_stdout() {
- let mut cmd = Command::new("echo");
- cmd.arg("test_output_xyz");
+ let mut cmd = echo_cmd("test_output_xyz");
let result = run_streaming(&mut cmd, StdinMode::Null, FilterMode::CaptureOnly).unwrap();
assert!(result.raw.contains("test_output_xyz"));
}
#[test]
fn test_run_streaming_capture_only_filtered_equals_raw() {
- let mut cmd = Command::new("echo");
- cmd.arg("check_equality");
+ let mut cmd = echo_cmd("check_equality");
let result = run_streaming(&mut cmd, StdinMode::Null, FilterMode::CaptureOnly).unwrap();
assert_eq!(result.filtered.trim(), result.raw_stdout.trim());
}
#[test]
fn test_exec_capture_success() {
- let mut cmd = Command::new("echo");
- cmd.arg("hello_capture");
+ let mut cmd = echo_cmd("hello_capture");
let result = exec_capture(&mut cmd).unwrap();
assert!(result.success());
assert_eq!(result.exit_code, 0);
@@ -816,7 +866,7 @@ pub(crate) mod tests {
#[test]
fn test_exec_capture_failure() {
- let mut cmd = Command::new("false");
+ let mut cmd = false_cmd();
let result = exec_capture(&mut cmd).unwrap();
assert!(!result.success());
assert_eq!(result.exit_code, 1);
@@ -825,8 +875,7 @@ pub(crate) mod tests {
#[test]
fn test_exec_capture_stderr() {
// nosemgrep: interpreter-execution
- let mut cmd = Command::new("sh");
- cmd.args(["-c", "echo err_msg >&2"]);
+ let mut cmd = sh_cmd("echo err_msg >&2");
let result = exec_capture(&mut cmd).unwrap();
assert!(result.stderr.contains("err_msg"));
}
@@ -834,8 +883,7 @@ pub(crate) mod tests {
#[test]
fn test_exec_capture_combined() {
// nosemgrep: interpreter-execution
- let mut cmd = Command::new("sh");
- cmd.args(["-c", "echo out_msg; echo err_msg >&2"]);
+ let mut cmd = sh_cmd("echo out_msg & echo err_msg >&2");
let result = exec_capture(&mut cmd).unwrap();
let combined = result.combined();
assert!(combined.contains("out_msg"));
diff --git a/src/core/telemetry.rs b/src/core/telemetry.rs
index d9bee51f09..2c17d0e611 100644
--- a/src/core/telemetry.rs
+++ b/src/core/telemetry.rs
@@ -179,14 +179,7 @@ fn get_or_create_salt() -> String {
}
if let Ok(mut f) = std::fs::File::create(&salt_path) {
let _ = f.write_all(salt.as_bytes());
- #[cfg(unix)]
- {
- use std::os::unix::fs::PermissionsExt;
- let _ = std::fs::set_permissions(
- &salt_path,
- std::fs::Permissions::from_mode(0o600),
- );
- }
+
}
salt
})
diff --git a/src/core/toml_filter.rs b/src/core/toml_filter.rs
index 164b3e69bb..2738a3f916 100644
--- a/src/core/toml_filter.rs
+++ b/src/core/toml_filter.rs
@@ -1627,8 +1627,8 @@ match_command = "^make\\b"
let filters = make_filters(BUILTIN_TOML);
assert_eq!(
filters.len(),
- 63,
- "Expected exactly 63 built-in filters, got {}. \
+ 81,
+ "Expected exactly 81 built-in filters, got {}. \
Update this count when adding/removing filters in src/filters/.",
filters.len()
);
@@ -1685,11 +1685,11 @@ expected = "output line 1\noutput line 2"
let combined = format!("{}\n\n{}", BUILTIN_TOML, new_filter);
let filters = make_filters(&combined);
- // All 63 existing filters still present + 1 new = 64
+ // All 81 existing filters still present + 1 new = 82
assert_eq!(
filters.len(),
- 64,
- "Expected 64 filters after concat (63 built-in + 1 new)"
+ 82,
+ "Expected 82 filters after concat (81 built-in + 1 new)"
);
// New filter is discoverable
diff --git a/src/core/utils.rs b/src/core/utils.rs
index a3bc84fe00..9e8e018c3f 100644
--- a/src/core/utils.rs
+++ b/src/core/utils.rs
@@ -193,35 +193,16 @@ pub fn exit_code_from_output(output: &std::process::Output, label: &str) -> i32
match output.status.code() {
Some(code) => code,
None => {
- #[cfg(unix)]
- {
- use std::os::unix::process::ExitStatusExt;
- if let Some(sig) = output.status.signal() {
- eprintln!("[rtk] {}: process terminated by signal {}", label, sig);
- return 128 + sig;
- }
- }
eprintln!("[rtk] {}: process terminated by signal", label);
1
}
}
}
-/// Extract exit code from an ExitStatus (for `.status()` calls, not `.output()`).
-/// Returns the actual exit code, or `128 + signal` per Unix convention when
-/// terminated by a signal. Falls back to 1 on non-Unix platforms.
pub fn exit_code_from_status(status: &std::process::ExitStatus, label: &str) -> i32 {
match status.code() {
Some(code) => code,
None => {
- #[cfg(unix)]
- {
- use std::os::unix::process::ExitStatusExt;
- if let Some(sig) = status.signal() {
- eprintln!("[rtk] {}: process terminated by signal {}", label, sig);
- return 128 + sig;
- }
- }
eprintln!("[rtk] {}: process terminated by signal", label);
1
}
diff --git a/src/filters/compare-object.toml b/src/filters/compare-object.toml
new file mode 100644
index 0000000000..bf50394e81
--- /dev/null
+++ b/src/filters/compare-object.toml
@@ -0,0 +1,10 @@
+[filters.compare-object]
+description = "Compact Compare-Object output"
+match_command = "(?i)^(Compare-Object|diff|compare)(\\s|$)"
+strip_ansi = true
+max_lines = 50
+
+[[tests.compare-object]]
+name = "short output passes through"
+input = "InputObject SideIndicator\n----------- -------------\nline1 =>\nline2 <="
+expected = "InputObject SideIndicator\n----------- -------------\nline1 =>\nline2 <="
diff --git a/src/filters/get-alias.toml b/src/filters/get-alias.toml
new file mode 100644
index 0000000000..84a60430ac
--- /dev/null
+++ b/src/filters/get-alias.toml
@@ -0,0 +1,10 @@
+[filters.get-alias]
+description = "Compact Get-Alias output — limit rows"
+match_command = "(?i)^(Get-Alias|gal)(\\s|$)"
+strip_ansi = true
+max_lines = 40
+
+[[tests.get-alias]]
+name = "short output passes through"
+input = "CommandType Name Version Source\n----------- ---- ------- ------\nAlias % -> ForEach-Object\nAlias ? -> Where-Object\nAlias cat -> Get-Content"
+expected = "CommandType Name Version Source\n----------- ---- ------- ------\nAlias % -> ForEach-Object\nAlias ? -> Where-Object\nAlias cat -> Get-Content"
diff --git a/src/filters/get-childitem.toml b/src/filters/get-childitem.toml
new file mode 100644
index 0000000000..db0fa6c738
--- /dev/null
+++ b/src/filters/get-childitem.toml
@@ -0,0 +1,11 @@
+[filters.get-childitem]
+description = "Compact Get-ChildItem output — truncate wide lines, limit rows"
+match_command = "(?i)^(Get-ChildItem|gci|dir|ls)(\\s|$)"
+strip_ansi = true
+truncate_lines_at = 120
+max_lines = 50
+
+[[tests.get-childitem]]
+name = "short output passes through"
+input = "Mode LastWriteTime Length Name\n----- ------------- ------ ----\n-a--- 1/1/2024 12:00 1234 main.rs"
+expected = "Mode LastWriteTime Length Name\n----- ------------- ------ ----\n-a--- 1/1/2024 12:00 1234 main.rs"
diff --git a/src/filters/get-command.toml b/src/filters/get-command.toml
new file mode 100644
index 0000000000..1cb32e7588
--- /dev/null
+++ b/src/filters/get-command.toml
@@ -0,0 +1,10 @@
+[filters.get-command]
+description = "Compact Get-Command output — limit rows"
+match_command = "(?i)^(Get-Command|gcm)(\\s|$)"
+strip_ansi = true
+max_lines = 40
+
+[[tests.get-command]]
+name = "short output passes through"
+input = "CommandType Name Version Source\n----------- ---- ------- ------\nCmdlet Get-ChildItem 7.0.0 Microsoft.PowerShell.Management"
+expected = "CommandType Name Version Source\n----------- ---- ------- ------\nCmdlet Get-ChildItem 7.0.0 Microsoft.PowerShell.Management"
diff --git a/src/filters/get-content.toml b/src/filters/get-content.toml
new file mode 100644
index 0000000000..9d45e27b5f
--- /dev/null
+++ b/src/filters/get-content.toml
@@ -0,0 +1,10 @@
+[filters.get-content]
+description = "Compact Get-Content output — limit rows"
+match_command = "(?i)^(Get-Content|gc|cat|type)(\\s|$)"
+strip_ansi = true
+max_lines = 100
+
+[[tests.get-content]]
+name = "short output passes through"
+input = "line1\nline2\nline3"
+expected = "line1\nline2\nline3"
diff --git a/src/filters/get-date.toml b/src/filters/get-date.toml
new file mode 100644
index 0000000000..7247b4367f
--- /dev/null
+++ b/src/filters/get-date.toml
@@ -0,0 +1,10 @@
+[filters.get-date]
+description = "Compact Get-Date output — single-line format"
+match_command = "(?i)^(Get-Date|gdate|date)(\\s|$)"
+strip_ansi = true
+max_lines = 5
+
+[[tests.get-date]]
+name = "short output passes through"
+input = "Monday, June 29, 2026 10:30:00 AM"
+expected = "Monday, June 29, 2026 10:30:00 AM"
diff --git a/src/filters/get-help.toml b/src/filters/get-help.toml
new file mode 100644
index 0000000000..383e330b1e
--- /dev/null
+++ b/src/filters/get-help.toml
@@ -0,0 +1,11 @@
+[filters.get-help]
+description = "Compact Get-Help output — limit rows"
+match_command = "(?i)^(Get-Help|help|man)(\\s|$)"
+strip_ansi = true
+strip_lines_matching = ["^\\s*$"]
+max_lines = 40
+
+[[tests.get-help]]
+name = "short output passes through"
+input = "NAME\n Get-ChildItem\n\nSYNOPSIS\n Gets items in a directory."
+expected = "NAME\n Get-ChildItem\n\nSYNOPSIS\n Gets items in a directory."
diff --git a/src/filters/get-item.toml b/src/filters/get-item.toml
new file mode 100644
index 0000000000..5396965197
--- /dev/null
+++ b/src/filters/get-item.toml
@@ -0,0 +1,10 @@
+[filters.get-item]
+description = "Compact Get-Item output — limit rows"
+match_command = "(?i)^(Get-Item|gi)(\\s|$)"
+strip_ansi = true
+max_lines = 30
+
+[[tests.get-item]]
+name = "short output passes through"
+input = "Directory: C:\\Users\\test\n\nMode LastWriteTime Length Name\n---- ------------- ------ ----\n-a--- 1/1/2024 12:00 100 file.txt"
+expected = "Directory: C:\\Users\\test\n\nMode LastWriteTime Length Name\n---- ------------- ------ ----\n-a--- 1/1/2024 12:00 100 file.txt"
diff --git a/src/filters/get-member.toml b/src/filters/get-member.toml
new file mode 100644
index 0000000000..e9bb9656ae
--- /dev/null
+++ b/src/filters/get-member.toml
@@ -0,0 +1,11 @@
+[filters.get-member]
+description = "Compact Get-Member output — limit rows"
+match_command = "(?i)^(Get-Member|gm)(\\s|$)"
+strip_ansi = true
+truncate_lines_at = 100
+max_lines = 30
+
+[[tests.get-member]]
+name = "short output passes through"
+input = "TypeName: System.String\nName MemberType Definition\n---- ---------- ----------\nLength Property int Length {get;}"
+expected = "TypeName: System.String\nName MemberType Definition\n---- ---------- ----------\nLength Property int Length {get;}"
diff --git a/src/filters/get-process.toml b/src/filters/get-process.toml
new file mode 100644
index 0000000000..c32e56dd97
--- /dev/null
+++ b/src/filters/get-process.toml
@@ -0,0 +1,11 @@
+[filters.get-process]
+description = "Compact Get-Process output — limit rows"
+match_command = "(?i)^(Get-Process|gps|ps)(\\s|$)"
+strip_ansi = true
+truncate_lines_at = 120
+max_lines = 30
+
+[[tests.get-process]]
+name = "short output passes through"
+input = "Handles NPM(K) PM(K) WS(K) CPU(s) Id ProcessName\n------- ------ ----- ----- ------ -- -----------\n 123 15 12345 67890 1.23 4567 powershell"
+expected = "Handles NPM(K) PM(K) WS(K) CPU(s) Id ProcessName\n------- ------ ----- ----- ------ -- -----------\n 123 15 12345 67890 1.23 4567 powershell"
diff --git a/src/filters/get-service.toml b/src/filters/get-service.toml
new file mode 100644
index 0000000000..df72c8b5e8
--- /dev/null
+++ b/src/filters/get-service.toml
@@ -0,0 +1,10 @@
+[filters.get-service]
+description = "Compact Get-Service output — limit rows"
+match_command = "(?i)^(Get-Service|gsv|sc query)(\\s|$)"
+strip_ansi = true
+max_lines = 30
+
+[[tests.get-service]]
+name = "short output passes through"
+input = "Status Name DisplayName\n------ ---- -----------\nRunning BITS Background Intelligent Transfer"
+expected = "Status Name DisplayName\n------ ---- -----------\nRunning BITS Background Intelligent Transfer"
diff --git a/src/filters/invoke-command.toml b/src/filters/invoke-command.toml
new file mode 100644
index 0000000000..5ca9f774a0
--- /dev/null
+++ b/src/filters/invoke-command.toml
@@ -0,0 +1,11 @@
+[filters.invoke-command]
+description = "Compact Invoke-Command output — limit rows"
+match_command = "(?i)^(Invoke-Command|icm)(\\s|$)"
+strip_ansi = true
+truncate_lines_at = 150
+max_lines = 50
+
+[[tests.invoke-command]]
+name = "short output passes through"
+input = "PSComputerName RunspaceId Value\n-------------- ---------- -----\nlocalhost xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 42"
+expected = "PSComputerName RunspaceId Value\n-------------- ---------- -----\nlocalhost xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 42"
diff --git a/src/filters/measure-object.toml b/src/filters/measure-object.toml
new file mode 100644
index 0000000000..4177dee0c2
--- /dev/null
+++ b/src/filters/measure-object.toml
@@ -0,0 +1,11 @@
+[filters.measure-object]
+description = "Compact Measure-Object output"
+match_command = "(?i)^(Measure-Object|measure)(\\s|$)"
+strip_ansi = true
+strip_lines_matching = ["^\\s*$"]
+max_lines = 20
+
+[[tests.measure-object]]
+name = "short output passes through"
+input = "Count: 42"
+expected = "Count: 42"
diff --git a/src/filters/select-string.toml b/src/filters/select-string.toml
new file mode 100644
index 0000000000..605ea33fa8
--- /dev/null
+++ b/src/filters/select-string.toml
@@ -0,0 +1,11 @@
+[filters.select-string]
+description = "Compact Select-String output — truncate lines, limit results"
+match_command = "(?i)^(Select-String|sls)(\\s|$)"
+strip_ansi = true
+truncate_lines_at = 150
+max_lines = 50
+
+[[tests.select-string]]
+name = "short output passes through"
+input = "main.rs:10:fn main() {\nlib.rs:5:pub fn foo() {"
+expected = "main.rs:10:fn main() {\nlib.rs:5:pub fn foo() {"
diff --git a/src/filters/systeminfo.toml b/src/filters/systeminfo.toml
new file mode 100644
index 0000000000..e5399d3467
--- /dev/null
+++ b/src/filters/systeminfo.toml
@@ -0,0 +1,11 @@
+[filters.systeminfo]
+description = "Compact systeminfo — strip empty lines, limit to key fields"
+match_command = "^systeminfo(\\s|$)"
+strip_ansi = true
+strip_lines_matching = ["^\\s*$"]
+max_lines = 30
+
+[[tests.systeminfo]]
+name = "strips blank lines, limits to 30"
+input = "Host Name: DESKTOP\nOS Name: Windows 10\n\nOS Version: 10.0.19045\n\nSystem Directory: C:\\Windows\n\n"
+expected = "Host Name: DESKTOP\nOS Name: Windows 10\nOS Version: 10.0.19045\nSystem Directory: C:\\Windows"
diff --git a/src/filters/tasklist.toml b/src/filters/tasklist.toml
new file mode 100644
index 0000000000..5530080e2f
--- /dev/null
+++ b/src/filters/tasklist.toml
@@ -0,0 +1,11 @@
+[filters.tasklist]
+description = "Compact tasklist output — limit rows, truncate lines"
+match_command = "^tasklist(\\s|$)"
+strip_ansi = true
+max_lines = 40
+truncate_lines_at = 80
+
+[[tests.tasklist]]
+name = "limits to 40 lines"
+input = "Image Name PID Session Name Session# Mem Usage\n======= === ============ ======== =========\nSystem 4 Services 0 8 K\ncmd.exe 100 Console 1 5,000 K\nnotepad.exe 200 Console 1 12,000 K\n"
+expected = "Image Name PID Session Name Session# Mem Usage\n======= === ============ ======== =========\nSystem 4 Services 0 8 K\ncmd.exe 100 Console 1 5,000 K\nnotepad.exe 200 Console 1 12,000 K\n"
diff --git a/src/filters/where-object.toml b/src/filters/where-object.toml
new file mode 100644
index 0000000000..41500fecff
--- /dev/null
+++ b/src/filters/where-object.toml
@@ -0,0 +1,11 @@
+[filters.where-object]
+description = "Compact Where-Object output — limit rows"
+match_command = "(?i)^(Where-Object|where|\\?\\s*\\{)(\\s|$)"
+strip_ansi = true
+truncate_lines_at = 120
+max_lines = 30
+
+[[tests.where-object]]
+name = "short output passes through"
+input = "Name Status\n---- ------\nBITS Running"
+expected = "Name Status\n---- ------\nBITS Running"
diff --git a/src/filters/winget.toml b/src/filters/winget.toml
new file mode 100644
index 0000000000..87025c7199
--- /dev/null
+++ b/src/filters/winget.toml
@@ -0,0 +1,40 @@
+[filters.winget]
+description = "Compact winget output — strip headers, truncate rows, limit"
+match_command = "^winget(\\s|$)"
+strip_ansi = true
+truncate_lines_at = 100
+max_lines = 40
+strip_lines_matching = [
+ "^\\s*$",
+ "^\\s*-+\\s*$",
+ "^\\s*-\\s+",
+ "^\\s*\\.+\\s*$",
+ "^-+File\\s+-+",
+ "^\\s*[│├└].*",
+ "^Found multiple",
+ "^\\s*\\|\\s*$",
+]
+replace = [
+ { pattern = "\\s{3,}", replacement = " " },
+ { pattern = "ARP\\\\Machine\\\\X\\d+\\\\[A-F0-9]+", replacement = "ARP" },
+]
+
+[[tests.winget]]
+name = "install output stripped of spinner garbage"
+input = "Name Id Version Available\n---- -- ------- ---------\n7zip 7zip.7zip 24.08 24.09\n ████████░░░░░░ Downloading\n ████████████████ Verifying\nSuccessfully installed"
+expected = "Name Id Version Available\n---- -- ------- ---------\n7zip 7zip.7zip 24.08 24.09\nSuccessfully installed"
+
+[[tests.winget]]
+name = "list output tables compacted"
+input = "Name Id Version Available Source\n---- -- ------- --------- ------\n7zip 7zip.7zip 24.08 24.09 winget\nBrave Brave.Brave 1.75 1.76 winget"
+expected = "Name Id Version Available Source\n---- -- ------- --------- ------\n7zip 7zip.7zip 24.08 24.09 winget\nBrave Brave.Brave 1.75 1.76 winget"
+
+[[tests.winget]]
+name = "strips leading dash noise"
+input = " -\n\n -\n\nName Id Version Available\n---- -- ------- ---------\n7zip 7zip.7zip 24.08 24.09"
+expected = "Name Id Version Available\n---- -- ------- ---------\n7zip 7zip.7zip 24.08 24.09"
+
+[[tests.winget]]
+name = "compresses multi-spaces"
+input = "Name Id Version\n---- -- -------\n7zip 7zip.7zip 24.08"
+expected = "Name Id Version\n---- -- -------\n7zip 7zip.7zip 24.08"
diff --git a/src/hooks/constants.rs b/src/hooks/constants.rs
index 23d2e10899..dc6e27994b 100644
--- a/src/hooks/constants.rs
+++ b/src/hooks/constants.rs
@@ -1,5 +1,5 @@
pub const REWRITE_HOOK_FILE: &str = "rtk-rewrite.sh";
-pub const GEMINI_HOOK_FILE: &str = "rtk-hook-gemini.sh";
+pub const GEMINI_HOOK_FILE: &str = "rtk-hook-gemini.ps1";
pub const CLAUDE_DIR: &str = ".claude";
pub const HOOKS_SUBDIR: &str = "hooks";
pub const SETTINGS_JSON: &str = "settings.json";
diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs
index 8e6cbc8195..953080fdfa 100644
--- a/src/hooks/hook_cmd.rs
+++ b/src/hooks/hook_cmd.rs
@@ -487,10 +487,6 @@ 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, "");
@@ -511,15 +507,6 @@ 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, &[], &[], &[])
@@ -550,7 +537,6 @@ 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(),
}
}
@@ -1060,11 +1046,8 @@ mod tests {
}
#[test]
- 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");
+ fn test_cursor_no_allow_rule_defers() {
+ assert_eq!(run_cursor_inner(&cursor_input("git status")), "{}");
}
#[test]
@@ -1080,15 +1063,14 @@ mod tests {
}
#[test]
- fn test_cursor_unallowed_segment_asks() {
+ fn test_cursor_unallowed_segment_defers() {
let out = run_cursor_inner_with_rules(
&cursor_input("git status && rm -rf /tmp/x"),
&[],
&[],
&["git *".to_string()],
);
- let v: Value = serde_json::from_str(&out).unwrap();
- assert_eq!(v["permission"], "ask");
+ assert_eq!(out, "{}");
}
#[test]
diff --git a/src/hooks/init.rs b/src/hooks/init.rs
index 28363f4ce6..32f0be8447 100644
--- a/src/hooks/init.rs
+++ b/src/hooks/init.rs
@@ -3322,50 +3322,10 @@ fn show_claude_config() -> Result<()> {
if binary_hook_registered {
println!("[ok] Hook: {} (native binary command)", CLAUDE_HOOK_COMMAND);
} else if hook_path.exists() {
- #[cfg(unix)]
- {
- use std::os::unix::fs::PermissionsExt;
- let metadata = fs::metadata(&hook_path)?;
- let perms = metadata.permissions();
- let is_executable = perms.mode() & 0o111 != 0;
-
- let hook_content = fs::read_to_string(&hook_path)?;
- let has_guards =
- hook_content.contains("command -v rtk") && hook_content.contains("command -v jq");
- let is_thin_delegator = hook_content.contains("rtk rewrite");
- let hook_version = super::hook_check::parse_hook_version(&hook_content);
-
- if !is_executable {
- println!(
- "[warn] Hook: {} (NOT executable - run: chmod +x)",
- hook_path.display()
- );
- } else if !is_thin_delegator {
- println!(
- "[warn] Hook: {} (outdated — run `rtk init -g` to upgrade to native binary)",
- hook_path.display()
- );
- } else if is_executable && has_guards {
- println!(
- "[warn] Hook: {} (legacy script v{} — run `rtk init -g` to upgrade)",
- hook_path.display(),
- hook_version
- );
- } else {
- println!(
- "[warn] Hook: {} (no guards - outdated)",
- hook_path.display()
- );
- }
- }
-
- #[cfg(not(unix))]
- {
- println!(
- "[warn] Hook: {} (legacy script — run `rtk init -g` to upgrade)",
- hook_path.display()
- );
- }
+ println!(
+ "[warn] Hook: {} (legacy script — run `rtk init -g` to upgrade)",
+ hook_path.display()
+ );
} else {
println!("[--] Hook: not found");
}
@@ -3480,31 +3440,7 @@ fn show_claude_config() -> Result<()> {
if cursor_binary_registered {
println!("[ok] Cursor hook: registered in hooks.json");
} else if cursor_hook.exists() {
- #[cfg(unix)]
- {
- use std::os::unix::fs::PermissionsExt;
- let meta = fs::metadata(&cursor_hook)?;
- let is_executable = meta.permissions().mode() & 0o111 != 0;
- let content = fs::read_to_string(&cursor_hook)?;
- let _is_thin = content.contains("rtk rewrite");
-
- if !is_executable {
- println!(
- "[warn] Cursor hook: {} (legacy script, NOT executable)",
- cursor_hook.display()
- );
- } else {
- println!(
- "[warn] Cursor hook: {} (legacy script — run `rtk init -g --agent cursor` to upgrade)",
- cursor_hook.display()
- );
- }
- }
-
- #[cfg(not(unix))]
- {
- println!("[warn] Cursor hook: {} (legacy script — run `rtk init -g --agent cursor` to upgrade)", cursor_hook.display());
- }
+ println!("[warn] Cursor hook: {} (legacy script — run `rtk init -g --agent cursor` to upgrade)", cursor_hook.display());
} else {
println!("[--] Cursor hook: not found");
}
@@ -3599,8 +3535,7 @@ fn run_opencode_only_mode(ctx: InitContext) -> Result<()> {
// ─── Gemini CLI support ───────────────────────────────────────────
/// Gemini hook wrapper script — delegates to `rtk hook gemini`
-const GEMINI_HOOK_SCRIPT: &str = r#"#!/bin/bash
-exec rtk hook gemini
+const GEMINI_HOOK_SCRIPT: &str = r#"rtk hook gemini
"#;
fn resolve_gemini_dir() -> Result {
@@ -3638,13 +3573,6 @@ pub fn run_gemini(
let hook_path = hook_dir.join(GEMINI_HOOK_FILE);
write_if_changed(&hook_path, GEMINI_HOOK_SCRIPT, "Gemini hook", ctx)?;
- #[cfg(unix)]
- if !dry_run {
- use std::os::unix::fs::PermissionsExt;
- fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755))
- .with_context(|| format!("Failed to set hook permissions: {}", hook_path.display()))?;
- }
-
// Store integrity baseline for tamper detection (skip in dry-run)
if !dry_run {
integrity::store_hash(&hook_path).with_context(|| {
@@ -5412,48 +5340,6 @@ mod tests {
assert_eq!(written, content);
}
- #[cfg(unix)]
- #[test]
- fn test_atomic_write_preserves_symlink() {
- use std::os::unix::fs::symlink;
-
- let temp = TempDir::new().unwrap();
- let target_path = temp.path().join("real-settings.json");
- let link_path = temp.path().join("settings.json");
-
- fs::write(&target_path, "{}").expect("seed target file");
- symlink(&target_path, &link_path).expect("create symlink");
-
- atomic_write(&link_path, "{\"hooks\":{}}").unwrap();
-
- let meta = fs::symlink_metadata(&link_path).unwrap();
- assert!(meta.file_type().is_symlink(), "symlink must survive");
- let written = fs::read_to_string(&target_path).unwrap();
- assert_eq!(written, "{\"hooks\":{}}");
- }
-
- #[cfg(unix)]
- #[test]
- fn test_atomic_write_preserves_relative_symlink() {
- use std::os::unix::fs::symlink;
-
- let temp = TempDir::new().unwrap();
- let subdir = temp.path().join("real");
- fs::create_dir(&subdir).unwrap();
- let target_path = subdir.join("settings.json");
- let link_path = temp.path().join("settings.json");
-
- fs::write(&target_path, "{}").expect("seed target file");
- symlink(Path::new("real/settings.json"), &link_path).expect("create relative symlink");
-
- atomic_write(&link_path, "{\"patched\":true}").unwrap();
-
- let meta = fs::symlink_metadata(&link_path).unwrap();
- assert!(meta.file_type().is_symlink(), "symlink must survive");
- let written = fs::read_to_string(&target_path).unwrap();
- assert_eq!(written, "{\"patched\":true}");
- }
-
// Test for preserve_order round-trip
#[test]
fn test_preserve_order_round_trip() {
diff --git a/src/hooks/integrity.rs b/src/hooks/integrity.rs
index b2d88e51f1..7ef7036314 100644
--- a/src/hooks/integrity.rs
+++ b/src/hooks/integrity.rs
@@ -85,24 +85,9 @@ pub fn store_hash(hook_path: &Path) -> Result<()> {
let content = format!("{} {}\n", hash, filename);
- // If hash file exists and is read-only, make it writable first
- #[cfg(unix)]
- if hash_file.exists() {
- use std::os::unix::fs::PermissionsExt;
- let _ = fs::set_permissions(&hash_file, fs::Permissions::from_mode(0o644));
- }
-
fs::write(&hash_file, &content)
.with_context(|| format!("Failed to write hash to {}", hash_file.display()))?;
- // Set read-only
- #[cfg(unix)]
- {
- use std::os::unix::fs::PermissionsExt;
- fs::set_permissions(&hash_file, fs::Permissions::from_mode(0o444))
- .with_context(|| format!("Failed to set permissions on {}", hash_file.display()))?;
- }
-
Ok(())
}
@@ -114,13 +99,6 @@ pub fn remove_hash(hook_path: &Path) -> Result {
return Ok(false);
}
- // Make writable before removing
- #[cfg(unix)]
- {
- use std::os::unix::fs::PermissionsExt;
- let _ = fs::set_permissions(&hash_file, fs::Permissions::from_mode(0o644));
- }
-
fs::remove_file(&hash_file)
.with_context(|| format!("Failed to remove hash file: {}", hash_file.display()))?;
@@ -467,10 +445,7 @@ mod tests {
}
#[test]
- #[cfg(unix)]
- fn test_hash_file_permissions() {
- use std::os::unix::fs::PermissionsExt;
-
+ fn test_hash_file_created() {
let temp = TempDir::new().unwrap();
let hook = temp.path().join("rtk-rewrite.sh");
fs::write(&hook, "test").unwrap();
@@ -478,8 +453,7 @@ mod tests {
store_hash(&hook).unwrap();
let hash_file = temp.path().join(".rtk-hook.sha256");
- let perms = fs::metadata(&hash_file).unwrap().permissions();
- assert_eq!(perms.mode() & 0o777, 0o444, "Hash file should be read-only");
+ assert!(hash_file.exists(), "Hash file should be created");
}
#[test]
diff --git a/src/main.rs b/src/main.rs
index d01e18b866..3c4134e649 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1424,17 +1424,6 @@ fn validate_pnpm_filters(filters: &[String], command: &PnpmCommands) -> Option code,
Err(e) => {
@@ -2331,7 +2320,7 @@ fn run_cli() -> Result {
.arg(&raw)
.status()
.with_context(|| format!("Failed to execute: {}", raw))?;
- core::utils::exit_code_from_status(&status, "run")
+ status.code().unwrap_or(1)
}
}
@@ -2380,31 +2369,6 @@ fn run_cli() -> Result {
// PID stored in this atomic.
static PROXY_CHILD_PID: AtomicU32 = AtomicU32::new(0);
- #[cfg(unix)]
- #[allow(unsafe_code)]
- {
- unsafe extern "C" fn handle_signal(sig: libc::c_int) {
- let pid = PROXY_CHILD_PID.load(Ordering::SeqCst);
- if pid != 0 {
- libc::kill(pid as libc::pid_t, libc::SIGTERM);
- libc::waitpid(pid as libc::pid_t, std::ptr::null_mut(), 0);
- }
- libc::signal(sig, libc::SIG_DFL);
- libc::raise(sig);
- }
- // nosemgrep: unsafe-block
- unsafe {
- libc::signal(
- libc::SIGINT,
- handle_signal as *const () as libc::sighandler_t,
- );
- libc::signal(
- libc::SIGTERM,
- handle_signal as *const () as libc::sighandler_t,
- );
- }
- }
-
struct ChildGuard(Option);
impl Drop for ChildGuard {
fn drop(&mut self) {