diff --git a/.claude/agents/rust-rtk.md b/.claude/agents/rust-rtk.md index 5adca48b91..4e86206e37 100644 --- a/.claude/agents/rust-rtk.md +++ b/.claude/agents/rust-rtk.md @@ -397,7 +397,7 @@ docker run --rm -v $(pwd):/rtk -w /rtk rust:latest cargo test # Linux via Docke ✅ **DO** compile regex once with `lazy_static!` ✅ **DO** verify token savings claims in tests (≥60%) ✅ **DO** test on macOS + Linux + Windows (via CI or manual) -✅ **DO** run `cargo fmt && cargo clippy && cargo test` before commit +✅ **DO** run `cargo fmt && cargo clippy --all-targets && cargo test` before commit ✅ **DO** benchmark startup time with `hyperfine` (<10ms target) ✅ **DO** use `anyhow::Result` with `.context()` for all error propagation diff --git a/.claude/commands/tech/codereview.md b/.claude/commands/tech/codereview.md index 35e92360d5..bcc58f0443 100644 --- a/.claude/commands/tech/codereview.md +++ b/.claude/commands/tech/codereview.md @@ -230,12 +230,12 @@ Glob tests/fixtures/_raw.txt └────────┬────────┘ │ ▼ - ┌──────────────────────┐ - │ 3. Quality gate │ - │ cargo fmt --all │ - │ cargo clippy │ - │ cargo test │ - └────────┬─────────────┘ + ┌─────────────────────────────┐ + │ 3. Quality gate │ + │ cargo fmt --all │ + │ cargo clippy --all-targets │ + │ cargo test │ + └──────────────┬──────────────┘ │ Loop ←┘ (max N iterations) ``` diff --git a/.github/workflows/CICD.md b/.github/workflows/CICD.md index b20e9cff6f..ad5deb0acc 100644 --- a/.github/workflows/CICD.md +++ b/.github/workflows/CICD.md @@ -10,17 +10,16 @@ Trigger: pull_request to develop or master └────────┬─────────┘ │ ┌────────▼─────────┐ - │ fmt │ + │ fmt --all │ └────────┬─────────┘ │ - ┌────────▼─────────┐ - │ clippy │ - │ -D unsafe_code │ - └┬───┬───┬───┬───┬─┘ + ┌───────────▼──────────┐ + │ clippy --all-targets │ + └───┬───┬───┬───┬───┬──┘ │ │ │ │ │ - ┌───────────────┘ │ │ │ └───────────────┐ - │ ┌───────────┘ │ └──────────┐ │ - ▼ ▼ ▼ ▼ ▼ + ┌───────────────┘ │ │ │ └────────────────┐ + │ ┌───────────┘ │ └───────────┐ │ + ▼ ▼ ▼ ▼ ▼ ┌──────────┐ ┌──────────┐ ┌───────────┐ ┌─────────┐ ┌──────────┐ │ test │ │ security │ │ semgrep │ │benchmark│ │ doc │ │ ubuntu │ │ cargo │ │ AST-aware │ │ >=80% │ │ review │ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b56acffad2..992dea78e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,7 +46,7 @@ jobs: with: components: clippy - uses: Swatinem/rust-cache@v2 - - run: cargo clippy --all-targets -- -D unsafe_code + - run: cargo clippy --all-targets # ─── Parallel gates (all need code to compile) ─── @@ -220,7 +220,7 @@ jobs: - name: Install Go uses: actions/setup-go@v5 with: - go-version: 'stable' + go-version: "stable" - name: Install Go tools run: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest @@ -228,7 +228,6 @@ jobs: - name: Run benchmark run: ./scripts/benchmark.sh - # ─── AI Doc Review: develop PRs only ─── doc-review: diff --git a/.github/workflows/pr-target-check.yml b/.github/workflows/pr-target-check.yml index 60211f1cd9..ac3ec13666 100644 --- a/.github/workflows/pr-target-check.yml +++ b/.github/workflows/pr-target-check.yml @@ -7,14 +7,23 @@ on: jobs: check-target: runs-on: ubuntu-latest + permissions: {} # Skip develop→master PRs (maintainer releases) if: >- github.event.pull_request.base.ref == 'master' && github.event.pull_request.head.ref != 'develop' steps: - - name: Add wrong-base label - uses: actions/github-script@v7 + - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + id: app-token with: + client-id: ${{ secrets.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + permission-pull-requests: write + + - name: Add wrong-base label and comment + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + with: + github-token: ${{ steps.app-token.outputs.token }} script: | const pr = context.payload.pull_request; @@ -27,13 +36,9 @@ jobs: }); // Post comment - const body = `👋 Thanks for the PR! It looks like this targets \`master\`, but all PRs should target the **\`develop\`** branch. - - Please update the base branch: - 1. Click **Edit** at the top right of this PR - 2. Change the base branch from \`master\` to \`develop\` + const body = `Automatic message from CI checks : It seems like this branch is targeting the wrong branch, any contribution should target develop branch. - See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/master/CONTRIBUTING.md) for details.`; + See [CONTRIBUTING.md](https://github.com/rtk-ai/rtk/blob/master/CONTRIBUTING.md) for details.`; await github.rest.issues.createComment({ owner: context.repo.owner, diff --git a/Cargo.toml b/Cargo.toml index 81cc9c1df4..726a01709c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,3 +65,7 @@ assets = [ assets = [ { source = "target/release/rtk", dest = "/usr/bin/rtk", mode = "755" }, ] + +[lints.rust] +unsafe_code = "deny" +warnings = "deny" diff --git a/README.md b/README.md index 1452b1ca8a..a0db81d25c 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ WebsiteInstallTroubleshooting • - Architecture • + ArchitectureDiscord

@@ -110,12 +110,13 @@ rtk init --agent windsurf # Windsurf rtk init --agent cline # Cline / Roo Code rtk init --agent kilocode # Kilo Code rtk init --agent antigravity # Google Antigravity +rtk init --agent hermes # Hermes # 2. Restart your AI tool, then test git status # Automatically rewritten to rtk git status ``` -The hook transparently rewrites Bash commands (e.g., `git status` -> `rtk git status`) before execution. Claude never sees the rewrite, it just gets compressed output. +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. @@ -350,7 +351,7 @@ rtk git status ## Supported AI Tools -RTK supports 12 AI coding tools. Each integration transparently rewrites shell commands to `rtk` equivalents for 60-90% token savings. +RTK supports 13 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 | |------|---------|--------| @@ -364,11 +365,12 @@ RTK supports 12 AI coding tools. Each integration transparently rewrites shell c | **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) | +| **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). +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 @@ -404,7 +406,7 @@ brew uninstall rtk # If installed via Homebrew - **[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](ARCHITECTURE.md)** — system design and technical decisions +- **[ARCHITECTURE.md](docs/contributing/ARCHITECTURE.md)** — system design and technical decisions - **[CONTRIBUTING.md](CONTRIBUTING.md)** — contribution guide - **[SECURITY.md](SECURITY.md)** — security policy diff --git a/docs/guide/getting-started/quick-start.md b/docs/guide/getting-started/quick-start.md index 6e1b7b558b..af4493e551 100644 --- a/docs/guide/getting-started/quick-start.md +++ b/docs/guide/getting-started/quick-start.md @@ -32,6 +32,22 @@ cd /your/project && rtk init This installs the hook that automatically rewrites commands. Restart your AI assistant after this step. +### Preview without writing: `--dry-run` + +To see exactly what `init` would change before it touches anything, add `--dry-run`: + +```bash +rtk init --global --dry-run +``` + +Every would-be file create/update/patch is printed with a `[dry-run] would ...` prefix, then a `[dry-run] Nothing written.` footer. Nothing on disk is modified, no settings.json is patched, and the telemetry consent prompt is skipped. Combine with `-v` to also print the full content RTK would write: + +```bash +rtk init --global --dry-run -v +``` + +`--dry-run` works for every init flavour (`--agent cursor`, `--gemini`, `--codex`, `--copilot`, `--uninstall`, ...). It cannot be combined with `--show`. + ## Step 2: Use your tools normally Once the hook is installed, nothing changes in how you work. Your AI assistant runs commands as usual — the hook intercepts them transparently and rewrites them before execution. diff --git a/docs/guide/getting-started/supported-agents.md b/docs/guide/getting-started/supported-agents.md index 4623353d5e..561f9de151 100644 --- a/docs/guide/getting-started/supported-agents.md +++ b/docs/guide/getting-started/supported-agents.md @@ -1,6 +1,6 @@ --- title: Supported Agents -description: How to integrate RTK with Claude Code, Cursor, Copilot, Cline, Windsurf, Codex, OpenCode, Kilo Code, and Antigravity +description: How to integrate RTK with Claude Code, Cursor, Copilot, Cline, Windsurf, Codex, OpenCode, Hermes, Kilo Code, and Antigravity sidebar: order: 3 --- @@ -35,6 +35,7 @@ Agent runs "cargo test" | Gemini CLI | Rust binary (`BeforeTool`) | Yes | | OpenCode | TypeScript plugin (`tool.execute.before`) | Yes | | OpenClaw | TypeScript plugin (`before_tool_call`) | Yes | +| Hermes | Python plugin (`terminal` command mutation) | Yes | | Cline / Roo Code | Rules file (prompt-level) | N/A | | Windsurf | Rules file (prompt-level) | N/A | | Codex CLI | AGENTS.md instructions | N/A | @@ -92,6 +93,16 @@ openclaw plugins install ./openclaw Plugin in the `openclaw/` directory. Uses the `before_tool_call` hook, delegates to `rtk rewrite`. +### Hermes + +```bash +rtk init --agent hermes +``` + +Creates `~/.hermes/plugins/rtk-rewrite/` and enables it through `plugins.enabled` in the Hermes config. Hermes loads Python plugins, so the plugin entrypoint is Python, but it is only a thin adapter. It mutates the Hermes `terminal` tool `command` before execution and delegates all rewrite decisions to Rust through `rtk rewrite`. The repository source and tests for that adapter live in `hooks/hermes/`; only installed runtime files use the `~/.hermes/plugins/rtk-rewrite/` path. + +The plugin fails open. If `rtk` is missing at load time, the hook is not registered. If `rtk rewrite` errors, the tool is not `terminal`, the payload has no string `command`, or the plugin raises an exception, Hermes runs the original command unchanged. The same `rtk rewrite` limitations apply: already-prefixed `rtk` commands, compound shell commands, heredocs, and commands without filters are not rewritten. + ### Cline / Roo Code ```bash @@ -137,7 +148,7 @@ Support is blocked on upstream `BeforeToolCallback` ([mistral-vibe#531](https:// | Tier | Mechanism | How rewrites work | |------|-----------|------------------| | **Full hook** | Shell script or Rust binary, intercepts via agent API | Transparent — agent never sees the raw command | -| **Plugin** | TypeScript/JS in agent's plugin system | Transparent — in-place mutation | +| **Plugin** | TypeScript, JavaScript, or Python in agent's plugin system | Transparent, in-place mutation when the agent allows it | | **Rules file** | Prompt-level instructions | Guidance only — agent is told to prefer `rtk ` | Rules file integrations (Cline, Windsurf, Codex, Kilo Code, Antigravity) rely on the model following instructions. Full hook integrations (Claude Code, Cursor, Gemini) are guaranteed — the command is rewritten before the agent sees it. diff --git a/hooks/README.md b/hooks/README.md index 6a6744281d..0879de9bbb 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -4,7 +4,7 @@ **Deployed hook artifacts** — the actual files installed on user machines by `rtk init`. These are shell scripts, TypeScript plugins, and rules files that run outside the Rust binary. They are **thin delegates**: parse agent-specific JSON, call `rtk rewrite` as a subprocess, format agent-specific response. Zero filtering logic lives here. -Owns: per-agent hook scripts and configuration files for 7 supported agents (Claude Code, Copilot, Cursor, Cline, Windsurf, Codex, OpenCode). +Owns: per-agent hook scripts and configuration files for 8 supported agents (Claude Code, Copilot, Cursor, Cline, Windsurf, Codex, OpenCode, Hermes). Does **not** own: hook installation/uninstallation (that's `src/hooks/init.rs`), the rewrite pattern registry (that's `discover/registry`), or integrity verification (that's `src/hooks/integrity.rs`). @@ -40,6 +40,7 @@ Each agent subdirectory has its own README with hook-specific details: - **[`windsurf/`](windsurf/README.md)** — Rules file (prompt-level), `.windsurfrules` workspace-scoped - **[`codex/`](codex/README.md)** — Awareness document, `AGENTS.md` integration, `$CODEX_HOME` or `~/.codex/` location - **[`opencode/`](opencode/README.md)** — TypeScript plugin, `zx` library, `tool.execute.before` event, in-place mutation +- **[`hermes/`](hermes/README.md)** — Python plugin, `pre_tool_call` hook, in-place terminal command mutation ## Supported Agents @@ -54,6 +55,7 @@ Each agent subdirectory has its own README with hook-specific details: | Windsurf | Custom instructions (rules file) | Prompt-level guidance | N/A | | Codex CLI | AGENTS.md / instructions | Prompt-level guidance | N/A | | OpenCode | TypeScript plugin (`tool.execute.before`) | In-place mutation | Yes | +| Hermes | Python plugin (`pre_tool_call`) | In-place mutation | Yes | ## JSON Formats by Agent @@ -156,6 +158,17 @@ if (rewritten && rewritten !== command) { } ``` +### Hermes (Python Plugin) + +Mutates `args["command"]` in-place via the `pre_tool_call` hook: + +```python +result = subprocess.run(["rtk", "rewrite", command], capture_output=True, text=True, timeout=2) +rewritten = result.stdout.strip() +if result.returncode in {0, 3} and rewritten and rewritten != command: + args["command"] = rewritten +``` + ## Command Rewrite Registry The registry (`src/discover/registry.rs`) handles command patterns across these categories: @@ -217,7 +230,7 @@ New integrations must follow the [Exit Code Contract](#exit-code-contract) and [ | Tier | Mechanism | Maintenance | Examples | |------|-----------|-------------|----------| | **Full hook** | Shell script or Rust binary, intercepts commands via agent's hook API | High — must track agent API changes | Claude Code, Cursor, Copilot, Gemini | -| **Plugin** | TypeScript/JS plugin in agent's plugin system | Medium — agent manages loading | OpenCode | +| **Plugin** | TypeScript/JS/Python plugin in agent's plugin system | Medium — agent manages loading | OpenCode, Hermes | | **Rules file** | Prompt-level instructions the agent reads | Low — no code to break | Cline, Windsurf, Codex | ### Eligibility @@ -232,4 +245,3 @@ RTK supports AI coding assistants that developers actually use day-to-day. To ad ### Maintenance If an agent's API changes and the hook breaks, the integration should be updated promptly. If the agent becomes unmaintained or the hook can't be fixed, the integration may be deprecated with a release note. - diff --git a/hooks/hermes/README.md b/hooks/hermes/README.md new file mode 100644 index 0000000000..2a755c7106 --- /dev/null +++ b/hooks/hermes/README.md @@ -0,0 +1,43 @@ +# RTK Plugin for Hermes + +Rewrites Hermes `terminal` tool commands to RTK equivalents before execution, so Hermes receives compact command output without changing your workflow. + +## Installation + +```bash +rtk init --agent hermes +``` + +The installer writes the plugin to `~/.hermes/plugins/rtk-rewrite/` and enables it through `plugins.enabled` in the Hermes config. The repository copy lives in `hooks/hermes/`; don't use that repo path as the runtime install path. + +## Development + +Run the Hermes plugin tests from the repository root: + +```bash +python3 -m unittest discover -s hooks/hermes +``` + +## How it works + +Hermes loads plugins from Python, so the plugin entrypoint is Python. The Python code is only a thin Hermes adapter. It reads the Hermes terminal tool payload, calls `rtk rewrite` for the actual command decision, then mutates the terminal tool `command` before Hermes executes it. + +All rewrite rules stay in Rust inside `rtk rewrite`. When RTK adds or changes command rewrite behavior, the Hermes plugin picks up that behavior by delegating to the RTK binary. + +## Fail-open behavior + +The plugin does not block command execution. If anything goes wrong, Hermes runs the original command unchanged. + +If rtk is not available in PATH when Hermes loads the plugin, the plugin prints a warning and skips hook registration. + +- `rtk` is missing from `PATH` +- `rtk rewrite` exits with an error +- Hermes sends a non-terminal tool call +- The tool payload has no string `command` +- The plugin raises an unexpected exception + +## Limitations + +- Only Hermes `terminal` tool calls are rewritten. +- Commands skipped by `rtk rewrite` stay unchanged, including commands already prefixed with `rtk`, compound shell commands, heredocs, and commands without an RTK filter. +- Shell hooks are not used for Hermes command rewriting. The integration depends on Hermes loading Python plugins and passing a mutable terminal tool payload. diff --git a/hooks/hermes/rtk-rewrite/__init__.py b/hooks/hermes/rtk-rewrite/__init__.py new file mode 100644 index 0000000000..6dcf44e832 --- /dev/null +++ b/hooks/hermes/rtk-rewrite/__init__.py @@ -0,0 +1,80 @@ +"""Hermes plugin adapter for RTK command rewriting. + +All rewrite logic lives in RTK's Rust ``rtk rewrite`` command; this module +only bridges Hermes ``pre_tool_call`` payloads to that command and fails open. +""" + +import shutil +import subprocess +import sys + + +ACCEPTED_REWRITE_RETURN_CODES = {0, 3} +EXPECTED_PASSTHROUGH_RETURN_CODES = {1, 2} +_rtk_available = None +_rtk_missing_warned = False + + +def register(ctx): + """Register the Hermes pre-tool callback.""" + if not _check_rtk(): + return + + ctx.register_hook("pre_tool_call", _pre_tool_call) + + +def _check_rtk(): + """Return whether the rtk binary is in PATH, warning once when missing.""" + global _rtk_available, _rtk_missing_warned + + if _rtk_available is None: + _rtk_available = shutil.which("rtk") is not None + + if not _rtk_available and not _rtk_missing_warned: + _warn("rtk binary not found in PATH; Hermes hook not registered") + _rtk_missing_warned = True + + return _rtk_available + + +def _pre_tool_call(tool_name=None, args=None, **_kwargs): + """Rewrite mutable Hermes terminal command args when RTK provides a change.""" + try: + if tool_name != "terminal" or not isinstance(args, dict): + return + + command = args.get("command") + if not isinstance(command, str) or not command.strip(): + return + + try: + result = subprocess.run( + ["rtk", "rewrite", command], + shell=False, + timeout=2, + capture_output=True, + text=True, + ) + except subprocess.TimeoutExpired: + _warn("rtk rewrite timed out") + return + + if result.returncode not in ACCEPTED_REWRITE_RETURN_CODES: + if result.returncode not in EXPECTED_PASSTHROUGH_RETURN_CODES: + details = f"rtk rewrite failed with exit {result.returncode}" + stderr = result.stderr.strip() + if stderr: + details = f"{details}: {stderr}" + _warn(details) + return + + rewritten = result.stdout.strip() + if rewritten and rewritten != command: + args["command"] = rewritten + except Exception as e: + _warn(str(e)) + return + + +def _warn(message): + print(f"rtk: hermes plugin warning: {message}", file=sys.stderr) diff --git a/hooks/hermes/rtk-rewrite/plugin.yaml b/hooks/hermes/rtk-rewrite/plugin.yaml new file mode 100644 index 0000000000..7a08e40b54 --- /dev/null +++ b/hooks/hermes/rtk-rewrite/plugin.yaml @@ -0,0 +1,8 @@ +name: rtk-rewrite +version: "0.1.0" +description: Rewrite Hermes terminal commands through RTK before execution. +author: RTK Contributors +hooks: + - pre_tool_call +provides_hooks: + - pre_tool_call diff --git a/hooks/hermes/tests/__init__.py b/hooks/hermes/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/hooks/hermes/tests/test_rtk_rewrite_plugin.py b/hooks/hermes/tests/test_rtk_rewrite_plugin.py new file mode 100644 index 0000000000..1434262466 --- /dev/null +++ b/hooks/hermes/tests/test_rtk_rewrite_plugin.py @@ -0,0 +1,352 @@ +import io +import importlib.util +import os +import shutil +import stat +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +PLUGIN_PATH = Path(__file__).resolve().parents[1] / "rtk-rewrite" / "__init__.py" + + +class FakeContext: + def __init__(self): + self.hooks = {} + + def register_hook(self, hook_name, callback): + self.hooks[hook_name] = callback + + +class FakeCompletedProcess: + def __init__(self, returncode=0, stdout="", stderr=""): + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + +def load_plugin_module(path=PLUGIN_PATH, module_name="rtk_rewrite_plugin"): + spec = importlib.util.spec_from_file_location(module_name, path) + if spec is None or spec.loader is None: + raise ImportError(f"Unable to load Hermes plugin from {path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def write_fake_rtk(bin_dir): + fake_rtk = bin_dir / "rtk" + fake_rtk.write_text( + "\n".join( + [ + f"#!{sys.executable}", + "import sys", + "if sys.argv[1:] == ['rewrite', 'git status']:", + " print('rtk git status')", + " raise SystemExit(0)", + "print('unexpected rtk args:', sys.argv[1:], file=sys.stderr)", + "raise SystemExit(1)", + "", + ] + ) + ) + fake_rtk.chmod(fake_rtk.stat().st_mode | stat.S_IXUSR) + return fake_rtk + + +class RtkRewritePluginTest(unittest.TestCase): + def load_callback(self): + module = load_plugin_module() + module._rtk_available = None + module._rtk_missing_warned = False + ctx = FakeContext() + + with mock.patch.object(module.shutil, "which", return_value="/usr/bin/rtk"): + module.register(ctx) + + self.assertIn("pre_tool_call", ctx.hooks) + return module, ctx.hooks["pre_tool_call"] + + def test_missing_rtk_skips_registering_pre_tool_call(self): + module = load_plugin_module() + module._rtk_available = None + module._rtk_missing_warned = False + ctx = FakeContext() + + with mock.patch.object(module.shutil, "which", return_value=None): + with mock.patch.object(module.sys, "stderr", new_callable=io.StringIO) as stderr: + module.register(ctx) + + self.assertNotIn("pre_tool_call", ctx.hooks) + self.assertEqual( + "rtk: hermes plugin warning: rtk binary not found in PATH; Hermes hook not registered\n", + stderr.getvalue(), + ) + + def test_missing_rtk_warns_only_once(self): + module = load_plugin_module() + module._rtk_available = None + module._rtk_missing_warned = False + + with mock.patch.object(module.shutil, "which", return_value=None): + with mock.patch.object(module.sys, "stderr", new_callable=io.StringIO) as stderr: + self.assertFalse(module._check_rtk()) + self.assertFalse(module._check_rtk()) + + self.assertEqual( + "rtk: hermes plugin warning: rtk binary not found in PATH; Hermes hook not registered\n", + stderr.getvalue(), + ) + + def test_check_rtk_found_is_quiet(self): + module = load_plugin_module() + module._rtk_available = None + module._rtk_missing_warned = False + + with mock.patch.object(module.shutil, "which", return_value="/usr/bin/rtk"): + with mock.patch.object(module.sys, "stderr", new_callable=io.StringIO) as stderr: + self.assertTrue(module._check_rtk()) + + self.assertEqual("", stderr.getvalue()) + + def test_check_rtk_caches_result_across_calls(self): + module = load_plugin_module() + module._rtk_available = None + module._rtk_missing_warned = False + + with mock.patch.object(module.shutil, "which", return_value="/usr/bin/rtk") as which: + self.assertTrue(module._check_rtk()) + self.assertTrue(module._check_rtk()) + + which.assert_called_once_with("rtk") + + def test_rewrite_success_mutates_same_terminal_args_dict(self): + module, callback = self.load_callback() + args = {"command": "git status"} + + with mock.patch.object( + module.subprocess, + "run", + return_value=FakeCompletedProcess(stdout="rtk git status\n"), + ): + callback(tool_name="terminal", args=args) + + self.assertEqual({"command": "rtk git status"}, args) + + def test_rewrite_returncode_three_mutates_same_terminal_args_dict(self): + module, callback = self.load_callback() + args = {"command": "git status"} + + with mock.patch.object( + module.subprocess, + "run", + return_value=FakeCompletedProcess(returncode=3, stdout="rtk git status\n"), + ): + callback(tool_name="terminal", args=args) + + self.assertEqual({"command": "rtk git status"}, args) + + def test_rewrite_returncode_zero_mutates_when_rewrite_changes_command(self): + module, callback = self.load_callback() + args = {"command": "git status"} + + with mock.patch.object( + module.subprocess, + "run", + return_value=FakeCompletedProcess(stdout="rtk git status\n"), + ): + callback(tool_name="terminal", args=args) + + self.assertEqual({"command": "rtk git status"}, args) + + def test_expected_passthrough_returncodes_do_not_warn_or_mutate(self): + for returncode in (1, 2): + with self.subTest(returncode=returncode): + module, callback = self.load_callback() + args = {"command": "git status"} + + with mock.patch.object( + module.subprocess, + "run", + return_value=FakeCompletedProcess( + returncode=returncode, + stdout="rtk git status\n", + stderr="unexpected stderr", + ), + ): + with mock.patch.object(module.sys, "stderr", new_callable=io.StringIO) as stderr: + callback(tool_name="terminal", args=args) + + self.assertEqual({"command": "git status"}, args) + self.assertEqual("", stderr.getvalue()) + + def test_unexpected_returncode_warns_with_stderr_details(self): + module, callback = self.load_callback() + args = {"command": "git status"} + + with mock.patch.object( + module.subprocess, + "run", + return_value=FakeCompletedProcess(returncode=4, stdout="rtk git status\n", stderr="bad news"), + ): + with mock.patch.object(module.sys, "stderr", new_callable=io.StringIO) as stderr: + callback(tool_name="terminal", args=args) + + self.assertEqual({"command": "git status"}, args) + self.assertEqual("rtk: hermes plugin warning: rtk rewrite failed with exit 4: bad news\n", stderr.getvalue()) + + def test_rewrite_timeout_warns_and_preserves_original_command(self): + module, callback = self.load_callback() + args = {"command": "git status"} + + timeout = subprocess.TimeoutExpired(cmd=["rtk", "rewrite", "git status"], timeout=2) + with mock.patch.object(module.subprocess, "run", side_effect=timeout): + with mock.patch.object(module.sys, "stderr", new_callable=io.StringIO) as stderr: + callback(tool_name="terminal", args=args) + + self.assertEqual({"command": "git status"}, args) + self.assertEqual("rtk: hermes plugin warning: rtk rewrite timed out\n", stderr.getvalue()) + + def test_file_not_found_preserves_original_command(self): + module, callback = self.load_callback() + args = {"command": "git status"} + + with mock.patch.object(module.subprocess, "run", side_effect=FileNotFoundError): + with mock.patch.object(module.sys, "stderr", new_callable=io.StringIO) as stderr: + callback(tool_name="terminal", args=args) + + self.assertEqual({"command": "git status"}, args) + self.assertIn("rtk: hermes plugin warning:", stderr.getvalue()) + + def test_unexpected_exception_prints_warning_and_keeps_command(self): + module, callback = self.load_callback() + args = {"command": "git status"} + + with mock.patch.object(module.subprocess, "run", side_effect=RuntimeError("boom")): + with mock.patch.object(module.sys, "stderr", new_callable=io.StringIO) as stderr: + callback(tool_name="terminal", args=args) + + self.assertEqual({"command": "git status"}, args) + self.assertEqual("rtk: hermes plugin warning: boom\n", stderr.getvalue()) + + def test_non_terminal_tool_is_noop(self): + module, callback = self.load_callback() + args = {"command": "git status"} + + with mock.patch.object(module.subprocess, "run") as run: + callback(tool_name="read_file", args=args) + + run.assert_not_called() + self.assertEqual({"command": "git status"}, args) + + def test_missing_command_is_noop(self): + module, callback = self.load_callback() + args = {} + + with mock.patch.object(module.subprocess, "run") as run: + callback(tool_name="terminal", args=args) + + run.assert_not_called() + self.assertEqual({}, args) + + def test_non_string_command_is_noop(self): + module, callback = self.load_callback() + args = {"command": ["git", "status"]} + + with mock.patch.object(module.subprocess, "run") as run: + callback(tool_name="terminal", args=args) + + run.assert_not_called() + self.assertEqual({"command": ["git", "status"]}, args) + + def test_empty_command_strings_are_noop(self): + for command in ("", " ", "\t\n"): + with self.subTest(command=command): + module, callback = self.load_callback() + args = {"command": command} + + with mock.patch.object(module.subprocess, "run") as run: + callback(tool_name="terminal", args=args) + + run.assert_not_called() + self.assertEqual({"command": command}, args) + + def test_empty_or_unchanged_rewrite_output_preserves_original_command(self): + for stdout in ("", "\n", "git status\n"): + with self.subTest(stdout=stdout): + module, callback = self.load_callback() + args = {"command": "git status"} + + with mock.patch.object( + module.subprocess, + "run", + return_value=FakeCompletedProcess(stdout=stdout), + ): + callback(tool_name="terminal", args=args) + + self.assertEqual({"command": "git status"}, args) + + +class InstalledRtkRewritePluginTest(unittest.TestCase): + @unittest.skipUnless(shutil.which("cargo"), "cargo is required for installed flow") + def test_cargo_init_installs_importable_plugin_that_rewrites_with_fake_rtk(self): + repo_root = Path(__file__).resolve().parents[3] + self.assertTrue((repo_root / "Cargo.toml").exists(), "repo_root must point at the repository root") + real_home = Path(os.path.expanduser("~")) + + with tempfile.TemporaryDirectory() as home, tempfile.TemporaryDirectory() as bin_dir: + home_path = Path(home) + fake_bin = Path(bin_dir) + write_fake_rtk(fake_bin) + + env = os.environ.copy() + env["HOME"] = str(home_path) + env["PATH"] = str(fake_bin) + os.pathsep + env.get("PATH", "") + env["RTK_TELEMETRY_DISABLED"] = "1" + env["CARGO_TERM_COLOR"] = "never" + env.setdefault("RUSTUP_TOOLCHAIN", "stable") + if "RUSTUP_HOME" not in env and (real_home / ".rustup").exists(): + env["RUSTUP_HOME"] = str(real_home / ".rustup") + if "CARGO_HOME" not in env and (real_home / ".cargo").exists(): + env["CARGO_HOME"] = str(real_home / ".cargo") + env.pop("RTK_CLAUDE_DIR", None) + + result = subprocess.run( + ["cargo", "run", "--quiet", "--", "init", "--agent", "hermes"], + cwd=repo_root, + env=env, + capture_output=True, + text=True, + timeout=300, + ) + + self.assertEqual( + 0, + result.returncode, + msg=f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}", + ) + + plugin_dir = home_path / ".hermes" / "plugins" / "rtk-rewrite" + init_path = plugin_dir / "__init__.py" + manifest_path = plugin_dir / "plugin.yaml" + self.assertTrue(init_path.exists(), "installed plugin __init__.py must exist") + self.assertTrue(manifest_path.exists(), "installed plugin.yaml must exist") + + module = load_plugin_module(init_path, "installed_rtk_rewrite_plugin") + ctx = FakeContext() + with mock.patch.dict(os.environ, {"PATH": env["PATH"]}): + module.register(ctx) + callback = ctx.hooks["pre_tool_call"] + + args = {"command": "git status"} + callback(tool_name="terminal", args=args) + + self.assertEqual({"command": "rtk git status"}, args) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/benchmark-sessions/lib/runner.py b/scripts/benchmark-sessions/lib/runner.py index 192fbcd41b..bd02dc1d05 100644 --- a/scripts/benchmark-sessions/lib/runner.py +++ b/scripts/benchmark-sessions/lib/runner.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import os import subprocess import tempfile from pathlib import Path @@ -21,11 +22,16 @@ def _create_tarball(source_dir: Path) -> str: - tarball = tempfile.mktemp(suffix=".tar.gz") - subprocess.run( - ["tar", "czf", tarball, "-C", str(source_dir), "."], - check=True, - ) + fd, tarball = tempfile.mkstemp(suffix=".tar.gz") + os.close(fd) + try: + subprocess.run( + ["tar", "czf", tarball, "-C", str(source_dir), "."], + check=True, + ) + except Exception: + Path(tarball).unlink(missing_ok=True) + raise return tarball @@ -73,6 +79,7 @@ async def run_benchmark( total_steps = 5 if terminal_bench else 4 vm_names: list[str] = [] + local_tarball: str | None = None manifest = RunManifest( task_name=task.name, @@ -86,7 +93,6 @@ async def run_benchmark( print(f" VMs ready: {', '.join(vm_names)}") _print_step(2, total_steps, "Setting up codebases") - local_tarball = None if not task.codebase.is_github: local_tarball = _create_tarball(task.codebase.local_path()) @@ -147,6 +153,8 @@ async def run_benchmark( print(f"\n Manifest written to {output_dir / 'manifest.json'}") finally: + if local_tarball: + Path(local_tarball).unlink(missing_ok=True) if not keep_vms and vm_names: print("\nCleaning up VMs...") await destroy_vm_pool(vm_names) diff --git a/src/analytics/gain.rs b/src/analytics/gain.rs index 9c8c2630aa..ac61dd9b56 100644 --- a/src/analytics/gain.rs +++ b/src/analytics/gain.rs @@ -640,7 +640,7 @@ fn export_csv( /// Silently returns None on any error (missing dirs, permission issues, etc.). fn check_rtk_disabled_bypass() -> Option { use crate::discover::provider::{ClaudeProvider, SessionProvider}; - use crate::discover::registry::has_rtk_disabled_prefix; + use crate::discover::registry::cmd_has_rtk_disabled_prefix; let provider = ClaudeProvider; @@ -663,7 +663,7 @@ fn check_rtk_disabled_bypass() -> Option { for ext_cmd in &extracted { total_bash += 1; - if has_rtk_disabled_prefix(&ext_cmd.command) { + if cmd_has_rtk_disabled_prefix(&ext_cmd.command) { bypassed += 1; } } diff --git a/src/cmds/dotnet/binlog.rs b/src/cmds/dotnet/binlog.rs index 027f482f08..09a23e327e 100644 --- a/src/cmds/dotnet/binlog.rs +++ b/src/cmds/dotnet/binlog.rs @@ -683,6 +683,8 @@ pub fn parse_build_from_text(text: &str) -> BuildSummary { issue.message.clone(), ); + // this avoid needing to clone the key for the second case + #[allow(clippy::collapsible_match)] match captures.name("kind").map(|m| m.as_str()) { Some("error") => { if seen_errors.insert(key) { @@ -1008,16 +1010,19 @@ pub fn parse_restore_issues_from_text(text: &str) -> (Vec, Vec { + Some("error") => { if seen_errors.insert(key) { errors.push(issue); } } - Some(kind) if kind == "warning" => { + Some("warning") => { if seen_warnings.insert(key) { warnings.push(issue); } diff --git a/src/cmds/dotnet/dotnet_cmd.rs b/src/cmds/dotnet/dotnet_cmd.rs index a609357111..4c307ff509 100644 --- a/src/cmds/dotnet/dotnet_cmd.rs +++ b/src/cmds/dotnet/dotnet_cmd.rs @@ -90,8 +90,8 @@ pub fn run_passthrough(args: &[OsString], verbose: u8) -> Result { eprintln!("Running: dotnet {} ...", subcommand); } - let result = exec_capture(&mut cmd) - .with_context(|| format!("Failed to run dotnet {}", subcommand))?; + let result = + exec_capture(&mut cmd).with_context(|| format!("Failed to run dotnet {}", subcommand))?; let raw = format!("{}\n{}", result.stdout, result.stderr); @@ -131,8 +131,8 @@ fn run_dotnet_with_binlog(subcommand: &str, args: &[String], verbose: u8) -> Res } let command_started_at = SystemTime::now(); - let result = exec_capture(&mut cmd) - .with_context(|| format!("Failed to run dotnet {}", subcommand))?; + let result = + exec_capture(&mut cmd).with_context(|| format!("Failed to run dotnet {}", subcommand))?; let raw = format!("{}\n{}", result.stdout, result.stderr); let command_success = result.success(); @@ -147,10 +147,8 @@ fn run_dotnet_with_binlog(subcommand: &str, args: &[String], verbose: u8) -> Res } else { binlog::BuildSummary::default() }; - let raw_summary = normalize_build_summary( - binlog::parse_build_from_text(&raw), - command_success, - ); + let raw_summary = + normalize_build_summary(binlog::parse_build_from_text(&raw), command_success); let summary = merge_build_summaries(binlog_summary, raw_summary); format_build_output(&summary, &binlog_path) } @@ -179,10 +177,8 @@ fn run_dotnet_with_binlog(subcommand: &str, args: &[String], verbose: u8) -> Res } else { binlog::BuildSummary::default() }; - let raw_diagnostics = normalize_build_summary( - binlog::parse_build_from_text(&raw), - command_success, - ); + let raw_diagnostics = + normalize_build_summary(binlog::parse_build_from_text(&raw), command_success); let test_build_summary = merge_build_summaries(binlog_diagnostics, raw_diagnostics); format_test_output( &summary, @@ -200,10 +196,8 @@ fn run_dotnet_with_binlog(subcommand: &str, args: &[String], verbose: u8) -> Res } else { binlog::RestoreSummary::default() }; - let raw_summary = normalize_restore_summary( - binlog::parse_restore_from_text(&raw), - command_success, - ); + let raw_summary = + normalize_restore_summary(binlog::parse_restore_from_text(&raw), command_success); let summary = merge_restore_summaries(binlog_summary, raw_summary); let (raw_errors, raw_warnings) = binlog::parse_restore_issues_from_text(&raw); @@ -597,12 +591,10 @@ fn scan_mtp_kind_in_file(path: &Path) -> MtpProjectKind { | b"testingplatformdotnettestsupport" ); } - Ok(Event::Text(e)) => { - if inside_mtp_element { - if let Ok(text) = e.unescape() { - if text.trim().eq_ignore_ascii_case("true") { - return MtpProjectKind::VsTestBridge; - } + Ok(Event::Text(e)) if inside_mtp_element => { + if let Ok(text) = e.unescape() { + if text.trim().eq_ignore_ascii_case("true") { + return MtpProjectKind::VsTestBridge; } } } @@ -979,49 +971,73 @@ fn format_issue(issue: &binlog::BinlogIssue, kind: &str) -> String { ) } +/// Format the build summary for stdout. +/// +/// `_binlog_path` is intentionally unused — the binlog is a temporary file +/// that has already been cleaned up by the time this runs. fn format_build_output(summary: &binlog::BuildSummary, _binlog_path: &Path) -> String { let status_icon = if summary.succeeded { "ok" } else { "fail" }; let duration = summary.duration_text.as_deref().unwrap_or("unknown"); - let mut out = format!( - "{} dotnet build: {} projects, {} errors, {} warnings ({})", - status_icon, - summary.project_count, - summary.errors.len(), - summary.warnings.len(), - duration - ); - + let mut errors = String::new(); if !summary.errors.is_empty() { - out.push_str("\n---------------------------------------\n\nErrors:\n"); + errors.push_str("Errors:\n"); for issue in summary.errors.iter().take(20) { - out.push_str(&format!("{}\n", format_issue(issue, "error"))); + errors.push_str(&format!("{}\n", format_issue(issue, "error"))); } if summary.errors.len() > 20 { - out.push_str(&format!( + errors.push_str(&format!( " ... +{} more errors\n", summary.errors.len() - 20 )); } } + let mut warnings = String::new(); if !summary.warnings.is_empty() { - out.push_str("\nWarnings:\n"); + warnings.push_str("Warnings:\n"); for issue in summary.warnings.iter().take(10) { - out.push_str(&format!("{}\n", format_issue(issue, "warning"))); + warnings.push_str(&format!("{}\n", format_issue(issue, "warning"))); } if summary.warnings.len() > 10 { - out.push_str(&format!( + warnings.push_str(&format!( " ... +{} more warnings\n", summary.warnings.len() - 10 )); } } - // Binlog path omitted from output (temp file, already cleaned up) - out + let sep = if !warnings.is_empty() || !errors.is_empty() { + "---------------------------------------" + } else { + "" + }; + + let verdict = format!( + "{} dotnet build: {} projects, {} errors, {} warnings ({})", + status_icon, + summary.project_count, + summary.errors.len(), + summary.warnings.len(), + duration + ); + + // Status line is emitted last so consumers that read the tail of the stream + // (`| tail -N`, agent watch/monitor modes, bounded context windows) get a + // definitive verdict. Mirrors native `dotnet build`, which ends with + // `Build succeeded.` / `Build FAILED.`. See issue #1574. + // Warnings before errors: errors survive `| tail -N` immediately above the verdict. + [warnings, errors, sep.into(), verdict] + .into_iter() + .filter(|s| !s.is_empty()) + .collect::>() + .join("\n") } +/// Format the test summary for stdout. +/// +/// `_binlog_path` is intentionally unused — the binlog is a temporary file +/// that has already been cleaned up by the time this runs. fn format_test_output( summary: &binlog::TestSummary, errors: &[binlog::BinlogIssue], @@ -1038,7 +1054,7 @@ fn format_test_output( && summary.total == 0 && summary.failed_tests.is_empty(); - let mut out = if counts_unavailable { + let header = if counts_unavailable { format!( "{} dotnet test: completed (binlog-only mode, counts unavailable, {} warnings) ({})", status_icon, warning_count, duration @@ -1061,47 +1077,74 @@ fn format_test_output( ) }; + let mut failed_tests_section = String::new(); if has_failures && !summary.failed_tests.is_empty() { - out.push_str("\n---------------------------------------\n\nFailed Tests:\n"); + failed_tests_section.push_str("Failed Tests:\n"); for failed in summary.failed_tests.iter().take(15) { - out.push_str(&format!(" {}\n", failed.name)); + failed_tests_section.push_str(&format!(" {}\n", failed.name)); for detail in &failed.details { - out.push_str(&format!(" {}\n", truncate(detail, 320))); + failed_tests_section.push_str(&format!(" {}\n", truncate(detail, 320))); } - out.push('\n'); + failed_tests_section.push('\n'); } if summary.failed_tests.len() > 15 { - out.push_str(&format!( + failed_tests_section.push_str(&format!( "... +{} more failed tests\n", summary.failed_tests.len() - 15 )); } } + let mut errors_section = String::new(); if !errors.is_empty() { - out.push_str("\nErrors:\n"); + errors_section.push_str("Errors:\n"); for issue in errors.iter().take(10) { - out.push_str(&format!("{}\n", format_issue(issue, "error"))); + errors_section.push_str(&format!("{}\n", format_issue(issue, "error"))); } if errors.len() > 10 { - out.push_str(&format!(" ... +{} more errors\n", errors.len() - 10)); + errors_section.push_str(&format!(" ... +{} more errors\n", errors.len() - 10)); } } + let mut warnings_section = String::new(); if !warnings.is_empty() { - out.push_str("\nWarnings:\n"); + warnings_section.push_str("Warnings:\n"); for issue in warnings.iter().take(10) { - out.push_str(&format!("{}\n", format_issue(issue, "warning"))); + warnings_section.push_str(&format!("{}\n", format_issue(issue, "warning"))); } if warnings.len() > 10 { - out.push_str(&format!(" ... +{} more warnings\n", warnings.len() - 10)); + warnings_section.push_str(&format!(" ... +{} more warnings\n", warnings.len() - 10)); } } - // Binlog path omitted from output (temp file, already cleaned up) - out + let sep = if !failed_tests_section.is_empty() + || !warnings_section.is_empty() + || !errors_section.is_empty() + { + "---------------------------------------" + } else { + "" + }; + + // Status line emitted last; see format_build_output (issue #1574). + // Warnings before errors: errors survive `| tail -N` immediately above the verdict. + [ + failed_tests_section, + warnings_section, + errors_section, + sep.into(), + header, + ] + .into_iter() + .filter(|s| !s.is_empty()) + .collect::>() + .join("\n") } +/// Format the restore summary for stdout. +/// +/// `_binlog_path` is intentionally unused — the binlog is a temporary file +/// that has already been cleaned up by the time this runs. fn format_restore_output( summary: &binlog::RestoreSummary, errors: &[binlog::BinlogIssue], @@ -1112,33 +1155,46 @@ fn format_restore_output( let status_icon = if has_errors { "fail" } else { "ok" }; let duration = summary.duration_text.as_deref().unwrap_or("unknown"); - let mut out = format!( - "{} dotnet restore: {} projects, {} errors, {} warnings ({})", - status_icon, summary.restored_projects, summary.errors, summary.warnings, duration - ); - + let mut errors_section = String::new(); if !errors.is_empty() { - out.push_str("\n---------------------------------------\n\nErrors:\n"); + errors_section.push_str("Errors:\n"); for issue in errors.iter().take(20) { - out.push_str(&format!("{}\n", format_issue(issue, "error"))); + errors_section.push_str(&format!("{}\n", format_issue(issue, "error"))); } if errors.len() > 20 { - out.push_str(&format!(" ... +{} more errors\n", errors.len() - 20)); + errors_section.push_str(&format!(" ... +{} more errors\n", errors.len() - 20)); } } + let mut warnings_section = String::new(); if !warnings.is_empty() { - out.push_str("\nWarnings:\n"); + warnings_section.push_str("Warnings:\n"); for issue in warnings.iter().take(10) { - out.push_str(&format!("{}\n", format_issue(issue, "warning"))); + warnings_section.push_str(&format!("{}\n", format_issue(issue, "warning"))); } if warnings.len() > 10 { - out.push_str(&format!(" ... +{} more warnings\n", warnings.len() - 10)); + warnings_section.push_str(&format!(" ... +{} more warnings\n", warnings.len() - 10)); } } - // Binlog path omitted from output (temp file, already cleaned up) - out + let sep = if !warnings_section.is_empty() || !errors_section.is_empty() { + "---------------------------------------" + } else { + "" + }; + + let verdict = format!( + "{} dotnet restore: {} projects, {} errors, {} warnings ({})", + status_icon, summary.restored_projects, summary.errors, summary.warnings, duration + ); + + // Status line emitted last; see format_build_output (issue #1574). + // Warnings before errors: errors survive `| tail -N` immediately above the verdict. + [warnings_section, errors_section, sep.into(), verdict] + .into_iter() + .filter(|s| !s.is_empty()) + .collect::>() + .join("\n") } #[cfg(test)] @@ -1367,6 +1423,94 @@ mod tests { assert!(output.contains("counts unavailable")); } + // Regression tests for issue #1574: status line must be the final line so that + // consumers reading the tail of the stream (`| tail -N`, agent watch/monitor + // modes, bounded context windows) get a definitive `ok` / `fail` verdict. + // Mirrors native `dotnet`, which ends with `Build succeeded.` / `Build FAILED.`. + + #[test] + fn test_format_build_output_status_line_is_last_for_tail_consumers() { + let summary = binlog::BuildSummary { + succeeded: true, + project_count: 1, + errors: Vec::new(), + warnings: vec![binlog::BinlogIssue { + code: "CS0219".to_string(), + file: "src/Program.cs".to_string(), + line: 25, + column: 10, + message: "Variable assigned but never used".to_string(), + }], + duration_text: Some("00:00:01.23".to_string()), + }; + let output = format_build_output(&summary, Path::new("/tmp/build.binlog")); + let last_line = output.lines().last().expect("output must not be empty"); + assert!( + last_line.starts_with("ok dotnet build:"), + "status line must be the last line for `| tail -N` consumers, got: {:?}", + last_line + ); + + let last_5: Vec<&str> = output.lines().rev().take(5).collect(); + assert!( + last_5.iter().any(|l| l.starts_with("ok dotnet build:")), + "`tail -5` must include the status line, got tail: {:?}", + last_5 + ); + } + + #[test] + fn test_format_test_output_status_line_is_last_for_tail_consumers() { + let summary = binlog::TestSummary { + passed: 940, + failed: 0, + skipped: 7, + total: 947, + project_count: 1, + failed_tests: Vec::new(), + duration_text: Some("1 s".to_string()), + }; + let warnings = vec![binlog::BinlogIssue { + code: String::new(), + file: "/sdk/Microsoft.TestPlatform.targets".to_string(), + line: 48, + column: 5, + message: "Violators:".to_string(), + }]; + let output = format_test_output(&summary, &[], &warnings, Path::new("/tmp/test.binlog")); + let last_line = output.lines().last().expect("output must not be empty"); + assert!( + last_line.starts_with("ok dotnet test:"), + "status line must be the last line, got: {:?}", + last_line + ); + } + + #[test] + fn test_format_restore_output_status_line_is_last_for_tail_consumers() { + let summary = binlog::RestoreSummary { + restored_projects: 1, + warnings: 0, + errors: 1, + duration_text: Some("00:00:01.00".to_string()), + }; + let issues = vec![binlog::BinlogIssue { + code: "NU1101".to_string(), + file: "/repo/src/App/App.csproj".to_string(), + line: 0, + column: 0, + message: "Unable to find package Foo.Bar".to_string(), + }]; + let output = + format_restore_output(&summary, &issues, &[], Path::new("/tmp/restore.binlog")); + let last_line = output.lines().last().expect("output must not be empty"); + assert!( + last_line.starts_with("fail dotnet restore:"), + "status line must be the last line, got: {:?}", + last_line + ); + } + #[test] fn test_normalize_build_summary_sets_success_floor() { let summary = binlog::BuildSummary { diff --git a/src/cmds/dotnet/dotnet_trx.rs b/src/cmds/dotnet/dotnet_trx.rs index 42275e727f..ef94f00892 100644 --- a/src/cmds/dotnet/dotnet_trx.rs +++ b/src/cmds/dotnet/dotnet_trx.rs @@ -253,22 +253,16 @@ fn parse_trx_content(content: &str) -> Option { .unwrap_or_else(|| "unknown".to_string()); } } - b"ErrorInfo" => { - if in_failed_result { - in_error_info = true; - } + b"ErrorInfo" if in_failed_result => { + in_error_info = true; } - b"Message" => { - if in_failed_result && in_error_info { - capture_field = Some(CaptureField::Message); - message_buf.clear(); - } + b"Message" if in_failed_result && in_error_info => { + capture_field = Some(CaptureField::Message); + message_buf.clear(); } - b"StackTrace" => { - if in_failed_result && in_error_info { - capture_field = Some(CaptureField::StackTrace); - stack_buf.clear(); - } + b"StackTrace" if in_failed_result && in_error_info => { + capture_field = Some(CaptureField::StackTrace); + stack_buf.clear(); } _ => {} }, @@ -332,34 +326,32 @@ fn parse_trx_content(content: &str) -> Option { b"ErrorInfo" => { in_error_info = false; } - b"UnitTestResult" => { - if in_failed_result { - let mut details = Vec::new(); + b"UnitTestResult" if in_failed_result => { + let mut details = Vec::new(); - let message = message_buf.trim(); - if !message.is_empty() { - details.push(message.to_string()); - } + let message = message_buf.trim(); + if !message.is_empty() { + details.push(message.to_string()); + } - let stack = stack_buf.trim(); - if !stack.is_empty() { - let stack_lines: Vec<&str> = stack.lines().take(3).collect(); - if !stack_lines.is_empty() { - details.push(stack_lines.join("\n")); - } + let stack = stack_buf.trim(); + if !stack.is_empty() { + let stack_lines: Vec<&str> = stack.lines().take(3).collect(); + if !stack_lines.is_empty() { + details.push(stack_lines.join("\n")); } + } - summary.failed_tests.push(FailedTest { - name: failed_test_name.clone(), - details, - }); + summary.failed_tests.push(FailedTest { + name: failed_test_name.clone(), + details, + }); - in_failed_result = false; - in_error_info = false; - capture_field = None; - message_buf.clear(); - stack_buf.clear(); - } + in_failed_result = false; + in_error_info = false; + capture_field = None; + message_buf.clear(); + stack_buf.clear(); } _ => {} }, diff --git a/src/cmds/go/go_cmd.rs b/src/cmds/go/go_cmd.rs index 4ef99daf18..965a7571b8 100644 --- a/src/cmds/go/go_cmd.rs +++ b/src/cmds/go/go_cmd.rs @@ -46,7 +46,9 @@ pub fn run_test(args: &[String], verbose: u8) -> Result { let mut cmd = resolved_command("go"); cmd.arg("test"); - if !args.iter().any(|a| a == "-json") { + let skip_json = args.iter().any(|a| a == "-json" || a.starts_with("-bench")); + + if !skip_json { cmd.arg("-json"); } @@ -55,14 +57,24 @@ pub fn run_test(args: &[String], verbose: u8) -> Result { } if verbose > 0 { - eprintln!("Running: go test -json {}", args.join(" ")); + eprintln!( + "Running: go test {}{}", + if !skip_json { "-json " } else { "" }, + args.join(" ") + ); } + let filter: fn(&str) -> String = if skip_json { + |s: &str| s.to_string() + } else { + filter_go_test_json + }; + runner::run_filtered( cmd, "go test", &args.join(" "), - filter_go_test_json, + filter, crate::core::runner::RunOptions::stdout_only().tee("go_test"), ) } @@ -329,10 +341,8 @@ pub(crate) fn filter_go_test_json(output: &str) -> String { let pkg_result = packages.entry(package.clone()).or_default(); match event.action.as_str() { - "pass" => { - if event.test.is_some() { - pkg_result.pass += 1; - } + "pass" if event.test.is_some() => { + pkg_result.pass += 1; } "fail" => { if let Some(test) = &event.test { @@ -358,10 +368,8 @@ pub(crate) fn filter_go_test_json(output: &str) -> String { pkg_result.package_failed = true; } } - "skip" => { - if event.test.is_some() { - pkg_result.skip += 1; - } + "skip" if event.test.is_some() => { + pkg_result.skip += 1; } "output" => { if let Some(output_text) = &event.output { @@ -614,9 +622,7 @@ fn is_go_build_error_line(line: &str) -> bool { // Canonical compiler/config error locations: file:line:col: ... let is_go_config_location = !lower.starts_with("go: ") - && (lower.contains("go.mod:") - || lower.contains("go.work:") - || lower.contains("go.sum:")); + && (lower.contains("go.mod:") || lower.contains("go.work:") || lower.contains("go.sum:")); if trimmed.contains(".go:") || is_go_config_location { return true; } @@ -883,9 +889,7 @@ go: downloading golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2"#; #[test] fn test_is_go_build_error_line_recognizes_real_compiler_errors() { assert!(is_go_build_error_line("undefined: missingFunc")); - assert!(is_go_build_error_line( - "cannot find package \"foo/bar\"" - )); + assert!(is_go_build_error_line("cannot find package \"foo/bar\"")); assert!(is_go_build_error_line( "found packages a (a.go) and b (b.go) in /tmp/rtk-go-build-probe-mix" )); @@ -969,7 +973,9 @@ go: cannot load module missing listed in go.work file: open missing/go.mod: no s let result = filter_go_build(output); assert!(result.contains("3 errors")); - assert!(result.contains("go.mod file not found in current directory or any parent directory")); + assert!( + result.contains("go.mod file not found in current directory or any parent directory") + ); assert!(result.contains("no Go files in /tmp/example")); assert!(result.contains("go: cannot load module missing listed in go.work file")); } diff --git a/src/cmds/go/golangci_cmd.rs b/src/cmds/go/golangci_cmd.rs index cb6ca34b08..384ff1855a 100644 --- a/src/cmds/go/golangci_cmd.rs +++ b/src/cmds/go/golangci_cmd.rs @@ -339,7 +339,7 @@ pub(crate) fn filter_golangci_json(output: &str, version: u32) -> String { } let mut file_linter_counts: Vec<_> = file_linters.iter().collect(); - file_linter_counts.sort_by(|a, b| b.1.len().cmp(&a.1.len())); + file_linter_counts.sort_by_key(|b| std::cmp::Reverse(b.1.len())); for (linter, linter_issues) in file_linter_counts.iter().take(3) { result.push_str(&format!(" {} ({})\n", linter, linter_issues.len())); diff --git a/src/cmds/js/lint_cmd.rs b/src/cmds/js/lint_cmd.rs index 21eb528f54..cfb5bea860 100644 --- a/src/cmds/js/lint_cmd.rs +++ b/src/cmds/js/lint_cmd.rs @@ -107,17 +107,13 @@ pub fn run(args: &[String], verbose: u8) -> Result { "eslint" => { cmd.arg("-f").arg("json"); } - "ruff" => { - // Force JSON output for ruff check - if !effective_args.contains(&"--output-format".to_string()) { - cmd.arg("check").arg("--output-format=json"); - } + // Force JSON output for ruff check + "ruff" if !effective_args.contains(&"--output-format".to_string()) => { + cmd.arg("check").arg("--output-format=json"); } - "pylint" => { - // Force JSON2 output for pylint - if !effective_args.contains(&"--output-format".to_string()) { - cmd.arg("--output-format=json2"); - } + // Force JSON2 output for pylint + "pylint" if !effective_args.contains(&"--output-format".to_string()) => { + cmd.arg("--output-format=json2"); } "mypy" => { // mypy uses default text output (no special flags) @@ -263,7 +259,7 @@ fn filter_eslint_json(output: &str) -> String { .filter(|r| !r.messages.is_empty()) .map(|r| (r, r.messages.len())) .collect(); - by_file.sort_by(|a, b| b.1.cmp(&a.1)); + by_file.sort_by_key(|b| std::cmp::Reverse(b.1)); // Build output let mut result = String::new(); diff --git a/src/cmds/js/pnpm_cmd.rs b/src/cmds/js/pnpm_cmd.rs index 1048060f47..53661faaf5 100644 --- a/src/cmds/js/pnpm_cmd.rs +++ b/src/cmds/js/pnpm_cmd.rs @@ -414,12 +414,7 @@ fn run_install(args: &[String], verbose: u8) -> Result { println!("{}", filtered); - timer.track( - &format!("pnpm install"), - &format!("rtk pnpm install"), - &combined, - &filtered, - ); + timer.track("pnpm install", "rtk pnpm install", &combined, &filtered); Ok(0) } diff --git a/src/cmds/js/tsc_cmd.rs b/src/cmds/js/tsc_cmd.rs index e879882899..0b75fc9f68 100644 --- a/src/cmds/js/tsc_cmd.rs +++ b/src/cmds/js/tsc_cmd.rs @@ -9,9 +9,8 @@ use regex::Regex; use std::collections::{HashMap, HashSet}; lazy_static! { - static ref TSC_ERROR: Regex = Regex::new( - r"^(.+?)\((\d+),(\d+)\):\s+(error|warning)\s+(TS\d+):\s+(.+)$" - ).unwrap(); + static ref TSC_ERROR: Regex = + Regex::new(r"^(.+?)\((\d+),(\d+)\):\s+(error|warning)\s+(TS\d+):\s+(.+)$").unwrap(); } pub fn run(args: &[String], verbose: u8) -> Result { @@ -106,7 +105,6 @@ impl BlockHandler for TscHandler { } pub(crate) fn filter_tsc_output(output: &str) -> String { - struct TsError { file: String, line: usize, @@ -193,7 +191,7 @@ pub(crate) fn filter_tsc_output(output: &str) -> String { // Files sorted by error count (most errors first) let mut files_sorted: Vec<_> = by_file.iter().collect(); - files_sorted.sort_by(|a, b| b.1.len().cmp(&a.1.len())); + files_sorted.sort_by_key(|b| std::cmp::Reverse(b.1.len())); // Show every error per file — no limits for (file, file_errors) in &files_sorted { diff --git a/src/cmds/jvm/gradlew_cmd.rs b/src/cmds/jvm/gradlew_cmd.rs new file mode 100644 index 0000000000..ad2a2987e3 --- /dev/null +++ b/src/cmds/jvm/gradlew_cmd.rs @@ -0,0 +1,1395 @@ +use crate::core::runner::{self, RunOptions}; +use crate::core::stream::StreamFilter; +use crate::core::utils::resolved_command; +use anyhow::Result; +use lazy_static::lazy_static; +use regex::Regex; +use std::ffi::OsString; +use std::process::Command; + +// ── Shared regex patterns (used across multiple filters) ───────────────────── + +lazy_static! { + static ref TASK_LINE: Regex = Regex::new(r"^> Task :").unwrap(); + static ref TRY_SECTION: Regex = + Regex::new(r"^\* Try:|^> Run with --|^> Get more help at").unwrap(); + static ref BUILD_STATUS: Regex = Regex::new(r"^BUILD (SUCCESSFUL|FAILED)").unwrap(); + static ref ACTIONABLE: Regex = Regex::new(r"^\d+ actionable tasks?").unwrap(); +} + +#[derive(Debug, PartialEq)] +enum GradlewTask { + Build, + Test, + ConnectedTest, + Lint, + Dependencies, + Other, +} + +fn detect_task(args: &[String]) -> GradlewTask { + // Use the last non-flag, non-clean task to determine the filter. + // Example: `clean assembleDebug` → Build (last non-clean task). + // Note: for mixed-task invocations like `test assemble`, last wins. + let task = args + .iter() + .filter(|a| !a.starts_with('-') && a.to_lowercase() != "clean") + .map(|s| s.to_lowercase()) + .next_back() + .unwrap_or_default(); + + if task.contains("connected") { + GradlewTask::ConnectedTest + } else if task.contains("test") { + GradlewTask::Test + } else if task.contains("assemble") + || task.contains("build") + || task.contains("bundle") + || task.contains("install") + { + GradlewTask::Build + } else if task.contains("lint") || task.contains("ktlint") || task.contains("detekt") { + GradlewTask::Lint + } else if task == "check" { + GradlewTask::Test + } else if task.contains("dependencies") { + GradlewTask::Dependencies + } else if task.is_empty() { + // Only "clean" was passed (filtered out above) → treat as Build to filter task noise + GradlewTask::Build + } else { + GradlewTask::Other + } +} + +/// Returns the Gradle executable: prefers `./gradlew` (wrapper), falls back to `gradle`. +fn gradlew_binary() -> &'static str { + if cfg!(windows) { + if std::path::Path::new(".\\gradlew.bat").exists() { + ".\\gradlew.bat" + } else { + "gradle" + } + } else if std::path::Path::new("./gradlew").exists() { + "./gradlew" + } else { + "gradle" + } +} + +/// Builds a Gradle `Command`. +/// +/// Local wrappers (`./gradlew`, `gradlew.bat`) are passed as string literals so +/// semgrep's `dynamic-command-execution` rule stays happy. The `gradle` system +/// binary is resolved via `resolved_command("gradle")` for PATHEXT support on +/// Windows (`.CMD`/`.BAT` shims) — matches how cargo, golangci-lint, etc. do it. +fn new_gradle_command(args: &[String]) -> Command { + let mut cmd = if cfg!(windows) { + if std::path::Path::new(".\\gradlew.bat").exists() { + Command::new(".\\gradlew.bat") + } else { + resolved_command("gradle") + } + } else if std::path::Path::new("./gradlew").exists() { + Command::new("./gradlew") + } else { + resolved_command("gradle") + }; + cmd.args(args); + cmd +} + +/// `StreamFilter` for build mode: keeps lines for which `filter_build_line` returns true. +struct BuildLineFilter; + +impl StreamFilter for BuildLineFilter { + fn feed_line(&mut self, line: &str) -> Option { + if filter_build_line(line) { + Some(format!("{}\n", line)) + } else { + None + } + } + + fn flush(&mut self) -> String { + String::new() + } +} + +pub fn run(args: &[String], verbose: u8) -> Result { + // Verbose flags bypass filtering — user wants full output + if args + .iter() + .any(|a| a == "--stacktrace" || a == "--info" || a == "--debug" || a == "--full-stacktrace") + { + let osargs: Vec = args.iter().map(OsString::from).collect(); + return runner::run_passthrough(gradlew_binary(), &osargs, verbose); + } + + let cmd = new_gradle_command(args); + let args_display = args.join(" "); + let tool = gradlew_binary(); + + match detect_task(args) { + GradlewTask::Build => runner::run_streamed( + cmd, + tool, + &args_display, + Box::new(BuildLineFilter), + RunOptions::with_tee("gradlew_build"), + ), + GradlewTask::Test => runner::run_filtered( + cmd, + tool, + &args_display, + filter_test, + RunOptions::with_tee("gradlew_test"), + ), + GradlewTask::ConnectedTest => runner::run_filtered( + cmd, + tool, + &args_display, + filter_connected, + RunOptions::with_tee("gradlew_connected"), + ), + GradlewTask::Lint => runner::run_filtered( + cmd, + tool, + &args_display, + filter_lint, + RunOptions::with_tee("gradlew_lint"), + ), + GradlewTask::Dependencies => runner::run_filtered( + cmd, + tool, + &args_display, + filter_dependencies, + RunOptions::with_tee("gradlew_deps"), + ), + GradlewTask::Other => { + let osargs: Vec = args.iter().map(OsString::from).collect(); + runner::run_passthrough(gradlew_binary(), &osargs, verbose) + } + } +} + +// ── Build filter predicate ──────────────────────────────────────────────────── + +fn filter_build_line(line: &str) -> bool { + lazy_static! { + static ref DAEMON_LINE: Regex = Regex::new( + r"^(Starting a Gradle Daemon|Daemon will be stopped|Reusing configuration cache|Calculating task graph|> Configure project|Deprecated Gradle features|You can use|For more on this|Configuration cache entry)" + ) + .unwrap(); + static ref PROGRESS: Regex = + Regex::new(r"^\s*\d+%|^Downloading|^Configuring|^Resolving|^\[Incubating\]|^Wrote HTML report|^class \S+ could not|^\[android-") + .unwrap(); + static ref ERROR_LINE: Regex = Regex::new( + r"(?i)(^FAILURE:|^\* What went wrong:|^\* Where:|> Could not|e: |error:|^Execution failed|Lint found \d+ error)" + ) + .unwrap(); + // Compiler + gradle warnings: kotlinc emits "w: ", javac/gradle "warning:" or "Warning:" + static ref WARN_LINE: Regex = Regex::new( + r"^(w: |warning:|Warning:|WARNING:)" + ) + .unwrap(); + static ref BUILD_SCAN: Regex = Regex::new(r"gradle\.com/s/|Publishing build scan").unwrap(); + } + + // Always strip these + if TASK_LINE.is_match(line) + || DAEMON_LINE.is_match(line) + || PROGRESS.is_match(line) + || TRY_SECTION.is_match(line) + { + return false; + } + + // Always keep these + BUILD_STATUS.is_match(line) + || ACTIONABLE.is_match(line) + || ERROR_LINE.is_match(line) + || WARN_LINE.is_match(line) + || BUILD_SCAN.is_match(line) + || line.trim().is_empty() // preserve blank lines that separate error sections +} + +// ── Test output filter ──────────────────────────────────────────────────────── + +/// Returns true if an `at ...` stack frame belongs to a test framework +/// (JUnit, Gradle runner, reflection) rather than user code. +fn is_framework_frame(trimmed: &str) -> bool { + trimmed.starts_with("at org.junit.") + || trimmed.starts_with("at junit.") + || trimmed.starts_with("at java.lang.reflect.") + || trimmed.starts_with("at sun.reflect.") + || trimmed.starts_with("at org.gradle.") +} + +fn filter_test(output: &str) -> String { + lazy_static! { + static ref FAILED_LINE: Regex = Regex::new(r"FAILED$| FAILED ").unwrap(); + static ref PASSED_SKIPPED: Regex = Regex::new(r" PASSED$| SKIPPED$").unwrap(); + static ref SUMMARY_LINE: Regex = Regex::new( + r"\d+ tests? completed|\d+ tests? failed|There were failing tests|See the report at" + ) + .unwrap(); + } + + if output.is_empty() { + return String::new(); + } + + let mut result_lines: Vec<&str> = Vec::new(); + let mut in_failure_block = false; + + for line in output.lines() { + // Skip always-noise lines + if TASK_LINE.is_match(line) || TRY_SECTION.is_match(line) { + continue; + } + + // Build summary lines always kept + if BUILD_STATUS.is_match(line) || ACTIONABLE.is_match(line) || SUMMARY_LINE.is_match(line) { + result_lines.push(line); + continue; + } + + // PASSED/SKIPPED per-test lines — strip + if PASSED_SKIPPED.is_match(line) { + in_failure_block = false; + continue; + } + + // FAILED per-test lines — keep + enter failure block for stack trace + if FAILED_LINE.is_match(line) { + in_failure_block = true; + result_lines.push(line); + continue; + } + + // Stack trace lines following a failure + if in_failure_block { + let trimmed = line.trim(); + if trimmed.starts_with("java.") || trimmed.starts_with("kotlin.") { + // Exception class + message — always keep + result_lines.push(line); + } else if trimmed.starts_with("at ") { + // Skip framework frames, keep first user-code frame + if !is_framework_frame(trimmed) { + result_lines.push(line); + in_failure_block = false; + } + } else if !trimmed.is_empty() { + in_failure_block = false; + } + } + } + + let filtered = result_lines.join("\n"); + + // Guarantee non-empty output + if filtered.trim().is_empty() { + if output.contains("BUILD SUCCESSFUL") { + return "ok ✓ (no test output — add testLogging to build.gradle for details)" + .to_string(); + } + return output.trim().to_string(); + } + + filtered +} + +// ── Connected / instrumented test filter ───────────────────────────────────── + +fn filter_connected(output: &str) -> String { + lazy_static! { + static ref INSTRUMENTATION_STATUS: Regex = + Regex::new(r"^INSTRUMENTATION_STATUS[_CODE]*:").unwrap(); + static ref INSTRUMENTATION_RESULT: Regex = Regex::new(r"^INSTRUMENTATION_RESULT:").unwrap(); + static ref INSTRUMENTATION_CODE: Regex = Regex::new(r"^INSTRUMENTATION_CODE:").unwrap(); + static ref STARTING_TESTS: Regex = Regex::new(r"^Starting \d+ tests? on ").unwrap(); + static ref INSTALLING_APK: Regex = Regex::new(r"^Installing APK").unwrap(); + } + + if output.is_empty() { + return String::new(); + } + + // Special case: no device + if output.contains("No connected devices!") { + return "connectedAndroidTest failed: No connected devices! Start an emulator or connect a device.".to_string(); + } + + let mut result_lines: Vec<&str> = Vec::new(); + + for line in output.lines() { + if INSTRUMENTATION_STATUS.is_match(line) + || INSTRUMENTATION_RESULT.is_match(line) + || INSTRUMENTATION_CODE.is_match(line) + || STARTING_TESTS.is_match(line) + || INSTALLING_APK.is_match(line) + || TASK_LINE.is_match(line) + || TRY_SECTION.is_match(line) + { + continue; + } + result_lines.push(line); + } + + // After stripping instrumentation noise, connected test output uses the same + // PASSED/FAILED line format as unit tests — delegate to filter_test. + let joined = result_lines.join("\n"); + let filtered = filter_test(&joined); + + if filtered.trim().is_empty() { + return "ok ✓ (connected tests passed)".to_string(); + } + filtered +} + +// ── Lint output filter ──────────────────────────────────────────────────────── + +fn filter_lint(output: &str) -> String { + lazy_static! { + // Android lint errors: src/main/java/Foo.kt:45: Error: message [IssueId] + static ref ANDROID_LINT_ERROR: Regex = + Regex::new(r"[^:]+:\d+:.*[Ee]rror:.*\[").unwrap(); + // Android lint warnings: src/main/java/Foo.kt:89: Warning: message [IssueId] + static ref ANDROID_LINT_WARNING: Regex = + Regex::new(r"[^:]+:\d+:.*[Ww]arning:.*\[").unwrap(); + // ktlint: file:line:col: Lint error > message + static ref KTLINT_VIOLATION: Regex = + Regex::new(r"[^:]+:\d+:\d+:.*[Ll]int").unwrap(); + // detekt: file:line:col: error - message + static ref DETEKT_VIOLATION: Regex = + Regex::new(r"[^:]+:\d+:\d+:.*error").unwrap(); + // Summary lines + static ref SUMMARY_LINE: Regex = + Regex::new(r"\d+ (issues?|errors?|warnings?)").unwrap(); + // Strip report path lines (too long) + static ref REPORT_LINE: Regex = + Regex::new(r"Wrote (HTML|XML|text) report|file://|/build/reports/lint").unwrap(); + } + + if output.is_empty() { + return String::new(); + } + + // Android lint emits violation + code snippet + caret + explanation, + // separated from the next violation by a blank line. We keep up to 3 + // non-empty context lines so the LLM sees what code is wrong without + // having to open the file. + const MAX_CONTEXT_LINES: usize = 3; + + let mut result_lines: Vec<&str> = Vec::new(); + let mut context_remaining: usize = 0; + + for line in output.lines() { + if TASK_LINE.is_match(line) || TRY_SECTION.is_match(line) || REPORT_LINE.is_match(line) { + context_remaining = 0; + continue; + } + + let is_android_lint = ANDROID_LINT_ERROR.is_match(line) || ANDROID_LINT_WARNING.is_match(line); + + if BUILD_STATUS.is_match(line) + || ACTIONABLE.is_match(line) + || SUMMARY_LINE.is_match(line) + || is_android_lint + || KTLINT_VIOLATION.is_match(line) + || DETEKT_VIOLATION.is_match(line) + { + result_lines.push(line); + // Only Android lint violations have multi-line context; + // ktlint/detekt/summary lines are single-line. + context_remaining = if is_android_lint { MAX_CONTEXT_LINES } else { 0 }; + continue; + } + + if context_remaining > 0 { + if line.trim().is_empty() { + // Blank line terminates the context block + context_remaining = 0; + } else { + result_lines.push(line); + context_remaining -= 1; + } + } + } + + let filtered = result_lines.join("\n"); + + if filtered.trim().is_empty() { + if output.contains("BUILD SUCCESSFUL") { + return "ok ✓ lint passed".to_string(); + } + return output.trim().to_string(); + } + + filtered +} + +// ── Dependencies output filter ─────────────────────────────────────────────── + +fn filter_dependencies(output: &str) -> String { + if output.is_empty() { + return String::new(); + } + + let mut configs: Vec<(String, Vec)> = Vec::new(); + let mut current_config = String::new(); + let mut current_deps: Vec = Vec::new(); + let mut total_deps = 0; + + for line in output.lines() { + let trimmed = line.trim(); + + // Skip noise + if trimmed.is_empty() + || TASK_LINE.is_match(trimmed) + || TRY_SECTION.is_match(trimmed) + || BUILD_STATUS.is_match(trimmed) + || ACTIONABLE.is_match(trimmed) + || trimmed.starts_with("Downloading") + || trimmed.starts_with("Download ") + || trimmed.starts_with("Starting a Gradle") + || trimmed == "No dependencies" + || trimmed == "(n)" + { + continue; + } + + // Configuration header: "compileClasspath - Compile classpath for source set 'main'." + // Not indented, not a tree line, contains " - " + if !trimmed.starts_with('+') + && !trimmed.starts_with('|') + && !trimmed.starts_with('\\') + && !trimmed.starts_with(' ') + && trimmed.contains(" - ") + { + if !current_config.is_empty() && !current_deps.is_empty() { + configs.push((current_config.clone(), current_deps.clone())); + } + current_config = trimmed.split(" - ").next().unwrap_or(trimmed).to_string(); + current_deps = Vec::new(); + continue; + } + + // Top-level dependencies only (first level of the tree). + // Check the *untrimmed* line — top-level deps start at column 0, + // transitive deps are indented (e.g., "| +---" or " \---"). + if (line.starts_with("+---") || line.starts_with("\\---")) && !current_config.is_empty() { + let dep = trimmed + .trim_start_matches("+--- ") + .trim_start_matches("\\--- ") + .to_string(); + current_deps.push(dep); + total_deps += 1; + } + } + + // Flush last config + if !current_config.is_empty() && !current_deps.is_empty() { + configs.push((current_config, current_deps)); + } + + if configs.is_empty() { + if output.contains("BUILD SUCCESSFUL") { + return "ok ✓ no dependencies".to_string(); + } + return output.trim().to_string(); + } + + let mut result = format!( + "{} top-level dependencies across {} configurations\n", + total_deps, + configs.len() + ); + + for (config, deps) in &configs { + result.push_str(&format!("\n{} ({}):\n", config, deps.len())); + for dep in deps.iter().take(20) { + result.push_str(&format!(" {}\n", dep)); + } + if deps.len() > 20 { + result.push_str(&format!(" ... +{} more\n", deps.len() - 20)); + } + } + + result.trim_end().to_string() +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn count_tokens(text: &str) -> usize { + text.split_whitespace().count() + } + + // ── TASK DETECTION ──────────────────────────────────────────────────────── + + #[test] + fn test_detect_connected_wins_over_test() { + // connectedAndroidTest contains "test" — ConnectedTest must win + let args = vec!["connectedDebugAndroidTest".to_string()]; + assert_eq!(detect_task(&args), GradlewTask::ConnectedTest); + } + + #[test] + fn test_detect_assemble_debug() { + let args = vec!["assembleDebug".to_string()]; + assert_eq!(detect_task(&args), GradlewTask::Build); + } + + #[test] + fn test_detect_test_debug_unit_test() { + let args = vec!["testDebugUnitTest".to_string()]; + assert_eq!(detect_task(&args), GradlewTask::Test); + } + + #[test] + fn test_detect_module_prefixed_task() { + let args = vec![":app:testDebugUnitTest".to_string()]; + assert_eq!(detect_task(&args), GradlewTask::Test); + } + + #[test] + fn test_detect_module_prefixed_assemble() { + let args = vec![":app:assembleDebug".to_string()]; + assert_eq!(detect_task(&args), GradlewTask::Build); + } + + #[test] + fn test_detect_flag_value_does_not_trigger_test() { + // -Pflavor=testRelease should NOT match Test when task is assemble + let args = vec![ + "assembleRelease".to_string(), + "-Pflavor=testRelease".to_string(), + ]; + assert_eq!(detect_task(&args), GradlewTask::Build); + } + + #[test] + fn test_detect_multi_task_uses_last() { + // clean assembleDebug → Build (last non-clean task) + let args = vec!["clean".to_string(), "assembleDebug".to_string()]; + assert_eq!(detect_task(&args), GradlewTask::Build); + } + + #[test] + fn test_detect_lint() { + let args = vec!["lint".to_string()]; + assert_eq!(detect_task(&args), GradlewTask::Lint); + } + + #[test] + fn test_detect_ktlint() { + let args = vec!["ktlintCheck".to_string()]; + assert_eq!(detect_task(&args), GradlewTask::Lint); + } + + #[test] + fn test_detect_bundle() { + let args = vec!["bundleRelease".to_string()]; + assert_eq!(detect_task(&args), GradlewTask::Build); + } + + #[test] + fn test_detect_unknown_passthrough() { + let args = vec!["signingReport".to_string()]; + assert_eq!(detect_task(&args), GradlewTask::Other); + } + + #[test] + fn test_detect_clean_alone_is_build() { + // "clean" alone → task.is_empty() after filtering → Build (strips task noise) + let args = vec!["clean".to_string()]; + assert_eq!(detect_task(&args), GradlewTask::Build); + } + + #[test] + fn test_detect_install_debug() { + let args = vec!["installDebug".to_string()]; + assert_eq!(detect_task(&args), GradlewTask::Build); + } + + #[test] + fn test_detect_uninstall_debug() { + // "uninstallDebug" contains "install" → Build + let args = vec!["uninstallDebug".to_string()]; + assert_eq!(detect_task(&args), GradlewTask::Build); + } + + #[test] + fn test_detect_clean_install() { + // clean installDebug → last non-clean task is installDebug → Build + let args = vec!["clean".to_string(), "installDebug".to_string()]; + assert_eq!(detect_task(&args), GradlewTask::Build); + } + + #[test] + fn test_detect_check() { + let args = vec!["check".to_string()]; + assert_eq!(detect_task(&args), GradlewTask::Test); + } + + #[test] + fn test_detect_dependencies() { + let args = vec!["dependencies".to_string()]; + assert_eq!(detect_task(&args), GradlewTask::Dependencies); + } + + #[test] + fn test_detect_dependencies_with_module() { + // :app:dependencies → contains "dependencies" + let args = vec![":app:dependencies".to_string()]; + assert_eq!(detect_task(&args), GradlewTask::Dependencies); + } + + // ── BUILD FILTER ────────────────────────────────────────────────────────── + + #[test] + fn test_build_success_strips_task_lines() { + let input = r#"> Configure project :app +> Task :app:preBuild UP-TO-DATE +> Task :app:generateDebugBuildConfig UP-TO-DATE +> Task :app:generateDebugResValues UP-TO-DATE +> Task :app:generateDebugResources UP-TO-DATE +> Task :app:mergeDebugResources UP-TO-DATE +> Task :app:processDebugManifest UP-TO-DATE +> Task :app:compileDebugKotlin UP-TO-DATE +> Task :app:compileDebugJavaWithJavac UP-TO-DATE +> Task :app:validateSigningDebug UP-TO-DATE +> Task :app:packageDebug UP-TO-DATE +> Task :app:assembleDebug UP-TO-DATE + +BUILD SUCCESSFUL in 1m 23s +42 actionable tasks: 42 executed"#; + + let filtered: Vec<&str> = input.lines().filter(|l| filter_build_line(l)).collect(); + let savings = 100.0 + - (count_tokens(&filtered.join("\n")) as f64 / count_tokens(input) as f64 * 100.0); + assert!( + savings >= 70.0, + "Expected ≥70% savings, got {:.1}%", + savings + ); + assert!(filtered.iter().any(|l| l.contains("BUILD SUCCESSFUL"))); + assert!(!filtered.iter().any(|l| l.starts_with("> Task :"))); + } + + #[test] + fn test_build_failure_preserves_errors_strips_try() { + let input = r#"> Task :app:compileDebugKotlin FAILED + +FAILURE: Build failed with an exception. + +* What went wrong: +e: /src/app/MainActivity.kt: (42, 5): Unresolved reference: MyService + +* Try: +> Run with --stacktrace option to get the stack trace. +> Run with --info or --debug option to get more log output. +> Get more help at https://help.gradle.org + +BUILD FAILED in 12s"#; + + let filtered: Vec<&str> = input.lines().filter(|l| filter_build_line(l)).collect(); + assert!(filtered.iter().any(|l| l.contains("Unresolved reference"))); + assert!(filtered.iter().any(|l| l.contains("BUILD FAILED"))); + assert!(!filtered.iter().any(|l| l.contains("Run with --stacktrace"))); + assert!(!filtered.iter().any(|l| l.contains("Get more help at"))); + } + + #[test] + fn test_build_filter_never_empty_on_success() { + let input = r#"> Task :app:assembleDebug UP-TO-DATE +BUILD SUCCESSFUL in 3s +1 actionable tasks: 1 up-to-date"#; + let filtered: Vec<&str> = input.lines().filter(|l| filter_build_line(l)).collect(); + assert!( + !filtered.is_empty(), + "Filter must not produce empty output on success" + ); + } + + #[test] + fn test_build_daemon_lines_stripped() { + let input = r#"Starting a Gradle Daemon (subsequent builds will be faster) +Daemon will be stopped at the end of the build after running out of JVM memory +> Task :app:assembleDebug +BUILD SUCCESSFUL in 5s"#; + let filtered: Vec<&str> = input.lines().filter(|l| filter_build_line(l)).collect(); + assert!(!filtered.iter().any(|l| l.contains("Daemon"))); + assert!(filtered.iter().any(|l| l.contains("BUILD SUCCESSFUL"))); + } + + #[test] + fn test_build_scan_url_preserved() { + let input = r#"> Task :app:assembleDebug +BUILD SUCCESSFUL in 5s +Publishing build scan... +https://gradle.com/s/abc123"#; + let filtered: Vec<&str> = input.lines().filter(|l| filter_build_line(l)).collect(); + assert!(filtered.iter().any(|l| l.contains("gradle.com/s/"))); + } + + // ── TEST FILTER ─────────────────────────────────────────────────────────── + + #[test] + fn test_unit_test_failures_preserved_passes_stripped() { + // Realistic test run with multi-frame JUnit stack traces + let input = r#"> Task :app:testDebugUnitTest +com.example.FooTest > test1 PASSED +com.example.FooTest > test2 PASSED +com.example.FooTest > test3 PASSED +com.example.FooTest > test4 PASSED +com.example.FooTest > test5 PASSED +com.example.FooTest > test6 PASSED +com.example.FooTest > test7 PASSED +com.example.FooTest > testBar FAILED + java.lang.AssertionError: expected:<3> but was:<-1> + at org.junit.Assert.fail(Assert.java:89) + at org.junit.Assert.assertEquals(Assert.java:197) + at com.example.FooTest.testBar(FooTest.kt:25) +com.example.FooTest > testQux PASSED + +10 tests completed, 1 failed"#; + let out = filter_test(input); + assert!( + out.contains("testBar FAILED"), + "FAILED test must be preserved" + ); + assert!( + out.contains("AssertionError"), + "Exception class must be preserved" + ); + assert!( + out.contains("FooTest.testBar"), + "User code frame must be preserved" + ); + assert!( + !out.contains("org.junit.Assert.fail"), + "Framework frames must be skipped" + ); + assert!(!out.contains("PASSED"), "PASSED tests must be stripped"); + assert!( + out.contains("10 tests completed, 1 failed"), + "Summary must be preserved" + ); + + let savings = 100.0 - (count_tokens(&out) as f64 / count_tokens(input) as f64 * 100.0); + assert!( + savings >= 60.0, + "Expected ≥60% savings, got {:.1}%", + savings + ); + } + + #[test] + fn test_unit_test_skips_framework_frames() { + let input = r#"com.example.CalcTest > testAdd FAILED + java.lang.AssertionError: expected:<5> but was:<3> + at org.junit.Assert.fail(Assert.java:89) + at org.junit.Assert.assertEquals(Assert.java:197) + at java.lang.reflect.Method.invoke(Method.java:498) + at com.example.CalcTest.testAdd(CalcTest.kt:10)"#; + let out = filter_test(input); + assert!( + out.contains("com.example.CalcTest.testAdd"), + "User code frame must be shown" + ); + assert!( + !out.contains("org.junit.Assert"), + "JUnit frames must be skipped" + ); + assert!( + !out.contains("java.lang.reflect"), + "Reflection frames must be skipped" + ); + } + + #[test] + fn test_unit_test_gradle_default_no_testlogging() { + // Gradle default: no per-test lines shown + let input = r#"> Task :app:testDebugUnitTest + +BUILD SUCCESSFUL in 15s +3 actionable tasks: 1 executed, 2 up-to-date"#; + let out = filter_test(input); + assert!( + out.contains("BUILD SUCCESSFUL") || out.contains("ok ✓"), + "Must output something on success" + ); + assert!(!out.is_empty(), "must not produce empty output"); + } + + #[test] + fn test_unit_test_report_path_preserved() { + let input = r#"There were failing tests. See the report at: file:///app/build/reports/tests/testDebugUnitTest/index.html +BUILD FAILED in 20s"#; + let out = filter_test(input); + assert!(out.contains("See the report at")); + assert!(out.contains("BUILD FAILED")); + } + + #[test] + fn test_try_section_stripped_from_test_output() { + let input = r#"com.example.FooTest > testBar FAILED + java.lang.AssertionError: expected true + +* Try: +> Run with --stacktrace option to get the stack trace. +> Run with --info or --debug option to get more log output. +> Get more help at https://help.gradle.org + +BUILD FAILED in 5s"#; + let out = filter_test(input); + assert!(!out.contains("Run with --stacktrace")); + assert!(!out.contains("Get more help at")); + assert!(out.contains("BUILD FAILED")); + } + + // ── CONNECTED TEST FILTER ───────────────────────────────────────────────── + + #[test] + fn test_connected_strips_device_noise() { + let input = r#"Starting 3 tests on Pixel_6_API_33(AVD) - 13 +INSTRUMENTATION_STATUS: numtests=3 +INSTRUMENTATION_STATUS_CODE: 1 +com.example.MainActivityTest > exampleTest[Pixel_6_API_33] FAILED + AssertionError: expected true +INSTRUMENTATION_STATUS_CODE: -2 +Tests run: 3, Failures: 1, Errors: 0, Skipped: 0"#; + let out = filter_connected(input); + assert!(out.contains("FAILED"), "FAILED test must be preserved"); + assert!( + !out.contains("INSTRUMENTATION_STATUS:"), + "Instrumentation lines must be stripped" + ); + assert!( + !out.contains("Starting 3 tests"), + "Starting tests line must be stripped" + ); + } + + #[test] + fn test_connected_no_device_error() { + let input = "com.android.builder.testing.api.DeviceException: No connected devices!"; + let out = filter_connected(input); + assert!( + out.contains("No connected devices"), + "Must show actionable error" + ); + } + + // ── LINT FILTER ─────────────────────────────────────────────────────────── + + #[test] + fn test_lint_preserves_violations() { + let input = r#"Wrote HTML report to file:/path/app/build/reports/lint-results-debug.html +src/main/java/com/example/MainActivity.kt:45: Error: Format string invalid [StringFormatInvalid] + String.format(getString(R.string.no_args), arg) + ^ +0 errors, 4 warnings"#; + let out = filter_lint(input); + assert!( + out.contains("StringFormatInvalid"), + "Lint violation must be preserved" + ); + assert!( + out.contains("0 errors, 4 warnings"), + "Summary must be preserved" + ); + assert!( + !out.contains("Wrote HTML report"), + "Report path must be stripped" + ); + } + + #[test] + fn test_lint_preserves_warnings() { + let input = r#"src/main/java/com/example/Utils.kt:89: Warning: HardcodedText [HardcodedText] + return "Hello World" + ~~~~~~~~~~~~~ +src/main/res/layout/activity_main.xml:15: Warning: Missing contentDescription attribute on image [ContentDescription] + Task :app:lint +BUILD SUCCESSFUL in 8s +3 actionable tasks: 1 executed, 2 up-to-date"#; + let out = filter_lint(input); + assert!(!out.is_empty(), "Must produce output on lint success"); + assert!( + out.contains("BUILD SUCCESSFUL") || out.contains("ok ✓"), + "Must indicate success" + ); + } + + // ── FIXTURE-BASED TESTS ────────────────────────────────────────────────── + + #[test] + fn test_build_fixture_token_savings() { + let input = include_str!("../../../tests/fixtures/gradlew_build_raw.txt"); + let filtered: Vec<&str> = input.lines().filter(|l| filter_build_line(l)).collect(); + let savings = 100.0 + - (count_tokens(&filtered.join("\n")) as f64 / count_tokens(input) as f64 * 100.0); + assert!( + savings >= 70.0, + "Build fixture: expected ≥70% savings, got {:.1}%", + savings + ); + assert!(!filtered.iter().any(|l| l.starts_with("> Task :"))); + } + + #[test] + fn test_build_failed_fixture_token_savings() { + let input = include_str!("../../../tests/fixtures/gradlew_build_failed_raw.txt"); + let filtered: Vec<&str> = input.lines().filter(|l| filter_build_line(l)).collect(); + assert!( + filtered.iter().any(|l| l.contains("BUILD FAILED")), + "BUILD FAILED must be preserved" + ); + assert!( + !filtered.iter().any(|l| l.contains("Run with --stacktrace")), + "Try section must be stripped" + ); + } + + #[test] + fn test_test_fixture_preserves_failures() { + let input = include_str!("../../../tests/fixtures/gradlew_test_raw.txt"); + let out = filter_test(input); + assert!(!out.contains("PASSED"), "PASSED tests must be stripped"); + let savings = 100.0 - (count_tokens(&out) as f64 / count_tokens(input) as f64 * 100.0); + assert!( + savings >= 60.0, + "Test fixture: expected ≥60% savings, got {:.1}%", + savings + ); + } + + #[test] + fn test_test_failed_fixture_shows_user_code() { + let input = include_str!("../../../tests/fixtures/gradlew_test_failed_raw.txt"); + let out = filter_test(input); + assert!(out.contains("FAILED"), "FAILED tests must be preserved"); + assert!( + out.contains("CalculatorTest.testSubtraction") + || out.contains("MainViewModelTest.loadDataError"), + "User code frame must be shown" + ); + assert!( + out.contains("5 tests completed, 2 failed"), + "Summary must be preserved" + ); + } + + #[test] + fn test_connected_fixture_token_savings() { + let input = include_str!("../../../tests/fixtures/gradlew_connected_raw.txt"); + let out = filter_connected(input); + assert!( + !out.contains("INSTRUMENTATION_STATUS"), + "Instrumentation lines must be stripped" + ); + } + + #[test] + fn test_lint_fixture_token_savings() { + let input = include_str!("../../../tests/fixtures/gradlew_lint_raw.txt"); + let out = filter_lint(input); + assert!( + !out.contains("Wrote HTML report"), + "Report lines must be stripped" + ); + let savings = 100.0 - (count_tokens(&out) as f64 / count_tokens(input) as f64 * 100.0); + assert!( + savings >= 60.0, + "Lint fixture: expected ≥60% savings, got {:.1}%", + savings + ); + } + + // ── OUTPUT FORMAT TESTS ────────────────────────────────────────────────── + + #[test] + fn test_build_success_output_format() { + let input = include_str!("../../../tests/fixtures/gradlew_build_raw.txt"); + let output: String = input + .lines() + .filter(|l| filter_build_line(l)) + .collect::>() + .join("\n"); + assert!(output.contains("BUILD SUCCESSFUL"), "should keep BUILD SUCCESSFUL"); + assert!(output.contains("actionable tasks"), "should keep actionable tasks line"); + assert!(!output.contains("> Task :"), "should strip task progress lines"); + } + + #[test] + fn test_build_failed_output_format() { + let input = include_str!("../../../tests/fixtures/gradlew_build_failed_raw.txt"); + let output: String = input + .lines() + .filter(|l| filter_build_line(l)) + .collect::>() + .join("\n"); + assert!(output.contains("BUILD FAILED"), "should keep BUILD FAILED"); + assert!(output.contains("FAILURE:"), "should keep failure header"); + assert!(output.contains("e: "), "should keep error lines"); + assert!(!output.contains("> Task :"), "should strip task progress lines"); + } + + #[test] + fn test_test_success_output_format() { + let input = include_str!("../../../tests/fixtures/gradlew_test_raw.txt"); + let output = filter_test(input); + assert!(output.contains("tests completed"), "should keep test summary"); + assert!(output.contains("BUILD SUCCESSFUL"), "should keep BUILD SUCCESSFUL"); + assert!(!output.contains("PASSED"), "should strip passing test lines"); + } + + #[test] + fn test_test_failed_output_format() { + let input = include_str!("../../../tests/fixtures/gradlew_test_failed_raw.txt"); + let output = filter_test(input); + assert!(output.contains("FAILED"), "should keep failed test names"); + assert!(output.contains("tests completed"), "should keep test summary"); + assert!(output.contains("BUILD FAILED"), "should keep BUILD FAILED"); + assert!(!output.contains("PASSED"), "should strip passing test lines"); + assert!(!output.contains("at org.junit."), "should strip framework frames"); + } + + #[test] + fn test_connected_output_format() { + let input = include_str!("../../../tests/fixtures/gradlew_connected_raw.txt"); + let output = filter_connected(input); + assert!(output.contains("BUILD SUCCESSFUL"), "should keep BUILD SUCCESSFUL"); + assert!(!output.contains("INSTRUMENTATION_STATUS"), "should strip instrumentation noise"); + } + + #[test] + fn test_lint_output_format() { + let input = include_str!("../../../tests/fixtures/gradlew_lint_raw.txt"); + let output = filter_lint(input); + assert!(output.contains("Error:"), "should keep error violations"); + assert!(output.contains("Warning:"), "should keep warning violations"); + assert!(output.contains("BUILD FAILED"), "should keep BUILD FAILED"); + assert!(!output.contains("Wrote HTML report"), "should strip report paths"); + } + + #[test] + fn test_lint_preserves_code_context() { + // Violation on line 1, then snippet + caret + explanation should all be kept + // (up to 3 context lines, until blank line separator). + let input = include_str!("../../../tests/fixtures/gradlew_lint_raw.txt"); + let output = filter_lint(input); + assert!( + output.contains("String.format(getString(R.string.template)"), + "code snippet after Android lint error must be preserved" + ); + assert!( + output.contains("This format string placeholder index"), + "explanation line after caret must be preserved" + ); + assert!( + output.contains("return \"Hello World\""), + "code snippet after Android lint warning must be preserved" + ); + assert!( + output.contains(" Task :app:compileDebugKotlin +w: /src/Foo.kt: (42, 5): Parameter 'unused' is never used +warning: [options] bootstrap class path not set +Warning: Gradle deprecation detected + +BUILD SUCCESSFUL in 4s"#; + let filtered: Vec<&str> = input.lines().filter(|l| filter_build_line(l)).collect(); + let output = filtered.join("\n"); + assert!(output.contains("w: "), "kotlinc warnings must be kept"); + assert!(output.contains("warning: [options]"), "javac warnings must be kept"); + assert!(output.contains("Warning: Gradle"), "Gradle warnings must be kept"); + assert!(output.contains("BUILD SUCCESSFUL"), "status must be kept"); + assert!(!output.contains("> Task :"), "task progress must be stripped"); + } + + // ── CHECK (BUILD FILTER ON MIXED OUTPUT) ──────────────────────────────── + + #[test] + fn test_build_filter_strips_configure_and_dokka_noise() { + let input = r#"Calculating task graph as no cached configuration is available for tasks: check + +> Configure project :core +class org.jetbrains.dokka.gradle.adapters.AndroidExtensionWrapper could not get Android Extension for project :core +[android-junit5]: Cannot configure Jacoco for this project + +> Task :core:preBuild UP-TO-DATE +> Task :core:preDebugBuild UP-TO-DATE +> Task :core:compileDebugKotlin UP-TO-DATE +> Task :samplev2:lintDebug FAILED +Lint found 8 errors, 21 warnings. First failure: + +/src/LogsScreen.kt:50: Error: Field requires API level 26 [NewApi] + val uiState = viewModel.uiState.collectAsState() + +[Incubating] Problems report is available at: file:///build/reports/problems.html + +Deprecated Gradle features were used in this build, making it incompatible with Gradle 10. + +You can use '--warning-mode all' to show the individual deprecation warnings. +388 actionable tasks: 97 executed + +FAILURE: Build failed with an exception. + +* What went wrong: +Execution failed for task ':samplev2:lintDebug'. + +* Try: +> Run with --stacktrace option to get the stack trace. + +BUILD FAILED in 3s"#; + + let filtered: Vec<&str> = input.lines().filter(|l| filter_build_line(l)).collect(); + let out = filtered.join("\n"); + + // Must keep + assert!( + out.contains("BUILD FAILED"), + "BUILD FAILED must be preserved" + ); + assert!(out.contains("FAILURE:"), "FAILURE line must be preserved"); + assert!( + out.contains("Execution failed"), + "Execution failed must be preserved" + ); + assert!( + out.contains("Lint found 8 error"), + "Lint summary must be preserved" + ); + assert!( + out.contains("Error: Field requires"), + "Lint error must be preserved" + ); + + // Must strip + assert!( + !out.contains("Configure project"), + "Configure lines must be stripped" + ); + assert!(!out.contains("dokka"), "Dokka warnings must be stripped"); + assert!( + !out.contains("android-junit5"), + "Plugin warnings must be stripped" + ); + assert!(!out.contains("> Task :"), "Task lines must be stripped"); + assert!(!out.contains("Incubating"), "Incubating must be stripped"); + assert!( + !out.contains("Deprecated Gradle"), + "Deprecated must be stripped" + ); + assert!( + !out.contains("Run with --stacktrace"), + "Try section must be stripped" + ); + + let savings = 100.0 - (count_tokens(&out) as f64 / count_tokens(input) as f64 * 100.0); + assert!( + savings >= 60.0, + "Expected ≥60% savings, got {:.1}%", + savings + ); + } + + // ── DEPENDENCIES FILTER ───────────────────────────────────────────────── + + #[test] + fn test_dependencies_filter_extracts_top_level() { + let input = r#"> Task :app:dependencies + +------------------------------------------------------------ +Project ':app' +------------------------------------------------------------ + +implementation - Implementation dependencies for the 'main' feature. ++--- org.jetbrains.kotlin:kotlin-stdlib:1.9.22 ++--- androidx.core:core-ktx:1.12.0 ++--- androidx.appcompat:appcompat:1.6.1 +| +--- androidx.annotation:annotation:1.3.0 +| +--- androidx.core:core:1.9.0 +| \--- androidx.cursoradapter:cursoradapter:1.0.0 ++--- com.google.android.material:material:1.11.0 +| +--- androidx.annotation:annotation:1.2.0 +| +--- androidx.appcompat:appcompat:1.1.0 +| \--- androidx.recyclerview:recyclerview:1.0.0 +\--- com.squareup.retrofit2:retrofit:2.9.0 + +--- com.squareup.okhttp3:okhttp:3.14.9 + \--- com.squareup.okio:okio:1.17.2 + +testImplementation - Test dependencies for the 'main' feature. ++--- junit:junit:4.13.2 +\--- org.mockito:mockito-core:5.8.0 + +BUILD SUCCESSFUL in 2s +1 actionable tasks: 1 executed"#; + + let out = filter_dependencies(input); + assert!( + out.contains("implementation (5):"), + "Must show config with count: {}", + out + ); + assert!( + out.contains("testImplementation (2):"), + "Must show test config: {}", + out + ); + assert!( + out.contains("kotlin-stdlib"), + "Must show top-level dep: {}", + out + ); + // Should NOT contain transitive deps + assert!( + !out.contains("cursoradapter"), + "Transitive deps must be stripped: {}", + out + ); + + let savings = 100.0 - (count_tokens(&out) as f64 / count_tokens(input) as f64 * 100.0); + assert!( + savings >= 60.0, + "Expected ≥60% savings, got {:.1}%", + savings + ); + } + + #[test] + fn test_dependencies_filter_empty() { + assert_eq!(filter_dependencies(""), ""); + } + + #[test] + fn test_dependencies_filter_no_deps() { + let input = r#"> Task :app:dependencies +No dependencies + +BUILD SUCCESSFUL in 1s"#; + let out = filter_dependencies(input); + assert!(out.contains("ok"), "Must show success: {}", out); + } + + // ── EDGE CASES ──────────────────────────────────────────────────────────── + + #[test] + fn test_filter_empty_input() { + assert_eq!(filter_test(""), ""); + assert_eq!(filter_connected(""), ""); + assert_eq!(filter_lint(""), ""); + assert_eq!(filter_dependencies(""), ""); + } + + #[test] + fn test_build_filter_empty_line_preserved() { + // Blank lines that separate error sections should be preserved + assert!(filter_build_line(""), "empty line must pass through"); + assert!( + filter_build_line(" "), + "whitespace-only line must pass through" + ); + } + + #[test] + fn test_verbose_flag_detection() { + // Verify that verbose flags are detected correctly + let stacktrace_args = ["assembleDebug".to_string(), "--stacktrace".to_string()]; + assert!(stacktrace_args.iter().any(|a| a == "--stacktrace" + || a == "--info" + || a == "--debug" + || a == "--full-stacktrace")); + + let info_args = ["testDebugUnitTest".to_string(), "--info".to_string()]; + assert!(info_args.iter().any(|a| a == "--stacktrace" + || a == "--info" + || a == "--debug" + || a == "--full-stacktrace")); + } + + #[test] + fn test_build_token_savings() { + let input = r#"Starting a Gradle Daemon (subsequent builds will be faster) +> Configure project :app +> Task :app:preBuild UP-TO-DATE +> Task :app:generateDebugBuildConfig UP-TO-DATE +> Task :app:generateDebugResValues UP-TO-DATE +> Task :app:generateDebugResources UP-TO-DATE +> Task :app:mergeDebugResources UP-TO-DATE +> Task :app:processDebugManifest UP-TO-DATE +> Task :app:compileDebugKotlin UP-TO-DATE +> Task :app:compileDebugJavaWithJavac UP-TO-DATE +> Task :app:compileDebugSources UP-TO-DATE +> Task :app:mergeDebugShaders UP-TO-DATE +> Task :app:compileDebugShaders UP-TO-DATE +> Task :app:generateDebugAssets UP-TO-DATE +> Task :app:mergeDebugAssets UP-TO-DATE +> Task :app:mergeDebugJniLibFolders UP-TO-DATE +> Task :app:validateSigningDebug UP-TO-DATE +> Task :app:packageDebug UP-TO-DATE +> Task :app:assembleDebug UP-TO-DATE + +BUILD SUCCESSFUL in 3s +18 actionable tasks: 18 up-to-date"#; + + let filtered: Vec<&str> = input.lines().filter(|l| filter_build_line(l)).collect(); + let token_savings = 100.0 + - (count_tokens(&filtered.join("\n")) as f64 / count_tokens(input) as f64 * 100.0); + assert!( + token_savings >= 70.0, + "Expected ≥70% token savings, got {:.1}%", + token_savings + ); + } + + #[test] + fn test_is_framework_frame() { + assert!(is_framework_frame( + "at org.junit.Assert.fail(Assert.java:89)" + )); + assert!(is_framework_frame( + "at junit.framework.Assert.fail(Assert.java:50)" + )); + assert!(is_framework_frame( + "at java.lang.reflect.Method.invoke(Method.java:498)" + )); + assert!(is_framework_frame("at org.gradle.api.internal.tasks.testing.SuiteTestClassProcessor.processTestClass(SuiteTestClassProcessor.java:51)")); + assert!(!is_framework_frame( + "at com.example.FooTest.testBar(FooTest.kt:25)" + )); + assert!(!is_framework_frame( + "at com.example.MyApp.doSomething(MyApp.java:100)" + )); + } +} diff --git a/src/cmds/jvm/mod.rs b/src/cmds/jvm/mod.rs new file mode 100644 index 0000000000..cf7f18aa48 --- /dev/null +++ b/src/cmds/jvm/mod.rs @@ -0,0 +1 @@ +automod::dir!(pub "src/cmds/jvm"); diff --git a/src/cmds/mod.rs b/src/cmds/mod.rs index 1eca0b84cc..d064182b3d 100644 --- a/src/cmds/mod.rs +++ b/src/cmds/mod.rs @@ -5,6 +5,7 @@ pub mod dotnet; pub mod git; pub mod go; pub mod js; +pub mod jvm; pub mod python; pub mod ruby; pub mod rust; diff --git a/src/cmds/python/mypy_cmd.rs b/src/cmds/python/mypy_cmd.rs index b0b6a43f3f..69bc7173af 100644 --- a/src/cmds/python/mypy_cmd.rs +++ b/src/cmds/python/mypy_cmd.rs @@ -181,7 +181,7 @@ pub fn filter_mypy_output(output: &str) -> String { // Files sorted by error count (most errors first) let mut files_sorted: Vec<_> = by_file.iter().collect(); - files_sorted.sort_by(|a, b| b.1.len().cmp(&a.1.len())); + files_sorted.sort_by_key(|b| std::cmp::Reverse(b.1.len())); for (file, file_errors) in &files_sorted { result.push_str(&format!("{} ({} errors)\n", file, file_errors.len())); diff --git a/src/cmds/rust/cargo_cmd.rs b/src/cmds/rust/cargo_cmd.rs index e85cb134c1..04f3f6324e 100644 --- a/src/cmds/rust/cargo_cmd.rs +++ b/src/cmds/rust/cargo_cmd.rs @@ -103,10 +103,7 @@ impl BlockHandler for CargoBuildHandler { self.finished_line = Some(trimmed.to_string()); return true; } - if line.starts_with("warning:") - && line.contains("generated") - && line.contains("warning") - { + if line.starts_with("warning:") && line.contains("generated") && line.contains("warning") { return true; } if (line.starts_with("error:") || line.starts_with("error[")) @@ -1133,7 +1130,11 @@ fn filter_cargo_clippy(output: &str) -> String { line.to_string() } } else { - let prefix = if is_error_line { "error: " } else { "warning: " }; + let prefix = if is_error_line { + "error: " + } else { + "warning: " + }; line.strip_prefix(prefix).unwrap_or(line).to_string() }; } else if line.trim_start().starts_with("--> ") { @@ -1194,7 +1195,7 @@ fn filter_cargo_clippy(output: &str) -> String { // Sort warning rules by frequency let mut rule_counts: Vec<_> = by_rule.iter().collect(); - rule_counts.sort_by(|a, b| b.1.len().cmp(&a.1.len())); + rule_counts.sort_by_key(|b| std::cmp::Reverse(b.1.len())); for (rule, locations) in rule_counts.iter().take(15) { result.push_str(&format!(" {} ({}x)\n", rule, locations.len())); @@ -1632,10 +1633,22 @@ error[E0308]: mismatched types error: aborting due to 1 previous error "#; let result = filter_cargo_clippy(output); - assert!(result.contains("cargo clippy: 1 errors, 0 warnings"), "got: {}", result); - assert!(result.contains("error[E0308]: mismatched types"), "got: {}", result); + assert!( + result.contains("cargo clippy: 1 errors, 0 warnings"), + "got: {}", + result + ); + assert!( + result.contains("error[E0308]: mismatched types"), + "got: {}", + result + ); assert!(result.contains("src/main.rs:10:5"), "got: {}", result); - assert!(result.contains("expected `i32`, found `&str`"), "got: {}", result); + assert!( + result.contains("expected `i32`, found `&str`"), + "got: {}", + result + ); } #[test] diff --git a/src/cmds/system/format_cmd.rs b/src/cmds/system/format_cmd.rs index e147640ea5..6e37c15968 100644 --- a/src/cmds/system/format_cmd.rs +++ b/src/cmds/system/format_cmd.rs @@ -83,17 +83,13 @@ pub fn run(args: &[String], verbose: u8) -> Result { let user_args = args[start_idx..].to_vec(); match formatter.as_str() { - "black" => { - // Inject --check if not present for check mode - if !user_args.iter().any(|a| a == "--check" || a == "--diff") { - cmd.arg("--check"); - } + // Inject --check if not present for check mode + "black" if !user_args.iter().any(|a| a == "--check" || a == "--diff") => { + cmd.arg("--check"); } - "ruff" => { - // Add "format" subcommand if not present - if user_args.is_empty() || !user_args[0].starts_with("format") { - cmd.arg("format"); - } + // Add "format" subcommand if not present + "ruff" if user_args.is_empty() || !user_args[0].starts_with("format") => { + cmd.arg("format"); } _ => {} } diff --git a/src/core/config.rs b/src/core/config.rs index 8d3da72750..ed0f00c6c9 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -29,6 +29,28 @@ pub struct HooksConfig { /// Survives `rtk init -g` re-runs since config.toml is user-owned. #[serde(default)] pub exclude_commands: Vec, + + /// Wrapper prefixes that should be transparently stripped before routing + /// to a filter, then re-prepended on the rewrite. For example, with + /// `transparent_prefixes = ["docker exec mycontainer"]`, the command + /// `docker exec mycontainer git status` rewrites to + /// `docker exec mycontainer rtk git status` instead of passing through + /// unrewritten. + /// + /// Useful for any per-project env wrapper that sits in front of every + /// command — e.g. `docker exec mycontainer`, `direnv exec .`, `poetry run`, + /// or `bundle exec`. + /// + /// Matching is literal, not pattern-based. Configure the exact concrete + /// prefix you actually use, such as `docker exec mycontainer`. + /// + /// Extends the built-in `SHELL_PREFIX_BUILTINS` list (`noglob`, `command`, + /// `builtin`, `exec`, `nocorrect`) with user- or organization-specific + /// wrappers. Matching is strict: a configured prefix `"foo bar"` matches + /// a command that starts with `"foo bar "` (or strictly equals `"foo bar"`), + /// not anything else. + #[serde(default)] + pub transparent_prefixes: Vec, } #[derive(Debug, Serialize, Deserialize)] @@ -201,6 +223,32 @@ exclude_commands = ["curl", "gh"] fn test_hooks_config_default_empty() { let config = Config::default(); assert!(config.hooks.exclude_commands.is_empty()); + assert!(config.hooks.transparent_prefixes.is_empty()); + } + + #[test] + fn test_hooks_config_transparent_prefixes_deserialize() { + let toml = r#" +[hooks] +transparent_prefixes = ["direnv exec .", "nix develop --command"] +"#; + let config: Config = toml::from_str(toml).expect("valid toml"); + assert_eq!( + config.hooks.transparent_prefixes, + vec!["direnv exec .", "nix develop --command"] + ); + } + + #[test] + fn test_hooks_config_transparent_prefixes_missing_is_empty() { + // Older configs that predate this field must still parse. + let toml = r#" +[hooks] +exclude_commands = ["curl"] +"#; + let config: Config = toml::from_str(toml).expect("valid toml"); + assert_eq!(config.hooks.exclude_commands, vec!["curl"]); + assert!(config.hooks.transparent_prefixes.is_empty()); } #[test] diff --git a/src/core/telemetry_cmd.rs b/src/core/telemetry_cmd.rs index 70ba30db76..e14b53b3aa 100644 --- a/src/core/telemetry_cmd.rs +++ b/src/core/telemetry_cmd.rs @@ -55,7 +55,7 @@ fn run_status() -> Result<()> { println!(); println!("Data controller: RTK AI Labs, contact@rtk-ai.app"); - println!("Details: https://github.com/rtk-ai/rtk/blob/main/docs/TELEMETRY.md"); + println!("Details: https://github.com/rtk-ai/rtk/blob/master/docs/TELEMETRY.md"); Ok(()) } @@ -73,7 +73,7 @@ fn run_enable() -> Result<()> { eprintln!(); eprintln!(" What: command names (not arguments), token savings, OS, version"); eprintln!(" Who: RTK AI Labs, contact@rtk-ai.app"); - eprintln!(" Details: https://github.com/rtk-ai/rtk/blob/main/docs/TELEMETRY.md"); + eprintln!(" Details: https://github.com/rtk-ai/rtk/blob/master/docs/TELEMETRY.md"); eprintln!(); eprint!("Enable anonymous telemetry? [y/N] "); diff --git a/src/core/utils.rs b/src/core/utils.rs index feae1fb6db..a73eba9bcf 100644 --- a/src/core/utils.rs +++ b/src/core/utils.rs @@ -343,19 +343,13 @@ pub fn resolved_command(name: &str) -> Command { // On Windows, resolution failure likely means a .CMD/.BAT wrapper // wasn't found — always warn so users have a signal. // On Unix, this is less common; only log in debug builds. - #[cfg(target_os = "windows")] - eprintln!( - "rtk: Failed to resolve '{}' via PATH, falling back to direct exec: {}", - name, e - ); - #[cfg(not(target_os = "windows"))] - { - #[cfg(debug_assertions)] + if cfg!(any(target_os = "windows", debug_assertions)) { eprintln!( "rtk: Failed to resolve '{}' via PATH, falling back to direct exec: {}", name, e ); } + Command::new(name) } } diff --git a/src/discover/mod.rs b/src/discover/mod.rs index e5b4a87b8a..98d3eae765 100644 --- a/src/discover/mod.rs +++ b/src/discover/mod.rs @@ -11,11 +11,13 @@ use std::collections::HashMap; use provider::{ClaudeProvider, SessionProvider}; use registry::{ - category_avg_tokens, classify_command, has_rtk_disabled_prefix, split_command_chain, - strip_disabled_prefix, Classification, + category_avg_tokens, classify_command, split_command_chain, strip_disabled_prefix, + Classification, }; use report::{DiscoverReport, SupportedEntry, UnsupportedEntry}; +use crate::discover::registry::prefix_contains_rtk_disabled; + /// Aggregation bucket for supported commands. struct SupportedBucket { rtk_equivalent: &'static str, @@ -96,8 +98,8 @@ pub fn run( total_commands += 1; // Detect RTK_DISABLED= bypass before classification - if has_rtk_disabled_prefix(part) { - let actual_cmd = strip_disabled_prefix(part); + let (env_prefix, actual_cmd) = strip_disabled_prefix(part); + if prefix_contains_rtk_disabled(env_prefix) { // Only count if the underlying command is one RTK supports match classify_command(actual_cmd) { Classification::Supported { .. } => { @@ -226,7 +228,7 @@ pub fn run( .collect(); // Sort by estimated savings descending - supported.sort_by(|a, b| b.estimated_savings_tokens.cmp(&a.estimated_savings_tokens)); + supported.sort_by_key(|b| std::cmp::Reverse(b.estimated_savings_tokens)); let mut unsupported: Vec = unsupported_map .into_iter() @@ -238,7 +240,7 @@ pub fn run( .collect(); // Sort by count descending - unsupported.sort_by(|a, b| b.count.cmp(&a.count)); + unsupported.sort_by_key(|b| std::cmp::Reverse(b.count)); // Build RTK_DISABLED examples sorted by frequency (top 5) let rtk_disabled_examples: Vec = { @@ -261,6 +263,7 @@ pub fn run( parse_errors, rtk_disabled_count, rtk_disabled_examples, + agent_status: report::AgentIntegrationStatus::detect(), }; match format { diff --git a/src/discover/provider.rs b/src/discover/provider.rs index 08b4ddc8f6..ee76c7a169 100644 --- a/src/discover/provider.rs +++ b/src/discover/provider.rs @@ -45,47 +45,25 @@ impl ClaudeProvider { /// Get the base directory for Claude Code projects. fn projects_dir() -> Result { let home = dirs::home_dir().context("could not determine home directory")?; - let dir = home.join(CLAUDE_DIR).join("projects"); - if !dir.exists() { - anyhow::bail!( - "Claude Code projects directory not found: {}\nMake sure Claude Code has been used at least once.", - dir.display() - ); - } - Ok(dir) + Ok(Self::projects_dir_for_home(&home)) } - /// Encode a filesystem path to Claude Code's directory name format. - /// - /// Claude Code replaces `/`, `.`, `_`, `\`, and any non-ASCII character - /// with `-` when computing the project directory slug under `~/.claude/projects/`. - /// - /// `/Users/foo/bar` → `-Users-foo-bar` - /// `/Users/first.last/bar` → `-Users-first-last-bar` - /// `/home/chris/2_project` → `-home-chris-2-project` - /// `C:\Users\foo\bar` → `C:-Users-foo-bar` - pub fn encode_project_path(path: &str) -> String { - const SANITIZED_CHARS: &[char] = &['/', '.', '_', '\\']; - - path.chars() - .map(|c| { - if !c.is_ascii() || SANITIZED_CHARS.contains(&c) { - '-' - } else { - c - } - }) - .collect() + fn projects_dir_for_home(home: &Path) -> PathBuf { + home.join(CLAUDE_DIR).join("projects") } -} -impl SessionProvider for ClaudeProvider { - fn discover_sessions( - &self, + fn discover_sessions_in_projects_dir( + projects_dir: &Path, project_filter: Option<&str>, since_days: Option, ) -> Result> { - let projects_dir = Self::projects_dir()?; + if !projects_dir + .try_exists() + .with_context(|| format!("failed to access {}", projects_dir.display()))? + { + return Ok(Vec::new()); + } + let cutoff = since_days.map(|days| { SystemTime::now() .checked_sub(Duration::from_secs(days * 86400)) @@ -95,7 +73,7 @@ impl SessionProvider for ClaudeProvider { let mut sessions = Vec::new(); // List project directories - let entries = fs::read_dir(&projects_dir) + let entries = fs::read_dir(projects_dir) .with_context(|| format!("failed to read {}", projects_dir.display()))?; for entry in entries.flatten() { @@ -141,6 +119,40 @@ impl SessionProvider for ClaudeProvider { Ok(sessions) } + /// Encode a filesystem path to Claude Code's directory name format. + /// + /// Claude Code replaces `/`, `.`, `_`, `\`, and any non-ASCII character + /// with `-` when computing the project directory slug under `~/.claude/projects/`. + /// + /// `/Users/foo/bar` → `-Users-foo-bar` + /// `/Users/first.last/bar` → `-Users-first-last-bar` + /// `/home/chris/2_project` → `-home-chris-2-project` + /// `C:\Users\foo\bar` → `C:-Users-foo-bar` + pub fn encode_project_path(path: &str) -> String { + const SANITIZED_CHARS: &[char] = &['/', '.', '_', '\\']; + + path.chars() + .map(|c| { + if !c.is_ascii() || SANITIZED_CHARS.contains(&c) { + '-' + } else { + c + } + }) + .collect() + } +} + +impl SessionProvider for ClaudeProvider { + fn discover_sessions( + &self, + project_filter: Option<&str>, + since_days: Option, + ) -> Result> { + let projects_dir = Self::projects_dir()?; + Self::discover_sessions_in_projects_dir(&projects_dir, project_filter, since_days) + } + fn extract_commands(&self, path: &Path) -> Result> { let file = fs::File::open(path).with_context(|| format!("failed to open {}", path.display()))?; @@ -409,6 +421,56 @@ mod tests { assert!(encoded.contains("Sites")); } + #[test] + fn test_discover_sessions_missing_projects_dir_returns_empty() { + let temp_home = tempfile::tempdir().unwrap(); + let missing_projects_dir = temp_home.path().join(CLAUDE_DIR).join("projects"); + + let sessions = ClaudeProvider::discover_sessions_in_projects_dir( + &missing_projects_dir, + None, + Some(30), + ) + .unwrap(); + + assert!(sessions.is_empty()); + } + + #[test] + fn test_discover_sessions_applies_project_filter() { + let projects_dir = tempfile::tempdir().unwrap(); + let matching_project = projects_dir.path().join("-Users-test-rtk"); + let other_project = projects_dir.path().join("-Users-test-other"); + std::fs::create_dir_all(&matching_project).unwrap(); + std::fs::create_dir_all(&other_project).unwrap(); + std::fs::write(matching_project.join("matching.jsonl"), "").unwrap(); + std::fs::write(other_project.join("other.jsonl"), "").unwrap(); + + let sessions = ClaudeProvider::discover_sessions_in_projects_dir( + projects_dir.path(), + Some("rtk"), + None, + ) + .unwrap(); + + assert_eq!(sessions.len(), 1); + assert_eq!( + sessions[0].file_name().and_then(|name| name.to_str()), + Some("matching.jsonl") + ); + } + + #[test] + fn test_discover_sessions_existing_non_directory_returns_error() { + let projects_file = tempfile::NamedTempFile::new().unwrap(); + + let err = + ClaudeProvider::discover_sessions_in_projects_dir(projects_file.path(), None, None) + .unwrap_err(); + + assert!(err.to_string().contains("failed to read")); + } + #[test] fn test_extract_output_content() { let jsonl = make_jsonl(&[ diff --git a/src/discover/registry.rs b/src/discover/registry.rs index 3921089cae..bb7b11f2a4 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -382,23 +382,26 @@ fn strip_absolute_path(cmd: &str) -> String { } } -/// Check if a command has RTK_DISABLED= prefix in its env prefix portion. -pub fn has_rtk_disabled_prefix(cmd: &str) -> bool { - let trimmed = cmd.trim(); - let stripped = ENV_PREFIX.replace(trimmed, ""); - let prefix_len = trimmed.len() - stripped.len(); - let prefix_part = &trimmed[..prefix_len]; +pub fn prefix_contains_rtk_disabled(prefix_part: &str) -> bool { prefix_part.contains("RTK_DISABLED=") } -/// Strip RTK_DISABLED=X and other env prefixes, return the actual command. -pub fn strip_disabled_prefix(cmd: &str) -> &str { +/// Check if a command has RTK_DISABLED= prefix in its env prefix portion. +pub fn cmd_has_rtk_disabled_prefix(cmd: &str) -> bool { + let (prefix_part, _) = strip_disabled_prefix(cmd); + prefix_contains_rtk_disabled(prefix_part) +} + +/// Strip RTK_DISABLED=X and other env prefixes, returns `(env_prefix, actual_command)`. +pub fn strip_disabled_prefix(cmd: &str) -> (&str, &str) { let trimmed = cmd.trim(); let stripped = ENV_PREFIX.replace(trimmed, ""); // stripped is a Cow that borrows from trimmed when no replacement happens. // We need to return a &str into the original, so compute the offset. let prefix_len = trimmed.len() - stripped.len(); - trimmed[prefix_len..].trim_start() + let prefix_part = &trimmed[..prefix_len]; + let rest = trimmed[prefix_len..].trim(); + (prefix_part, rest) } fn strip_trailing_redirects(cmd: &str) -> (&str, &str) { @@ -442,7 +445,25 @@ fn strip_trailing_redirects(cmd: &str) -> (&str, &str) { /// Handles compound commands (`&&`, `||`, `;`) by rewriting each segment independently. /// For pipes (`|`), only rewrites the left-hand command (pipe targets stay raw), /// but continues rewriting segments after subsequent `&&`/`||`/`;` operators. -pub fn rewrite_command(cmd: &str, excluded: &[String]) -> Option { +/// Also strips user-configured transparent wrapper prefixes +/// (`[hooks].transparent_prefixes` in `config.toml`) before routing. +/// +/// A transparent prefix is a wrapper command that doesn't change *what* is +/// being run, only *how* it's run — e.g. `docker exec mycontainer`, +/// `direnv exec .`, `poetry run`, or `bundle exec`. Stripping it lets the inner +/// command match a filter; the prefix is then re-prepended to the rewrite. The +/// built-in [`SHELL_PREFIX_BUILTINS`] (`noglob`, `command`, `builtin`, `exec`, +/// `nocorrect`) are always applied in addition to user-configured prefixes. +/// +/// Matching is strict: a configured prefix `"foo bar"` matches a command that +/// starts with `"foo bar "` (or strictly equals `"foo bar"`), not anything +/// else. Matching is literal, not pattern-based: configure the exact concrete +/// prefix you use. +pub fn rewrite_command( + cmd: &str, + excluded: &[String], + transparent_prefixes: &[String], +) -> Option { let trimmed = cmd.trim(); if trimmed.is_empty() { return None; @@ -453,6 +474,7 @@ pub fn rewrite_command(cmd: &str, excluded: &[String]) -> Option { } let compiled = compile_exclude_patterns(excluded); + let normalized_prefixes = normalize_transparent_prefixes(transparent_prefixes); // Simple (non-compound) already-RTK command — return as-is. // For compound commands that start with "rtk" (e.g. "rtk git add . && cargo test"), @@ -466,11 +488,15 @@ pub fn rewrite_command(cmd: &str, excluded: &[String]) -> Option { return Some(trimmed.to_string()); } - rewrite_compound(trimmed, &compiled) + rewrite_compound(trimmed, &compiled, &normalized_prefixes) } /// Rewrite a compound command (with `&&`, `||`, `;`, `|`) by rewriting each segment. -fn rewrite_compound(cmd: &str, excluded: &[ExcludePattern]) -> Option { +fn rewrite_compound( + cmd: &str, + excluded: &[ExcludePattern], + transparent_prefixes: &[String], +) -> Option { let tokens = tokenize(cmd); let mut result = String::with_capacity(cmd.len() + 32); let mut any_changed = false; @@ -483,7 +509,8 @@ fn rewrite_compound(cmd: &str, excluded: &[ExcludePattern]) -> Option { match tok.kind { TokenKind::Operator => { let seg = cmd[seg_start..tok.offset].trim(); - let rewritten = rewrite_segment(seg, excluded).unwrap_or_else(|| seg.to_string()); + let rewritten = rewrite_segment(seg, excluded, transparent_prefixes) + .unwrap_or_else(|| seg.to_string()); if rewritten != seg { any_changed = true; } @@ -513,7 +540,8 @@ fn rewrite_compound(cmd: &str, excluded: &[ExcludePattern]) -> Option { let rewritten = if is_pipe_incompatible { seg.to_string() } else { - rewrite_segment(seg, excluded).unwrap_or_else(|| seg.to_string()) + rewrite_segment(seg, excluded, transparent_prefixes) + .unwrap_or_else(|| seg.to_string()) }; if rewritten != seg { any_changed = true; @@ -541,7 +569,8 @@ fn rewrite_compound(cmd: &str, excluded: &[ExcludePattern]) -> Option { } TokenKind::Shellism if tok.value == "&" => { let seg = cmd[seg_start..tok.offset].trim(); - let rewritten = rewrite_segment(seg, excluded).unwrap_or_else(|| seg.to_string()); + let rewritten = rewrite_segment(seg, excluded, transparent_prefixes) + .unwrap_or_else(|| seg.to_string()); if rewritten != seg { any_changed = true; } @@ -557,7 +586,8 @@ fn rewrite_compound(cmd: &str, excluded: &[ExcludePattern]) -> Option { } let seg = cmd[seg_start..].trim(); - let rewritten = rewrite_segment(seg, excluded).unwrap_or_else(|| seg.to_string()); + let rewritten = + rewrite_segment(seg, excluded, transparent_prefixes).unwrap_or_else(|| seg.to_string()); if rewritten != seg { any_changed = true; } @@ -631,15 +661,33 @@ fn compile_exclude_patterns(patterns: &[String]) -> Vec { "rtk: warning: invalid exclude_commands pattern '{}': {}", pattern, e ); - ExcludePattern::Prefix(pattern.clone()) + ExcludePattern::Prefix(trimmed.to_string()) } }) }) .collect() } -fn rewrite_segment(seg: &str, excluded: &[ExcludePattern]) -> Option { - rewrite_segment_inner(seg, excluded, 0) +fn normalize_transparent_prefixes(prefixes: &[String]) -> Vec { + let mut normalized: Vec = prefixes + .iter() + .map(|prefix| prefix.trim()) + .filter(|prefix| !prefix.is_empty()) + .map(str::to_string) + .collect(); + + // Match longer wrappers first so `docker exec mycontainer` wins over `docker`. + normalized.sort_by(|a, b| b.len().cmp(&a.len()).then_with(|| a.cmp(b))); + normalized.dedup(); + normalized +} + +fn rewrite_segment( + seg: &str, + excluded: &[ExcludePattern], + transparent_prefixes: &[String], +) -> Option { + rewrite_segment_inner(seg, excluded, transparent_prefixes, 0) } fn is_excluded(cmd: &str, excluded: &[ExcludePattern]) -> bool { @@ -649,7 +697,12 @@ fn is_excluded(cmd: &str, excluded: &[ExcludePattern]) -> bool { }) } -fn rewrite_segment_inner(seg: &str, excluded: &[ExcludePattern], depth: usize) -> Option { +fn rewrite_segment_inner( + seg: &str, + excluded: &[ExcludePattern], + transparent_prefixes: &[String], + depth: usize, +) -> Option { let trimmed = seg.trim(); if trimmed.is_empty() { return None; @@ -659,12 +712,40 @@ fn rewrite_segment_inner(seg: &str, excluded: &[ExcludePattern], depth: usize) - return None; } + let (env_prefix, rest_after_env) = strip_disabled_prefix(trimmed); + if !env_prefix.is_empty() { + // #345: RTK_DISABLED=1 in env prefix → skip rewrite entirely + // #508: warn on stderr so agents learn to stop overusing it + if env_prefix.contains("RTK_DISABLED=") { + eprintln!( + "[rtk] RTK_DISABLED=1 detected — skipping filter for this command. \ + Remove RTK_DISABLED=1 to restore token savings." + ); + return None; + } + let rewritten = + rewrite_segment_inner(rest_after_env, excluded, transparent_prefixes, depth + 1)?; + return Some(format!("{}{}", env_prefix, rewritten)); + } + for &prefix in SHELL_PREFIX_BUILTINS { if let Some(rest) = strip_word_prefix(trimmed, prefix) { if rest.is_empty() { return None; } - return rewrite_segment_inner(rest, excluded, depth + 1) + return rewrite_segment_inner(rest, excluded, transparent_prefixes, depth + 1) + .map(|rewritten| format!("{} {}", prefix, rewritten)); + } + } + + // User-configured wrapper prefixes (e.g. `docker exec mycontainer`). Same + // strip-recurse-reprepend contract as the builtin list above. + for prefix in transparent_prefixes { + if let Some(rest) = strip_word_prefix(trimmed, prefix) { + if rest.is_empty() { + return None; + } + return rewrite_segment_inner(rest, excluded, transparent_prefixes, depth + 1) .map(|rewritten| format!("{} {}", prefix, rewritten)); } } @@ -708,29 +789,13 @@ fn rewrite_segment_inner(seg: &str, excluded: &[ExcludePattern], depth: usize) - // Find the matching rule (rtk_cmd values are unique across all rules) let rule = RULES.iter().find(|r| r.rtk_cmd == rtk_equivalent)?; - // Extract env prefix (sudo, env VAR=val, etc.) - let stripped_cow = ENV_PREFIX.replace(cmd_part, ""); - let env_prefix_len = cmd_part.len() - stripped_cow.len(); - let env_prefix = &cmd_part[..env_prefix_len]; - let cmd_clean = stripped_cow.trim(); - - // #345: RTK_DISABLED=1 in env prefix → skip rewrite entirely - // #508: warn on stderr so agents learn to stop overusing it - if has_rtk_disabled_prefix(cmd_part) { - eprintln!( - "[rtk] RTK_DISABLED=1 detected — skipping filter for this command. \ - Remove RTK_DISABLED=1 to restore token savings." - ); - return None; - } - - if let Some(parts) = parse_golangci_run_parts(cmd_clean) { + if let Some(parts) = parse_golangci_run_parts(cmd_part) { let rewritten = if parts.global_segment.is_empty() { - format!("{}rtk golangci-lint {}", env_prefix, parts.run_segment) + format!("rtk golangci-lint {}", parts.run_segment) } else { format!( - "{}rtk golangci-lint {} {}", - env_prefix, parts.global_segment, parts.run_segment + "rtk golangci-lint {} {}", + parts.global_segment, parts.run_segment ) }; return Some(rewritten); @@ -739,7 +804,7 @@ fn rewrite_segment_inner(seg: &str, excluded: &[ExcludePattern], depth: usize) - // #196: gh with --json/--jq/--template produces structured output that // rtk gh would corrupt — skip rewrite so the caller gets raw JSON. if rule.rtk_cmd == "rtk gh" { - let args_lower = cmd_clean.to_lowercase(); + let args_lower = cmd_part.to_lowercase(); if args_lower.contains("--json") || args_lower.contains("--jq") || args_lower.contains("--template") @@ -750,11 +815,11 @@ fn rewrite_segment_inner(seg: &str, excluded: &[ExcludePattern], depth: usize) - // Try each rewrite prefix (longest first) with word-boundary check for &prefix in rule.rewrite_prefixes { - if let Some(rest) = strip_word_prefix(cmd_clean, prefix) { + if let Some(rest) = strip_word_prefix(cmd_part, prefix) { let rewritten = if rest.is_empty() { - format!("{}{}{}", env_prefix, rule.rtk_cmd, redirect_suffix) + format!("{}{}", rule.rtk_cmd, redirect_suffix) } else { - format!("{}{} {}{}", env_prefix, rule.rtk_cmd, rest, redirect_suffix) + format!("{} {}{}", rule.rtk_cmd, rest, redirect_suffix) }; return Some(rewritten); } @@ -783,6 +848,10 @@ mod tests { use super::super::report::RtkStatus; use super::*; + fn rewrite_command_no_prefixes(cmd: &str, excluded: &[String]) -> Option { + super::rewrite_command(cmd, excluded, &[]) + } + #[test] fn test_classify_git_status() { assert_eq!( @@ -825,7 +894,7 @@ mod tests { #[test] fn test_rewrite_yadm_status() { assert_eq!( - rewrite_command("yadm status", &[]), + rewrite_command_no_prefixes("yadm status", &[]), Some("rtk git status".to_string()) ); } @@ -1127,7 +1196,7 @@ mod tests { #[test] fn test_rewrite_git_status() { assert_eq!( - rewrite_command("git status", &[]), + rewrite_command_no_prefixes("git status", &[]), Some("rtk git status".into()) ); } @@ -1135,7 +1204,7 @@ mod tests { #[test] fn test_rewrite_git_log() { assert_eq!( - rewrite_command("git log -10", &[]), + rewrite_command_no_prefixes("git log -10", &[]), Some("rtk git log -10".into()) ); } @@ -1145,7 +1214,7 @@ mod tests { #[test] fn test_rewrite_git_dash_c_status() { assert_eq!( - rewrite_command("git -C /path/to/repo status", &[]), + rewrite_command_no_prefixes("git -C /path/to/repo status", &[]), Some("rtk git -C /path/to/repo status".into()) ); } @@ -1153,7 +1222,7 @@ mod tests { #[test] fn test_rewrite_git_dash_c_log() { assert_eq!( - rewrite_command("git -C /tmp/myrepo log --oneline -5", &[]), + rewrite_command_no_prefixes("git -C /tmp/myrepo log --oneline -5", &[]), Some("rtk git -C /tmp/myrepo log --oneline -5".into()) ); } @@ -1161,7 +1230,7 @@ mod tests { #[test] fn test_rewrite_git_dash_c_diff() { assert_eq!( - rewrite_command("git -C /home/user/project diff --name-only", &[]), + rewrite_command_no_prefixes("git -C /home/user/project diff --name-only", &[]), Some("rtk git -C /home/user/project diff --name-only".into()) ); } @@ -1185,7 +1254,7 @@ mod tests { #[test] fn test_rewrite_cargo_test() { assert_eq!( - rewrite_command("cargo test", &[]), + rewrite_command_no_prefixes("cargo test", &[]), Some("rtk cargo test".into()) ); } @@ -1193,7 +1262,7 @@ mod tests { #[test] fn test_rewrite_compound_and() { assert_eq!( - rewrite_command("git add . && cargo test", &[]), + rewrite_command_no_prefixes("git add . && cargo test", &[]), Some("rtk git add . && rtk cargo test".into()) ); } @@ -1201,7 +1270,7 @@ mod tests { #[test] fn test_rewrite_compound_three_segments() { assert_eq!( - rewrite_command( + rewrite_command_no_prefixes( "cargo fmt --all && cargo clippy --all-targets && cargo test", &[] ), @@ -1212,7 +1281,7 @@ mod tests { #[test] fn test_rewrite_already_rtk() { assert_eq!( - rewrite_command("rtk git status", &[]), + rewrite_command_no_prefixes("rtk git status", &[]), Some("rtk git status".into()) ); } @@ -1220,7 +1289,7 @@ mod tests { #[test] fn test_rewrite_background_single_amp() { assert_eq!( - rewrite_command("cargo test & git status", &[]), + rewrite_command_no_prefixes("cargo test & git status", &[]), Some("rtk cargo test & rtk git status".into()) ); } @@ -1228,7 +1297,7 @@ mod tests { #[test] fn test_rewrite_background_unsupported_right() { assert_eq!( - rewrite_command("cargo test & htop", &[]), + rewrite_command_no_prefixes("cargo test & htop", &[]), Some("rtk cargo test & htop".into()) ); } @@ -1237,25 +1306,25 @@ mod tests { fn test_rewrite_background_does_not_affect_double_amp() { // `&&` must still work after adding `&` support assert_eq!( - rewrite_command("cargo test && git status", &[]), + rewrite_command_no_prefixes("cargo test && git status", &[]), Some("rtk cargo test && rtk git status".into()) ); } #[test] fn test_rewrite_unsupported_returns_none() { - assert_eq!(rewrite_command("htop", &[]), None); + assert_eq!(rewrite_command_no_prefixes("htop", &[]), None); } #[test] fn test_rewrite_ignored_cd() { - assert_eq!(rewrite_command("cd /tmp", &[]), None); + assert_eq!(rewrite_command_no_prefixes("cd /tmp", &[]), None); } #[test] fn test_rewrite_with_env_prefix() { assert_eq!( - rewrite_command("GIT_SSH_COMMAND=ssh git push", &[]), + rewrite_command_no_prefixes("GIT_SSH_COMMAND=ssh git push", &[]), Some("GIT_SSH_COMMAND=ssh rtk git push".into()) ); } @@ -1281,7 +1350,7 @@ mod tests { ]; for command in commands { assert_eq!( - rewrite_command(&format!("{command} --noEmit"), &[]), + rewrite_command_no_prefixes(&format!("{command} --noEmit"), &[]), Some("rtk tsc --noEmit".into()), "Failed for command: {}", command @@ -1292,7 +1361,7 @@ mod tests { #[test] fn test_rewrite_cat_file() { assert_eq!( - rewrite_command("cat src/main.rs", &[]), + rewrite_command_no_prefixes("cat src/main.rs", &[]), Some("rtk read src/main.rs".into()) ); } @@ -1300,19 +1369,22 @@ mod tests { #[test] fn test_rewrite_cat_with_incompatible_flags_skipped() { // cat flags with different semantics than rtk read — skip rewrite - assert_eq!(rewrite_command("cat -A file.cpp", &[]), None); - assert_eq!(rewrite_command("cat -v file.txt", &[]), None); - assert_eq!(rewrite_command("cat -e file.txt", &[]), None); - assert_eq!(rewrite_command("cat -t file.txt", &[]), None); - assert_eq!(rewrite_command("cat -s file.txt", &[]), None); - assert_eq!(rewrite_command("cat --show-all file.txt", &[]), None); + assert_eq!(rewrite_command_no_prefixes("cat -A file.cpp", &[]), None); + assert_eq!(rewrite_command_no_prefixes("cat -v file.txt", &[]), None); + assert_eq!(rewrite_command_no_prefixes("cat -e file.txt", &[]), None); + assert_eq!(rewrite_command_no_prefixes("cat -t file.txt", &[]), None); + assert_eq!(rewrite_command_no_prefixes("cat -s file.txt", &[]), None); + assert_eq!( + rewrite_command_no_prefixes("cat --show-all file.txt", &[]), + None + ); } #[test] fn test_rewrite_cat_with_compatible_flags() { // cat -n (line numbers) maps to rtk read -n — allow rewrite assert_eq!( - rewrite_command("cat -n file.txt", &[]), + rewrite_command_no_prefixes("cat -n file.txt", &[]), Some("rtk read -n file.txt".into()) ); } @@ -1320,7 +1392,7 @@ mod tests { #[test] fn test_rewrite_rg_pattern() { assert_eq!( - rewrite_command("rg \"fn main\"", &[]), + rewrite_command_no_prefixes("rg \"fn main\"", &[]), Some("rtk grep \"fn main\"".into()) ); } @@ -1346,7 +1418,7 @@ mod tests { ]; for command in commands { assert_eq!( - rewrite_command(&format!("{command} test"), &[]), + rewrite_command_no_prefixes(&format!("{command} test"), &[]), Some("rtk playwright test".into()), "Failed for command: {}", command @@ -1375,7 +1447,7 @@ mod tests { ]; for command in commands { assert_eq!( - rewrite_command(&format!("{command} --turbo"), &[]), + rewrite_command_no_prefixes(&format!("{command} --turbo"), &[]), Some("rtk next --turbo".into()), "Failed for command: {}", command @@ -1387,7 +1459,7 @@ mod tests { fn test_rewrite_pipe_first_only() { // After a pipe, the filter command stays raw assert_eq!( - rewrite_command("git log -10 | grep feat", &[]), + rewrite_command_no_prefixes("git log -10 | grep feat", &[]), Some("rtk git log -10 | grep feat".into()) ); } @@ -1397,41 +1469,47 @@ mod tests { // find in a pipe should NOT be rewritten — rtk find output format // is incompatible with pipe consumers like xargs (#439) assert_eq!( - rewrite_command("find . -name '*.rs' | xargs grep 'fn run'", &[]), + rewrite_command_no_prefixes("find . -name '*.rs' | xargs grep 'fn run'", &[]), None ); } #[test] fn test_rewrite_find_pipe_xargs_wc() { - assert_eq!(rewrite_command("find src -type f | wc -l", &[]), None); + assert_eq!( + rewrite_command_no_prefixes("find src -type f | wc -l", &[]), + None + ); } #[test] fn test_rewrite_find_no_pipe_still_rewritten() { // find WITHOUT a pipe should still be rewritten assert_eq!( - rewrite_command("find . -name '*.rs'", &[]), + rewrite_command_no_prefixes("find . -name '*.rs'", &[]), Some("rtk find . -name '*.rs'".into()) ); } #[test] fn test_rewrite_heredoc_returns_none() { - assert_eq!(rewrite_command("cat <<'EOF'\nfoo\nEOF", &[]), None); + assert_eq!( + rewrite_command_no_prefixes("cat <<'EOF'\nfoo\nEOF", &[]), + None + ); } #[test] fn test_rewrite_empty_returns_none() { - assert_eq!(rewrite_command("", &[]), None); - assert_eq!(rewrite_command(" ", &[]), None); + assert_eq!(rewrite_command_no_prefixes("", &[]), None); + assert_eq!(rewrite_command_no_prefixes(" ", &[]), None); } #[test] fn test_rewrite_mixed_compound_partial() { // First segment already RTK, second gets rewritten assert_eq!( - rewrite_command("rtk git add . && cargo test", &[]), + rewrite_command_no_prefixes("rtk git add . && cargo test", &[]), Some("rtk git add . && rtk cargo test".into()) ); } @@ -1441,27 +1519,33 @@ mod tests { #[test] fn test_rewrite_rtk_disabled_curl() { assert_eq!( - rewrite_command("RTK_DISABLED=1 curl https://example.com", &[]), + rewrite_command_no_prefixes("RTK_DISABLED=1 curl https://example.com", &[]), None ); } #[test] fn test_rewrite_rtk_disabled_git_status() { - assert_eq!(rewrite_command("RTK_DISABLED=1 git status", &[]), None); + assert_eq!( + rewrite_command_no_prefixes("RTK_DISABLED=1 git status", &[]), + None + ); } #[test] fn test_rewrite_rtk_disabled_multi_env() { assert_eq!( - rewrite_command("FOO=1 RTK_DISABLED=1 git status", &[]), + rewrite_command_no_prefixes("FOO=1 RTK_DISABLED=1 git status", &[]), None ); } #[test] fn test_rewrite_rtk_disabled_warns_on_stderr() { - assert_eq!(rewrite_command("RTK_DISABLED=1 git status", &[]), None); + assert_eq!( + rewrite_command_no_prefixes("RTK_DISABLED=1 git status", &[]), + None + ); } #[test] @@ -1506,7 +1590,7 @@ mod tests { #[test] fn test_rewrite_non_rtk_disabled_env_still_rewrites() { assert_eq!( - rewrite_command("SOME_VAR=1 git status", &[]), + rewrite_command_no_prefixes("SOME_VAR=1 git status", &[]), Some("SOME_VAR=1 rtk git status".into()) ); } @@ -1514,7 +1598,7 @@ mod tests { #[test] fn test_rewrite_env_quoted_value_with_spaces() { assert_eq!( - rewrite_command( + rewrite_command_no_prefixes( r#"GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no" git push"#, &[] ), @@ -1525,7 +1609,7 @@ mod tests { #[test] fn test_rewrite_env_single_quoted_value_with_spaces() { assert_eq!( - rewrite_command("EDITOR='vim -u NONE' git commit", &[]), + rewrite_command_no_prefixes("EDITOR='vim -u NONE' git commit", &[]), Some("EDITOR='vim -u NONE' rtk git commit".into()) ); } @@ -1533,7 +1617,7 @@ mod tests { #[test] fn test_rewrite_env_quoted_plus_unquoted() { assert_eq!( - rewrite_command(r#"FOO="bar baz" BAR=1 git status"#, &[]), + rewrite_command_no_prefixes(r#"FOO="bar baz" BAR=1 git status"#, &[]), Some(r#"FOO="bar baz" BAR=1 rtk git status"#.into()) ); } @@ -1541,7 +1625,7 @@ mod tests { #[test] fn test_rewrite_env_escaped_quotes_in_value() { assert_eq!( - rewrite_command(r#"FOO="he said \"hello\"" git status"#, &[]), + rewrite_command_no_prefixes(r#"FOO="he said \"hello\"" git status"#, &[]), Some(r#"FOO="he said \"hello\"" rtk git status"#.into()) ); } @@ -1564,7 +1648,7 @@ mod tests { #[test] fn test_rewrite_redirect_2_gt_amp_1_with_pipe() { assert_eq!( - rewrite_command("cargo test 2>&1 | head", &[]), + rewrite_command_no_prefixes("cargo test 2>&1 | head", &[]), Some("rtk cargo test 2>&1 | head".into()) ); } @@ -1572,7 +1656,7 @@ mod tests { #[test] fn test_rewrite_redirect_2_gt_amp_1_trailing() { assert_eq!( - rewrite_command("cargo test 2>&1", &[]), + rewrite_command_no_prefixes("cargo test 2>&1", &[]), Some("rtk cargo test 2>&1".into()) ); } @@ -1581,7 +1665,7 @@ mod tests { fn test_rewrite_redirect_plain_2_devnull() { // 2>/dev/null has no `&`, never broken — non-regression assert_eq!( - rewrite_command("git status 2>/dev/null", &[]), + rewrite_command_no_prefixes("git status 2>/dev/null", &[]), Some("rtk git status 2>/dev/null".into()) ); } @@ -1589,7 +1673,7 @@ mod tests { #[test] fn test_rewrite_redirect_2_gt_amp_1_with_and() { assert_eq!( - rewrite_command("cargo test 2>&1 && echo done", &[]), + rewrite_command_no_prefixes("cargo test 2>&1 && echo done", &[]), Some("rtk cargo test 2>&1 && echo done".into()) ); } @@ -1597,7 +1681,7 @@ mod tests { #[test] fn test_rewrite_redirect_amp_gt_devnull() { assert_eq!( - rewrite_command("cargo test &>/dev/null", &[]), + rewrite_command_no_prefixes("cargo test &>/dev/null", &[]), Some("rtk cargo test &>/dev/null".into()) ); } @@ -1606,7 +1690,7 @@ mod tests { fn test_rewrite_redirect_double() { // Double redirect: only last one stripped, but full command rewrites correctly assert_eq!( - rewrite_command("git status 2>&1 >/dev/null", &[]), + rewrite_command_no_prefixes("git status 2>&1 >/dev/null", &[]), Some("rtk git status 2>&1 >/dev/null".into()) ); } @@ -1615,7 +1699,7 @@ mod tests { fn test_rewrite_redirect_fd_close() { // 2>&- (close stderr fd) assert_eq!( - rewrite_command("git status 2>&-", &[]), + rewrite_command_no_prefixes("git status 2>&-", &[]), Some("rtk git status 2>&-".into()) ); } @@ -1624,7 +1708,7 @@ mod tests { fn test_rewrite_redirect_quotes_not_stripped() { // Redirect-like chars inside quotes should NOT be stripped // Known limitation: apostrophes cause conservative no-strip (safe fallback) - let result = rewrite_command("git commit -m \"it's fixed\" 2>&1", &[]); + let result = rewrite_command_no_prefixes("git commit -m \"it's fixed\" 2>&1", &[]); assert!( result.is_some(), "Should still rewrite even with apostrophe" @@ -1635,7 +1719,7 @@ mod tests { fn test_rewrite_background_amp_non_regression() { // background `&` must still work after redirect fix assert_eq!( - rewrite_command("cargo test & git status", &[]), + rewrite_command_no_prefixes("cargo test & git status", &[]), Some("rtk cargo test & rtk git status".into()) ); } @@ -1646,7 +1730,7 @@ mod tests { fn test_rewrite_head_numeric_flag() { // head -20 file → rtk read file --max-lines 20 (not rtk read -20 file) assert_eq!( - rewrite_command("head -20 src/main.rs", &[]), + rewrite_command_no_prefixes("head -20 src/main.rs", &[]), Some("rtk read src/main.rs --max-lines 20".into()) ); } @@ -1654,7 +1738,7 @@ mod tests { #[test] fn test_rewrite_head_lines_long_flag() { assert_eq!( - rewrite_command("head --lines=50 src/lib.rs", &[]), + rewrite_command_no_prefixes("head --lines=50 src/lib.rs", &[]), Some("rtk read src/lib.rs --max-lines 50".into()) ); } @@ -1663,7 +1747,7 @@ mod tests { fn test_rewrite_head_no_flag_still_rewrites() { // plain `head file` → `rtk read file` (no numeric flag) assert_eq!( - rewrite_command("head src/main.rs", &[]), + rewrite_command_no_prefixes("head src/main.rs", &[]), Some("rtk read src/main.rs".into()) ); } @@ -1671,13 +1755,16 @@ mod tests { #[test] fn test_rewrite_head_other_flag_skipped() { // head -c 100 file: unsupported flag, skip rewriting - assert_eq!(rewrite_command("head -c 100 src/main.rs", &[]), None); + assert_eq!( + rewrite_command_no_prefixes("head -c 100 src/main.rs", &[]), + None + ); } #[test] fn test_rewrite_tail_numeric_flag() { assert_eq!( - rewrite_command("tail -20 src/main.rs", &[]), + rewrite_command_no_prefixes("tail -20 src/main.rs", &[]), Some("rtk read src/main.rs --tail-lines 20".into()) ); } @@ -1685,7 +1772,7 @@ mod tests { #[test] fn test_rewrite_tail_n_space_flag() { assert_eq!( - rewrite_command("tail -n 12 src/lib.rs", &[]), + rewrite_command_no_prefixes("tail -n 12 src/lib.rs", &[]), Some("rtk read src/lib.rs --tail-lines 12".into()) ); } @@ -1693,7 +1780,7 @@ mod tests { #[test] fn test_rewrite_tail_lines_long_flag() { assert_eq!( - rewrite_command("tail --lines=7 src/lib.rs", &[]), + rewrite_command_no_prefixes("tail --lines=7 src/lib.rs", &[]), Some("rtk read src/lib.rs --tail-lines 7".into()) ); } @@ -1701,19 +1788,22 @@ mod tests { #[test] fn test_rewrite_tail_lines_space_flag() { assert_eq!( - rewrite_command("tail --lines 7 src/lib.rs", &[]), + rewrite_command_no_prefixes("tail --lines 7 src/lib.rs", &[]), Some("rtk read src/lib.rs --tail-lines 7".into()) ); } #[test] fn test_rewrite_tail_other_flag_skipped() { - assert_eq!(rewrite_command("tail -c 100 src/main.rs", &[]), None); + assert_eq!( + rewrite_command_no_prefixes("tail -c 100 src/main.rs", &[]), + None + ); } #[test] fn test_rewrite_tail_plain_file_skipped() { - assert_eq!(rewrite_command("tail src/main.rs", &[]), None); + assert_eq!(rewrite_command_no_prefixes("tail src/main.rs", &[]), None); } // --- Issue #1362: head/tail with multiple files falls back to native command --- @@ -1727,35 +1817,50 @@ mod tests { #[test] fn test_rewrite_head_numeric_flag_multi_file_skipped() { - assert_eq!(rewrite_command("head -3 /tmp/a /tmp/b /tmp/c", &[]), None); + assert_eq!( + rewrite_command_no_prefixes("head -3 /tmp/a /tmp/b /tmp/c", &[]), + None + ); } #[test] fn test_rewrite_head_lines_long_flag_multi_file_skipped() { assert_eq!( - rewrite_command("head --lines=50 src/main.rs src/lib.rs", &[]), + rewrite_command_no_prefixes("head --lines=50 src/main.rs src/lib.rs", &[]), None ); } #[test] fn test_rewrite_tail_numeric_flag_multi_file_skipped() { - assert_eq!(rewrite_command("tail -20 a.log b.log", &[]), None); + assert_eq!( + rewrite_command_no_prefixes("tail -20 a.log b.log", &[]), + None + ); } #[test] fn test_rewrite_tail_n_space_flag_multi_file_skipped() { - assert_eq!(rewrite_command("tail -n 12 a.log b.log c.log", &[]), None); + assert_eq!( + rewrite_command_no_prefixes("tail -n 12 a.log b.log c.log", &[]), + None + ); } #[test] fn test_rewrite_tail_lines_eq_multi_file_skipped() { - assert_eq!(rewrite_command("tail --lines=7 a.log b.log", &[]), None); + assert_eq!( + rewrite_command_no_prefixes("tail --lines=7 a.log b.log", &[]), + None + ); } #[test] fn test_rewrite_tail_lines_space_multi_file_skipped() { - assert_eq!(rewrite_command("tail --lines 7 a.log b.log", &[]), None); + assert_eq!( + rewrite_command_no_prefixes("tail --lines 7 a.log b.log", &[]), + None + ); } // --- New registry entries --- @@ -1807,7 +1912,7 @@ mod tests { #[test] fn test_rewrite_glab_mr_list() { assert_eq!( - rewrite_command("glab mr list", &[]), + rewrite_command_no_prefixes("glab mr list", &[]), Some("rtk glab mr list".into()) ); } @@ -1815,7 +1920,7 @@ mod tests { #[test] fn test_rewrite_glab_ci_status() { assert_eq!( - rewrite_command("glab ci status", &[]), + rewrite_command_no_prefixes("glab ci status", &[]), Some("rtk glab ci status".into()) ); } @@ -1911,7 +2016,7 @@ mod tests { #[test] fn test_rewrite_tree() { assert_eq!( - rewrite_command("tree src/", &[]), + rewrite_command_no_prefixes("tree src/", &[]), Some("rtk tree src/".into()) ); } @@ -1919,7 +2024,7 @@ mod tests { #[test] fn test_rewrite_diff() { assert_eq!( - rewrite_command("diff file1.txt file2.txt", &[]), + rewrite_command_no_prefixes("diff file1.txt file2.txt", &[]), Some("rtk diff file1.txt file2.txt".into()) ); } @@ -1927,7 +2032,7 @@ mod tests { #[test] fn test_rewrite_gh_release() { assert_eq!( - rewrite_command("gh release list", &[]), + rewrite_command_no_prefixes("gh release list", &[]), Some("rtk gh release list".into()) ); } @@ -1935,7 +2040,7 @@ mod tests { #[test] fn test_rewrite_cargo_install() { assert_eq!( - rewrite_command("cargo install rtk", &[]), + rewrite_command_no_prefixes("cargo install rtk", &[]), Some("rtk cargo install rtk".into()) ); } @@ -1943,7 +2048,7 @@ mod tests { #[test] fn test_rewrite_kubectl_describe() { assert_eq!( - rewrite_command("kubectl describe pod mypod", &[]), + rewrite_command_no_prefixes("kubectl describe pod mypod", &[]), Some("rtk kubectl describe pod mypod".into()) ); } @@ -1951,7 +2056,7 @@ mod tests { #[test] fn test_rewrite_docker_run() { assert_eq!( - rewrite_command("docker run --rm ubuntu bash", &[]), + rewrite_command_no_prefixes("docker run --rm ubuntu bash", &[]), Some("rtk docker run --rm ubuntu bash".into()) ); } @@ -1972,7 +2077,7 @@ mod tests { #[test] fn test_rewrite_swift_test() { assert_eq!( - rewrite_command("swift test --parallel", &[]), + rewrite_command_no_prefixes("swift test --parallel", &[]), Some("rtk swift test --parallel".into()) ); } @@ -1982,7 +2087,7 @@ mod tests { #[test] fn test_rewrite_docker_compose_ps() { assert_eq!( - rewrite_command("docker compose ps", &[]), + rewrite_command_no_prefixes("docker compose ps", &[]), Some("rtk docker compose ps".into()) ); } @@ -1990,7 +2095,7 @@ mod tests { #[test] fn test_rewrite_docker_compose_logs() { assert_eq!( - rewrite_command("docker compose logs web", &[]), + rewrite_command_no_prefixes("docker compose logs web", &[]), Some("rtk docker compose logs web".into()) ); } @@ -1998,25 +2103,31 @@ mod tests { #[test] fn test_rewrite_docker_compose_build() { assert_eq!( - rewrite_command("docker compose build", &[]), + rewrite_command_no_prefixes("docker compose build", &[]), Some("rtk docker compose build".into()) ); } #[test] fn test_rewrite_docker_compose_up_skipped() { - assert_eq!(rewrite_command("docker compose up -d", &[]), None); + assert_eq!( + rewrite_command_no_prefixes("docker compose up -d", &[]), + None + ); } #[test] fn test_rewrite_docker_compose_down_skipped() { - assert_eq!(rewrite_command("docker compose down", &[]), None); + assert_eq!( + rewrite_command_no_prefixes("docker compose down", &[]), + None + ); } #[test] fn test_rewrite_docker_compose_config_skipped() { assert_eq!( - rewrite_command("docker compose -f foo.yaml config --services", &[]), + rewrite_command_no_prefixes("docker compose -f foo.yaml config --services", &[]), None ); } @@ -2070,7 +2181,7 @@ mod tests { #[test] fn test_rewrite_aws() { assert_eq!( - rewrite_command("aws s3 ls", &[]), + rewrite_command_no_prefixes("aws s3 ls", &[]), Some("rtk aws s3 ls".into()) ); } @@ -2078,7 +2189,7 @@ mod tests { #[test] fn test_rewrite_aws_ec2() { assert_eq!( - rewrite_command("aws ec2 describe-instances --region us-east-1", &[]), + rewrite_command_no_prefixes("aws ec2 describe-instances --region us-east-1", &[]), Some("rtk aws ec2 describe-instances --region us-east-1".into()) ); } @@ -2086,7 +2197,7 @@ mod tests { #[test] fn test_rewrite_psql() { assert_eq!( - rewrite_command("psql -U postgres -d mydb", &[]), + rewrite_command_no_prefixes("psql -U postgres -d mydb", &[]), Some("rtk psql -U postgres -d mydb".into()) ); } @@ -2162,7 +2273,7 @@ mod tests { #[test] fn test_rewrite_ruff_check() { assert_eq!( - rewrite_command("ruff check .", &[]), + rewrite_command_no_prefixes("ruff check .", &[]), Some("rtk ruff check .".into()) ); } @@ -2170,7 +2281,7 @@ mod tests { #[test] fn test_rewrite_ruff_format() { assert_eq!( - rewrite_command("ruff format src/", &[]), + rewrite_command_no_prefixes("ruff format src/", &[]), Some("rtk ruff format src/".into()) ); } @@ -2178,7 +2289,7 @@ mod tests { #[test] fn test_rewrite_pytest() { assert_eq!( - rewrite_command("pytest tests/", &[]), + rewrite_command_no_prefixes("pytest tests/", &[]), Some("rtk pytest tests/".into()) ); } @@ -2186,7 +2297,7 @@ mod tests { #[test] fn test_rewrite_python_m_pytest() { assert_eq!( - rewrite_command("python -m pytest -x tests/", &[]), + rewrite_command_no_prefixes("python -m pytest -x tests/", &[]), Some("rtk pytest -x tests/".into()) ); } @@ -2194,7 +2305,7 @@ mod tests { #[test] fn test_rewrite_pip_list() { assert_eq!( - rewrite_command("pip list", &[]), + rewrite_command_no_prefixes("pip list", &[]), Some("rtk pip list".into()) ); } @@ -2202,7 +2313,7 @@ mod tests { #[test] fn test_rewrite_pip_outdated() { assert_eq!( - rewrite_command("pip outdated", &[]), + rewrite_command_no_prefixes("pip outdated", &[]), Some("rtk pip outdated".into()) ); } @@ -2210,7 +2321,7 @@ mod tests { #[test] fn test_rewrite_uv_pip_list() { assert_eq!( - rewrite_command("uv pip list", &[]), + rewrite_command_no_prefixes("uv pip list", &[]), Some("rtk pip list".into()) ); } @@ -2330,7 +2441,7 @@ mod tests { #[test] fn test_rewrite_go_test() { assert_eq!( - rewrite_command("go test ./...", &[]), + rewrite_command_no_prefixes("go test ./...", &[]), Some("rtk go test ./...".into()) ); } @@ -2338,7 +2449,7 @@ mod tests { #[test] fn test_rewrite_go_build() { assert_eq!( - rewrite_command("go build ./...", &[]), + rewrite_command_no_prefixes("go build ./...", &[]), Some("rtk go build ./...".into()) ); } @@ -2346,7 +2457,7 @@ mod tests { #[test] fn test_rewrite_go_vet() { assert_eq!( - rewrite_command("go vet ./...", &[]), + rewrite_command_no_prefixes("go vet ./...", &[]), Some("rtk go vet ./...".into()) ); } @@ -2354,7 +2465,7 @@ mod tests { #[test] fn test_rewrite_golangci_lint() { assert_eq!( - rewrite_command("golangci-lint run ./...", &[]), + rewrite_command_no_prefixes("golangci-lint run ./...", &[]), Some("rtk golangci-lint run ./...".into()) ); } @@ -2362,7 +2473,7 @@ mod tests { #[test] fn test_rewrite_golangci_lint_with_flag_before_run() { assert_eq!( - rewrite_command("golangci-lint -v run ./...", &[]), + rewrite_command_no_prefixes("golangci-lint -v run ./...", &[]), Some("rtk golangci-lint -v run ./...".into()) ); } @@ -2370,7 +2481,7 @@ mod tests { #[test] fn test_rewrite_golangci_lint_with_value_flag_before_run() { assert_eq!( - rewrite_command("golangci-lint --color never run ./...", &[]), + rewrite_command_no_prefixes("golangci-lint --color never run ./...", &[]), Some("rtk golangci-lint --color never run ./...".into()) ); } @@ -2378,7 +2489,7 @@ mod tests { #[test] fn test_rewrite_golangci_lint_with_inline_value_flag_before_run() { assert_eq!( - rewrite_command("golangci-lint --color=never run ./...", &[]), + rewrite_command_no_prefixes("golangci-lint --color=never run ./...", &[]), Some("rtk golangci-lint --color=never run ./...".into()) ); } @@ -2386,7 +2497,7 @@ mod tests { #[test] fn test_rewrite_golangci_lint_with_inline_config_flag_before_run() { assert_eq!( - rewrite_command("golangci-lint --config=foo.yml run ./...", &[]), + rewrite_command_no_prefixes("golangci-lint --config=foo.yml run ./...", &[]), Some("rtk golangci-lint --config=foo.yml run ./...".into()) ); } @@ -2394,7 +2505,7 @@ mod tests { #[test] fn test_rewrite_env_prefixed_golangci_lint_with_value_flag_before_run() { assert_eq!( - rewrite_command("FOO=1 golangci-lint --color never run ./...", &[]), + rewrite_command_no_prefixes("FOO=1 golangci-lint --color never run ./...", &[]), Some("FOO=1 rtk golangci-lint --color never run ./...".into()) ); } @@ -2402,19 +2513,22 @@ mod tests { #[test] fn test_rewrite_env_prefixed_golangci_lint_with_inline_value_flag_before_run() { assert_eq!( - rewrite_command("FOO=1 golangci-lint --color=never run ./...", &[]), + rewrite_command_no_prefixes("FOO=1 golangci-lint --color=never run ./...", &[]), Some("FOO=1 rtk golangci-lint --color=never run ./...".into()) ); } #[test] fn test_rewrite_bare_golangci_lint_skips_compact_wrapper() { - assert_eq!(rewrite_command("golangci-lint", &[]), None); + assert_eq!(rewrite_command_no_prefixes("golangci-lint", &[]), None); } #[test] fn test_rewrite_other_golangci_lint_subcommand_skips_compact_wrapper() { - assert_eq!(rewrite_command("golangci-lint version", &[]), None); + assert_eq!( + rewrite_command_no_prefixes("golangci-lint version", &[]), + None + ); } // --- JS/TS tooling --- @@ -2526,7 +2640,7 @@ mod tests { ]; for command in commands { assert_eq!( - rewrite_command(command, &[]), + rewrite_command_no_prefixes(command, &[]), Some("rtk lint".into()), "Failed for command: {}", command @@ -2619,7 +2733,7 @@ mod tests { ]; for command in commands { assert_eq!( - rewrite_command(command, &[]), + rewrite_command_no_prefixes(command, &[]), Some("rtk jest".into()), "Failed for command: {}", command @@ -2712,7 +2826,7 @@ mod tests { ]; for command in commands { assert_eq!( - rewrite_command(command, &[]), + rewrite_command_no_prefixes(command, &[]), Some("rtk vitest".into()), "Failed for command: {}", command @@ -2775,7 +2889,7 @@ mod tests { ]; for command in commands { assert_eq!( - rewrite_command(format!("{command} migrate dev").as_str(), &[]), + rewrite_command_no_prefixes(format!("{command} migrate dev").as_str(), &[]), Some("rtk prisma migrate dev".into()), "Failed for command: {}", command @@ -2804,7 +2918,7 @@ mod tests { ]; for command in commands { assert_eq!( - rewrite_command(format!("{command} --check src/").as_str(), &[]), + rewrite_command_no_prefixes(format!("{command} --check src/").as_str(), &[]), Some("rtk prettier --check src/".into()), "Failed for command: {}", command @@ -2826,7 +2940,7 @@ mod tests { ]; for command in commands { assert_eq!( - rewrite_command(format!("pnpm {command}").as_str(), &[]), + rewrite_command_no_prefixes(format!("pnpm {command}").as_str(), &[]), Some(format!("rtk pnpm {command}")), "Failed for command: pnpm {}", command @@ -2839,7 +2953,7 @@ mod tests { let commands = vec!["exec", "run", "run-script", "x"]; for command in commands { assert_eq!( - rewrite_command(format!("npm {command}").as_str(), &[]), + rewrite_command_no_prefixes(format!("npm {command}").as_str(), &[]), Some(format!("rtk npm {command}")), "Failed for bare command: npm {}", command @@ -2850,11 +2964,11 @@ mod tests { #[test] fn test_rewrite_npm_with_args() { assert_eq!( - rewrite_command("npm run test", &[]), + rewrite_command_no_prefixes("npm run test", &[]), Some("rtk npm run test".to_string()), ); assert_eq!( - rewrite_command("npm exec vitest", &[]), + rewrite_command_no_prefixes("npm exec vitest", &[]), Some("rtk vitest".to_string()), ); } @@ -2862,17 +2976,109 @@ mod tests { #[test] fn test_rewrite_npx() { assert_eq!( - rewrite_command("npx svgo", &[]), + rewrite_command_no_prefixes("npx svgo", &[]), Some("rtk npx svgo".to_string()), ); } + + // --- Gradle --- + + #[test] + fn test_classify_gradlew() { + assert!(matches!( + classify_command("./gradlew assembleDebug"), + Classification::Supported { + rtk_equivalent: "rtk gradlew", + .. + } + )); + } + + #[test] + fn test_classify_gradlew_no_dot_slash() { + assert!(matches!( + classify_command("gradlew build"), + Classification::Supported { + rtk_equivalent: "rtk gradlew", + .. + } + )); + } + + #[test] + fn test_classify_gradlew_bat() { + assert!(matches!( + classify_command("gradlew.bat clean"), + Classification::Supported { + rtk_equivalent: "rtk gradlew", + .. + } + )); + } + + #[test] + fn test_classify_gradle() { + assert!(matches!( + classify_command("gradle build"), + Classification::Supported { + rtk_equivalent: "rtk gradlew", + .. + } + )); + } + + #[test] + fn test_rewrite_gradlew() { + assert_eq!( + rewrite_command_no_prefixes("./gradlew assembleDebug", &[]), + Some("rtk gradlew assembleDebug".into()) + ); + } + + #[test] + fn test_rewrite_gradlew_no_dot_slash() { + assert_eq!( + rewrite_command_no_prefixes("gradlew build", &[]), + Some("rtk gradlew build".into()) + ); + } + + #[test] + fn test_rewrite_gradlew_bat() { + assert_eq!( + rewrite_command_no_prefixes("gradlew.bat clean", &[]), + Some("rtk gradlew clean".into()) + ); + } + + #[test] + fn test_rewrite_gradle() { + assert_eq!( + rewrite_command_no_prefixes("gradle build", &[]), + Some("rtk gradlew build".into()) + ); + } + + #[test] + fn test_rewrite_gradlew_test_savings() { + assert_eq!( + classify_command("./gradlew test"), + Classification::Supported { + rtk_equivalent: "rtk gradlew", + category: "Build", + estimated_savings_pct: 90.0, + status: RtkStatus::Existing, + } + ); + } + // --- Compound operator edge cases --- #[test] fn test_rewrite_compound_or() { // `||` fallback: left rewritten, right rewritten assert_eq!( - rewrite_command("cargo test || cargo build", &[]), + rewrite_command_no_prefixes("cargo test || cargo build", &[]), Some("rtk cargo test || rtk cargo build".into()) ); } @@ -2880,7 +3086,7 @@ mod tests { #[test] fn test_rewrite_compound_semicolon() { assert_eq!( - rewrite_command("git status; cargo test", &[]), + rewrite_command_no_prefixes("git status; cargo test", &[]), Some("rtk git status; rtk cargo test".into()) ); } @@ -2889,7 +3095,7 @@ mod tests { fn test_rewrite_compound_pipe_raw_filter() { // Pipe: rewrite first segment only, pass through rest unchanged assert_eq!( - rewrite_command("cargo test | grep FAILED", &[]), + rewrite_command_no_prefixes("cargo test | grep FAILED", &[]), Some("rtk cargo test | grep FAILED".into()) ); } @@ -2897,7 +3103,7 @@ mod tests { #[test] fn test_rewrite_compound_pipe_git_grep() { assert_eq!( - rewrite_command("git log -10 | grep feat", &[]), + rewrite_command_no_prefixes("git log -10 | grep feat", &[]), Some("rtk git log -10 | grep feat".into()) ); } @@ -2905,7 +3111,7 @@ mod tests { #[test] fn test_rewrite_compound_four_segments() { assert_eq!( - rewrite_command( + rewrite_command_no_prefixes( "cargo fmt --all && cargo clippy && cargo test && git status", &[] ), @@ -2920,7 +3126,7 @@ mod tests { fn test_rewrite_compound_mixed_supported_unsupported() { // unsupported segments stay raw assert_eq!( - rewrite_command("cargo test && htop", &[]), + rewrite_command_no_prefixes("cargo test && htop", &[]), Some("rtk cargo test && htop".into()) ); } @@ -2928,7 +3134,7 @@ mod tests { #[test] fn test_rewrite_compound_all_unsupported_returns_none() { // No rewrite at all: returns None - assert_eq!(rewrite_command("htop && top", &[]), None); + assert_eq!(rewrite_command_no_prefixes("htop && top", &[]), None); } // --- sudo / env prefix + rewrite --- @@ -2936,7 +3142,7 @@ mod tests { #[test] fn test_rewrite_sudo_docker() { assert_eq!( - rewrite_command("sudo docker ps", &[]), + rewrite_command_no_prefixes("sudo docker ps", &[]), Some("sudo rtk docker ps".into()) ); } @@ -2944,7 +3150,7 @@ mod tests { #[test] fn test_rewrite_env_var_prefix() { assert_eq!( - rewrite_command("GIT_SSH_COMMAND=ssh git push origin main", &[]), + rewrite_command_no_prefixes("GIT_SSH_COMMAND=ssh git push origin main", &[]), Some("GIT_SSH_COMMAND=ssh rtk git push origin main".into()) ); } @@ -2954,7 +3160,7 @@ mod tests { #[test] fn test_rewrite_find_with_flags() { assert_eq!( - rewrite_command("find . -name '*.rs' -type f", &[]), + rewrite_command_no_prefixes("find . -name '*.rs' -type f", &[]), Some("rtk find . -name '*.rs' -type f".into()) ); } @@ -2987,7 +3193,7 @@ mod tests { fn test_rewrite_excludes_curl() { let excluded = vec!["curl".to_string()]; assert_eq!( - rewrite_command("curl https://api.example.com/health", &excluded), + rewrite_command_no_prefixes("curl https://api.example.com/health", &excluded), None ); } @@ -2996,7 +3202,7 @@ mod tests { fn test_rewrite_exclude_does_not_affect_other_commands() { let excluded = vec!["curl".to_string()]; assert_eq!( - rewrite_command("git status", &excluded), + rewrite_command_no_prefixes("git status", &excluded), Some("rtk git status".into()) ); } @@ -3004,7 +3210,7 @@ mod tests { #[test] fn test_rewrite_empty_excludes_rewrites_curl() { let excluded: Vec = vec![]; - assert!(rewrite_command("curl https://api.example.com", &excluded).is_some()); + assert!(rewrite_command_no_prefixes("curl https://api.example.com", &excluded).is_some()); } #[test] @@ -3012,7 +3218,7 @@ mod tests { // curl excluded but git still rewrites let excluded = vec!["curl".to_string()]; assert_eq!( - rewrite_command("git status && curl https://api.example.com", &excluded), + rewrite_command_no_prefixes("git status && curl https://api.example.com", &excluded), Some("rtk git status && curl https://api.example.com".into()) ); } @@ -3021,7 +3227,7 @@ mod tests { fn test_exclude_env_prefixed_command() { let excluded = vec!["psql".to_string()]; assert_eq!( - rewrite_command("PGPASSWORD=postgres psql -h localhost", &excluded), + rewrite_command_no_prefixes("PGPASSWORD=postgres psql -h localhost", &excluded), None ); } @@ -3029,43 +3235,49 @@ mod tests { #[test] fn test_exclude_subcommand_pattern() { let excluded = vec!["git push".to_string()]; - assert_eq!(rewrite_command("git push origin main", &excluded), None); + assert_eq!( + rewrite_command_no_prefixes("git push origin main", &excluded), + None + ); } #[test] fn test_exclude_regex_pattern() { let excluded = vec!["^curl".to_string()]; - assert_eq!(rewrite_command("curl http://example.com", &excluded), None); + assert_eq!( + rewrite_command_no_prefixes("curl http://example.com", &excluded), + None + ); } #[test] fn test_exclude_invalid_regex_fallback() { let excluded = vec!["curl[".to_string()]; - assert!(rewrite_command("curl http://example.com", &excluded).is_some()); + assert!(rewrite_command_no_prefixes("curl http://example.com", &excluded).is_some()); } #[test] fn test_exclude_does_not_substring_match() { let excluded = vec!["go".to_string()]; - assert!(rewrite_command("golangci-lint run ./...", &excluded).is_some()); + assert!(rewrite_command_no_prefixes("golangci-lint run ./...", &excluded).is_some()); } #[test] fn test_exclude_does_not_match_hyphenated_command() { let excluded = vec!["golangci".to_string()]; - assert!(rewrite_command("golangci-lint run ./...", &excluded).is_some()); + assert!(rewrite_command_no_prefixes("golangci-lint run ./...", &excluded).is_some()); } #[test] fn test_exclude_empty_pattern_ignored() { let excluded = vec!["".to_string()]; - assert!(rewrite_command("git status", &excluded).is_some()); + assert!(rewrite_command_no_prefixes("git status", &excluded).is_some()); } #[test] fn test_exclude_bare_anchor_ignored() { let excluded = vec!["^".to_string()]; - assert!(rewrite_command("git status", &excluded).is_some()); + assert!(rewrite_command_no_prefixes("git status", &excluded).is_some()); } #[test] @@ -3085,13 +3297,16 @@ mod tests { #[test] fn test_rewrite_gh_json_skipped() { - assert_eq!(rewrite_command("gh pr list --json number,title", &[]), None); + assert_eq!( + rewrite_command_no_prefixes("gh pr list --json number,title", &[]), + None + ); } #[test] fn test_rewrite_gh_jq_skipped() { assert_eq!( - rewrite_command("gh pr list --json number --jq '.[].number'", &[]), + rewrite_command_no_prefixes("gh pr list --json number --jq '.[].number'", &[]), None ); } @@ -3099,7 +3314,7 @@ mod tests { #[test] fn test_rewrite_gh_template_skipped() { assert_eq!( - rewrite_command("gh pr view 42 --template '{{.title}}'", &[]), + rewrite_command_no_prefixes("gh pr view 42 --template '{{.title}}'", &[]), None ); } @@ -3107,7 +3322,7 @@ mod tests { #[test] fn test_rewrite_gh_api_json_skipped() { assert_eq!( - rewrite_command("gh api repos/owner/repo --jq '.name'", &[]), + rewrite_command_no_prefixes("gh api repos/owner/repo --jq '.name'", &[]), None ); } @@ -3115,7 +3330,7 @@ mod tests { #[test] fn test_rewrite_gh_without_json_still_works() { assert_eq!( - rewrite_command("gh pr list", &[]), + rewrite_command_no_prefixes("gh pr list", &[]), Some("rtk gh pr list".into()) ); } @@ -3123,28 +3338,30 @@ mod tests { // --- #508: RTK_DISABLED detection helpers --- #[test] - fn test_has_rtk_disabled_prefix() { - assert!(has_rtk_disabled_prefix("RTK_DISABLED=1 git status")); - assert!(has_rtk_disabled_prefix("FOO=1 RTK_DISABLED=1 cargo test")); - assert!(has_rtk_disabled_prefix( + fn test_cmd_has_rtk_disabled_prefix() { + assert!(cmd_has_rtk_disabled_prefix("RTK_DISABLED=1 git status")); + assert!(cmd_has_rtk_disabled_prefix( + "FOO=1 RTK_DISABLED=1 cargo test" + )); + assert!(cmd_has_rtk_disabled_prefix( "RTK_DISABLED=true git log --oneline" )); - assert!(!has_rtk_disabled_prefix("git status")); - assert!(!has_rtk_disabled_prefix("rtk git status")); - assert!(!has_rtk_disabled_prefix("SOME_VAR=1 git status")); + assert!(!cmd_has_rtk_disabled_prefix("git status")); + assert!(!cmd_has_rtk_disabled_prefix("rtk git status")); + assert!(!cmd_has_rtk_disabled_prefix("SOME_VAR=1 git status")); } #[test] fn test_strip_disabled_prefix() { assert_eq!( strip_disabled_prefix("RTK_DISABLED=1 git status"), - "git status" + ("RTK_DISABLED=1 ", "git status") ); assert_eq!( strip_disabled_prefix("FOO=1 RTK_DISABLED=1 cargo test"), - "cargo test" + ("FOO=1 RTK_DISABLED=1 ", "cargo test") ); - assert_eq!(strip_disabled_prefix("git status"), "git status"); + assert_eq!(strip_disabled_prefix("git status"), ("", "git status")); } // --- #485: absolute path normalization --- @@ -3254,7 +3471,7 @@ mod tests { #[test] fn test_rewrite_git_dash_c() { assert_eq!( - rewrite_command("git -C /tmp status", &[]), + rewrite_command_no_prefixes("git -C /tmp status", &[]), Some("rtk git -C /tmp status".to_string()) ); } @@ -3262,7 +3479,7 @@ mod tests { #[test] fn test_rewrite_git_no_pager() { assert_eq!( - rewrite_command("git --no-pager log -5", &[]), + rewrite_command_no_prefixes("git --no-pager log -5", &[]), Some("rtk git --no-pager log -5".to_string()) ); } @@ -3333,7 +3550,7 @@ mod tests { #[test] fn test_rewrite_wc() { assert_eq!( - rewrite_command("wc -l src/main.rs", &[]), + rewrite_command_no_prefixes("wc -l src/main.rs", &[]), Some("rtk wc -l src/main.rs".into()) ); } @@ -3341,7 +3558,7 @@ mod tests { #[test] fn test_rewrite_wc_multi_file() { assert_eq!( - rewrite_command("wc src/*.rs", &[]), + rewrite_command_no_prefixes("wc src/*.rs", &[]), Some("rtk wc src/*.rs".into()) ); } @@ -3362,7 +3579,7 @@ mod tests { #[test] fn test_rewrite_command_substitution_passthrough() { assert_eq!( - rewrite_command("git log $(git rev-parse HEAD~1)", &[]), + rewrite_command_no_prefixes("git log $(git rev-parse HEAD~1)", &[]), Some("rtk git log $(git rev-parse HEAD~1)".into()) ); } @@ -3378,7 +3595,7 @@ mod tests { #[test] fn test_shell_prefix_noglob() { assert_eq!( - rewrite_command("noglob git status", &[]), + rewrite_command_no_prefixes("noglob git status", &[]), Some("noglob rtk git status".into()) ); } @@ -3386,7 +3603,7 @@ mod tests { #[test] fn test_shell_prefix_command() { assert_eq!( - rewrite_command("command git status", &[]), + rewrite_command_no_prefixes("command git status", &[]), Some("command rtk git status".into()) ); } @@ -3394,28 +3611,199 @@ mod tests { #[test] fn test_shell_prefix_builtin_exec_nocorrect() { assert_eq!( - rewrite_command("builtin git status", &[]), + rewrite_command_no_prefixes("builtin git status", &[]), Some("builtin rtk git status".into()) ); assert_eq!( - rewrite_command("exec git status", &[]), + rewrite_command_no_prefixes("exec git status", &[]), Some("exec rtk git status".into()) ); assert_eq!( - rewrite_command("nocorrect git status", &[]), + rewrite_command_no_prefixes("nocorrect git status", &[]), Some("nocorrect rtk git status".into()) ); } #[test] fn test_shell_prefix_unknown_inner() { - assert_eq!(rewrite_command("noglob unknown_cmd --flag", &[]), None); + assert_eq!( + rewrite_command_no_prefixes("noglob unknown_cmd --flag", &[]), + None + ); + } + + // --- transparent_prefixes tests --- + + #[test] + fn test_transparent_prefix_strips_and_reprepends() { + let prefixes = vec!["shadowenv exec --".to_string()]; + assert_eq!( + super::rewrite_command("shadowenv exec -- git status", &[], &prefixes), + Some("shadowenv exec -- rtk git status".into()) + ); + } + + #[test] + fn test_transparent_prefix_with_test_runner() { + let prefixes = vec!["shadowenv exec --".to_string()]; + assert_eq!( + super::rewrite_command("shadowenv exec -- cargo test", &[], &prefixes), + Some("shadowenv exec -- rtk cargo test".into()) + ); + } + + #[test] + fn test_transparent_prefix_unknown_inner_returns_none() { + let prefixes = vec!["shadowenv exec --".to_string()]; + assert_eq!( + super::rewrite_command("shadowenv exec -- htop", &[], &prefixes), + None + ); + } + + #[test] + fn test_transparent_prefix_not_matched_is_passthrough() { + // Without the prefix configured, the wrapper breaks routing. + assert_eq!( + super::rewrite_command("shadowenv exec -- git status", &[], &[]), + None + ); + } + + #[test] + fn test_transparent_prefix_composed_with_builtin() { + // `noglob shadowenv exec -- git status` — builtin layer strips noglob, + // user layer strips shadowenv exec --, inner `git status` routes. + let prefixes = vec!["shadowenv exec --".to_string()]; + assert_eq!( + super::rewrite_command("noglob shadowenv exec -- git status", &[], &prefixes), + Some("noglob shadowenv exec -- rtk git status".into()) + ); + } + + #[test] + fn test_transparent_prefix_composed_with_env_prefix() { + let prefixes = vec!["bundle exec".to_string()]; + assert_eq!( + super::rewrite_command("RAILS_ENV=test bundle exec git status", &[], &prefixes), + Some("RAILS_ENV=test bundle exec rtk git status".into()) + ); + } + + #[test] + fn test_env_prefix_composed_with_builtin() { + assert_eq!( + rewrite_command_no_prefixes("sudo noglob git status", &[]), + Some("sudo noglob rtk git status".into()) + ); + } + + #[test] + fn test_transparent_prefix_multiple_configured() { + let prefixes = vec!["shadowenv exec --".to_string(), "direnv exec .".to_string()]; + assert_eq!( + super::rewrite_command("direnv exec . git status", &[], &prefixes), + Some("direnv exec . rtk git status".into()) + ); + } + + #[test] + fn test_transparent_prefixes_normalize_once() { + let prefixes = vec![ + " docker exec mycontainer ".to_string(), + "".to_string(), + "docker".to_string(), + "docker exec mycontainer".to_string(), + ]; + assert_eq!( + normalize_transparent_prefixes(&prefixes), + vec!["docker exec mycontainer".to_string(), "docker".to_string()] + ); + } + + #[test] + fn test_transparent_prefix_overlapping_entries_use_longest_match() { + let prefixes = vec!["docker".to_string(), "docker exec app".to_string()]; + assert_eq!( + super::rewrite_command("docker exec app git status", &[], &prefixes), + Some("docker exec app rtk git status".into()) + ); + } + + #[test] + fn test_transparent_prefix_whole_word_matching() { + // A prefix `"foo"` must NOT match `"foobar git status"`. + let prefixes = vec!["foo".to_string()]; + assert_eq!( + super::rewrite_command("foobar git status", &[], &prefixes), + None + ); + } + + #[test] + fn test_transparent_prefix_empty_rest_returns_none() { + let prefixes = vec!["shadowenv exec --".to_string()]; + assert_eq!( + super::rewrite_command("shadowenv exec --", &[], &prefixes), + None + ); + } + + #[test] + fn test_transparent_prefix_empty_entry_is_skipped() { + // A blank entry in the config should not cause spurious matches or panics. + let prefixes = vec!["".to_string(), " ".to_string()]; + assert_eq!( + super::rewrite_command("git status", &[], &prefixes), + Some("rtk git status".into()) + ); + } + + #[test] + fn test_transparent_prefix_inside_compound() { + // Each segment of `&&` / `;` should independently get prefix-stripped. + let prefixes = vec!["shadowenv exec --".to_string()]; + assert_eq!( + super::rewrite_command( + "shadowenv exec -- git status && shadowenv exec -- cargo test", + &[], + &prefixes + ), + Some("shadowenv exec -- rtk git status && shadowenv exec -- rtk cargo test".into()) + ); + } + + #[test] + fn test_transparent_prefix_respects_excluded() { + // An excluded inner command should still produce no rewrite even behind + // a transparent prefix. + let prefixes = vec!["shadowenv exec --".to_string()]; + let excluded = vec!["git".to_string()]; + assert_eq!( + super::rewrite_command("shadowenv exec -- git status", &excluded, &prefixes), + None + ); + } + + #[test] + fn test_transparent_prefix_recursion_bounded() { + // A prefix that could recurse forever (e.g. one that maps to itself) + // must terminate once MAX_PREFIX_DEPTH is reached. + let prefixes = vec!["wrap".to_string()]; + let mut cmd = String::new(); + for _ in 0..(MAX_PREFIX_DEPTH + 2) { + cmd.push_str("wrap "); + } + cmd.push_str("git status"); + // Doesn't matter exactly what it returns — just that it doesn't stack- + // overflow or loop forever. Exercise the code path. + let _ = super::rewrite_command(&cmd, &[], &prefixes); } #[test] fn test_python3_m_pytest() { assert_eq!( - rewrite_command("python3 -m pytest tests/", &[]), + rewrite_command_no_prefixes("python3 -m pytest tests/", &[]), Some("rtk pytest tests/".into()) ); } @@ -3423,14 +3811,17 @@ mod tests { #[test] fn test_pip_show() { assert_eq!( - rewrite_command("pip show flask", &[]), + rewrite_command_no_prefixes("pip show flask", &[]), Some("rtk pip show flask".into()) ); } #[test] fn test_gt_graphite() { - assert_eq!(rewrite_command("gt log", &[]), Some("rtk gt log".into())); + assert_eq!( + rewrite_command_no_prefixes("gt log", &[]), + Some("rtk gt log".into()) + ); } #[test] @@ -3446,7 +3837,7 @@ mod tests { #[test] fn test_rewrite_pipe_then_and() { assert_eq!( - rewrite_command("git log | head -5 && git stash", &[]), + rewrite_command_no_prefixes("git log | head -5 && git stash", &[]), Some("rtk git log | head -5 && rtk git stash".into()) ); } @@ -3454,7 +3845,7 @@ mod tests { #[test] fn test_rewrite_pipe_then_semicolon() { assert_eq!( - rewrite_command("cargo test | head; git status", &[]), + rewrite_command_no_prefixes("cargo test | head; git status", &[]), Some("rtk cargo test | head; rtk git status".into()) ); } @@ -3462,7 +3853,7 @@ mod tests { #[test] fn test_rewrite_pipe_then_or() { assert_eq!( - rewrite_command("cargo test | grep FAIL || git stash", &[]), + rewrite_command_no_prefixes("cargo test | grep FAIL || git stash", &[]), Some("rtk cargo test | grep FAIL || rtk git stash".into()) ); } @@ -3470,7 +3861,7 @@ mod tests { #[test] fn test_rewrite_env_pipe_then_and() { assert_eq!( - rewrite_command( + rewrite_command_no_prefixes( "RUST_BACKTRACE=1 cargo test 2>&1 | grep FAILED && git stash", &[] ), @@ -3481,7 +3872,7 @@ mod tests { #[test] fn test_rewrite_and_then_pipe() { assert_eq!( - rewrite_command("git status && cargo test | grep FAIL", &[]), + rewrite_command_no_prefixes("git status && cargo test | grep FAIL", &[]), Some("rtk git status && rtk cargo test | grep FAIL".into()) ); } @@ -3489,7 +3880,7 @@ mod tests { #[test] fn test_rewrite_multi_pipe_then_and() { assert_eq!( - rewrite_command("git log | head | tail && git status", &[]), + rewrite_command_no_prefixes("git log | head | tail && git status", &[]), Some("rtk git log | head | tail && rtk git status".into()) ); } diff --git a/src/discover/report.rs b/src/discover/report.rs index 2af129a5c3..c2c523e246 100644 --- a/src/discover/report.rs +++ b/src/discover/report.rs @@ -1,7 +1,11 @@ //! Data types for reporting which commands RTK can and cannot optimize. -use crate::hooks::constants::{CURSOR_DIR, HOOKS_SUBDIR, REWRITE_HOOK_FILE}; +use crate::hooks::constants::{ + CURSOR_DIR, HERMES_DIR, HERMES_PLUGINS_SUBDIR, HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, + HOOKS_SUBDIR, REWRITE_HOOK_FILE, +}; use serde::Serialize; +use std::path::Path; /// RTK support status for a command. #[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)] @@ -44,6 +48,36 @@ pub struct UnsupportedEntry { pub example: String, } +#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq, Default)] +pub struct AgentIntegrationStatus { + pub cursor_hook_installed: bool, + pub hermes_plugin_installed: bool, +} + +impl AgentIntegrationStatus { + pub fn detect() -> Self { + dirs::home_dir() + .map(|home| Self::detect_from_home(&home)) + .unwrap_or_default() + } + + fn detect_from_home(home: &Path) -> Self { + Self { + cursor_hook_installed: home + .join(CURSOR_DIR) + .join(HOOKS_SUBDIR) + .join(REWRITE_HOOK_FILE) + .exists(), + hermes_plugin_installed: home + .join(HERMES_DIR) + .join(HERMES_PLUGINS_SUBDIR) + .join(HERMES_PLUGIN_NAME) + .join(HERMES_PLUGIN_MANIFEST_FILE) + .is_file(), + } + } +} + /// Full discover report. #[derive(Debug, Serialize)] pub struct DiscoverReport { @@ -56,6 +90,7 @@ pub struct DiscoverReport { pub parse_errors: usize, pub rtk_disabled_count: usize, pub rtk_disabled_examples: Vec, + pub agent_status: AgentIntegrationStatus, } impl DiscoverReport { @@ -94,6 +129,7 @@ pub fn format_text(report: &DiscoverReport, limit: usize, verbose: bool) -> Stri if report.supported.is_empty() && report.unsupported.is_empty() { out.push_str("\nNo missed savings found. RTK usage looks good!\n"); + append_agent_notes(&mut out, report.agent_status); return out; } @@ -168,16 +204,7 @@ pub fn format_text(report: &DiscoverReport, limit: usize, verbose: bool) -> Stri out.push_str("\n~estimated from tool_result output sizes\n"); - // Cursor note: check if Cursor hooks are installed - if let Some(home) = dirs::home_dir() { - let cursor_hook = home - .join(CURSOR_DIR) - .join(HOOKS_SUBDIR) - .join(REWRITE_HOOK_FILE); - if cursor_hook.exists() { - out.push_str("\nNote: Cursor sessions are tracked via `rtk gain` (discover scans Claude Code only)\n"); - } - } + append_agent_notes(&mut out, report.agent_status); if verbose && report.parse_errors > 0 { out.push_str(&format!("Parse errors skipped: {}\n", report.parse_errors)); @@ -186,6 +213,16 @@ pub fn format_text(report: &DiscoverReport, limit: usize, verbose: bool) -> Stri out } +fn append_agent_notes(out: &mut String, status: AgentIntegrationStatus) { + if status.cursor_hook_installed { + out.push_str("\nNote: Cursor sessions are tracked via `rtk gain` (discover scans Claude Code only)\n"); + } + + if status.hermes_plugin_installed { + out.push_str("\nNote: Hermes plugin is installed; Hermes sessions are tracked via `rtk gain` (discover scans Claude Code only)\n"); + } +} + /// Format report as JSON. pub fn format_json(report: &DiscoverReport) -> String { serde_json::to_string_pretty(report).unwrap_or_else(|_| "{}".to_string()) @@ -230,6 +267,7 @@ mod tests { parse_errors: 0, rtk_disabled_count: 0, rtk_disabled_examples: vec![], + agent_status: AgentIntegrationStatus::default(), } } @@ -267,4 +305,69 @@ mod tests { let output = format_text(&report, 10, false); assert!(output.contains("100.0%")); } + + #[test] + fn test_agent_status_detects_hermes_plugin_manifest() { + let temp_home = tempfile::tempdir().unwrap(); + let manifest = temp_home + .path() + .join(HERMES_DIR) + .join(HERMES_PLUGINS_SUBDIR) + .join(HERMES_PLUGIN_NAME) + .join(HERMES_PLUGIN_MANIFEST_FILE); + std::fs::create_dir_all(manifest.parent().unwrap()).unwrap(); + std::fs::write(&manifest, "name: rtk-rewrite\n").unwrap(); + + let status = AgentIntegrationStatus::detect_from_home(temp_home.path()); + + assert!(status.hermes_plugin_installed); + assert!(!status.cursor_hook_installed); + } + + #[test] + fn test_agent_status_ignores_hermes_plugin_dir_without_manifest() { + let temp_home = tempfile::tempdir().unwrap(); + let plugin_dir = temp_home + .path() + .join(HERMES_DIR) + .join(HERMES_PLUGINS_SUBDIR) + .join(HERMES_PLUGIN_NAME); + std::fs::create_dir_all(plugin_dir).unwrap(); + + let status = AgentIntegrationStatus::detect_from_home(temp_home.path()); + + assert!(!status.hermes_plugin_installed); + } + + #[test] + fn test_format_text_reports_hermes_plugin_detected() { + let mut report = make_report(0, 0); + report.agent_status = AgentIntegrationStatus { + hermes_plugin_installed: true, + ..AgentIntegrationStatus::default() + }; + + let output = format_text(&report, 10, false); + + assert!( + output.contains("Hermes plugin is installed"), + "Expected Hermes installed note in output but got:\n{}", + output + ); + } + + #[test] + fn test_format_json_includes_agent_status() { + let mut report = make_report(0, 0); + report.agent_status = AgentIntegrationStatus { + cursor_hook_installed: true, + hermes_plugin_installed: true, + }; + + let output = format_json(&report); + let json: serde_json::Value = serde_json::from_str(&output).unwrap(); + + assert_eq!(json["agent_status"]["cursor_hook_installed"], true); + assert_eq!(json["agent_status"]["hermes_plugin_installed"], true); + } } diff --git a/src/discover/rules.rs b/src/discover/rules.rs index 74c876a1e5..df7c72d03e 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -626,6 +626,15 @@ pub const RULES: &[RtkRule] = &[ subcmd_savings: &[], subcmd_status: &[], }, + RtkRule { + pattern: r"^(?:\./gradlew|gradlew\.bat|gradlew|gradle)(?:\s+(test|build|clean|assemble\w*|install\w*|check|lint\w*|dependencies))?(\s|$)", + rtk_cmd: "rtk gradlew", + rewrite_prefixes: &["./gradlew", "gradlew.bat", "gradlew", "gradle"], + category: "Build", + savings_pct: 75.0, + subcmd_savings: &[("test", 90.0), ("build", 80.0), ("check", 80.0)], + subcmd_status: &[], + }, RtkRule { pattern: r"^hadolint\b", rtk_cmd: "rtk hadolint", diff --git a/src/hooks/README.md b/src/hooks/README.md index bf947a0f15..01a0213cb5 100644 --- a/src/hooks/README.md +++ b/src/hooks/README.md @@ -19,7 +19,7 @@ LLM agent integration layer that installs, validates, and executes command-rewri ## Installation Modes -`rtk init` supports 6 distinct installation flows: +`rtk init` supports these installation flows: | Mode | Command | Creates | Patches | |------|---------|---------|---------| @@ -30,6 +30,7 @@ LLM agent integration layer that installs, validates, and executes command-rewri | Cline | `rtk init --agent cline` | `.clinerules` | -- | | Codex | `rtk init --codex` | RTK.md in `$CODEX_HOME` or `~/.codex` | AGENTS.md | | Cursor | `rtk init -g --agent cursor` | Cursor hook | hooks.json | +| Hermes | `rtk init --agent hermes` | Python plugin in `~/.hermes/plugins/rtk-rewrite/` | `config.yaml` `plugins.enabled` | ## Integrity Verification diff --git a/src/hooks/constants.rs b/src/hooks/constants.rs index 1d9f33ccd8..85340510c8 100644 --- a/src/hooks/constants.rs +++ b/src/hooks/constants.rs @@ -21,3 +21,8 @@ pub const OPENCODE_PLUGIN_FILE: &str = "rtk.ts"; pub const CURSOR_DIR: &str = ".cursor"; pub const CODEX_DIR: &str = ".codex"; pub const GEMINI_DIR: &str = ".gemini"; +pub const HERMES_DIR: &str = ".hermes"; +pub const HERMES_PLUGINS_SUBDIR: &str = "plugins"; +pub const HERMES_PLUGIN_NAME: &str = "rtk-rewrite"; +pub const HERMES_PLUGIN_INIT_FILE: &str = "__init__.py"; +pub const HERMES_PLUGIN_MANIFEST_FILE: &str = "plugin.yaml"; diff --git a/src/hooks/hook_audit_cmd.rs b/src/hooks/hook_audit_cmd.rs index 2138ec939f..5fe339b940 100644 --- a/src/hooks/hook_audit_cmd.rs +++ b/src/hooks/hook_audit_cmd.rs @@ -143,7 +143,7 @@ pub fn run(since_days: u64, verbose: u8) -> Result<()> { if !skip_actions.is_empty() { let mut sorted_skips = skip_actions; - sorted_skips.sort_by(|a, b| b.1.cmp(&a.1)); + sorted_skips.sort_by_key(|b| std::cmp::Reverse(b.1)); for (action, count) in &sorted_skips { let reason = action.strip_prefix("skip:").unwrap_or(action); println!( diff --git a/src/hooks/hook_check.rs b/src/hooks/hook_check.rs index ce288ba99e..4dd26d82b8 100644 --- a/src/hooks/hook_check.rs +++ b/src/hooks/hook_check.rs @@ -155,8 +155,9 @@ fn warn_marker_path() -> Option { mod tests { use super::*; use crate::hooks::constants::{ - CODEX_DIR, CONFIG_DIR, CURSOR_DIR, GEMINI_DIR, GEMINI_HOOK_FILE, OPENCODE_PLUGIN_FILE, - OPENCODE_SUBDIR, PLUGIN_SUBDIR, + CODEX_DIR, CONFIG_DIR, CURSOR_DIR, GEMINI_DIR, GEMINI_HOOK_FILE, HERMES_DIR, + HERMES_PLUGINS_SUBDIR, HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, + OPENCODE_PLUGIN_FILE, OPENCODE_SUBDIR, PLUGIN_SUBDIR, }; fn other_integration_installed(home: &std::path::Path) -> bool { @@ -172,6 +173,10 @@ mod tests { home.join(GEMINI_DIR) .join(HOOKS_SUBDIR) .join(GEMINI_HOOK_FILE), + home.join(HERMES_DIR) + .join(HERMES_PLUGINS_SUBDIR) + .join(HERMES_PLUGIN_NAME) + .join(HERMES_PLUGIN_MANIFEST_FILE), ]; paths.iter().any(|p| p.exists()) } @@ -265,12 +270,33 @@ mod tests { assert!(other_integration_installed(tmp.path())); } + #[test] + fn test_other_integration_hermes() { + let tmp = tempfile::tempdir().expect("tempdir"); + let path = tmp + .path() + .join(HERMES_DIR) + .join(HERMES_PLUGINS_SUBDIR) + .join(HERMES_PLUGIN_NAME) + .join(HERMES_PLUGIN_MANIFEST_FILE); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, b"plugin").unwrap(); + assert!(other_integration_installed(tmp.path())); + } + #[test] fn test_other_integration_empty_dirs_not_enough() { let tmp = tempfile::tempdir().expect("tempdir"); std::fs::create_dir_all(tmp.path().join(CURSOR_DIR).join(HOOKS_SUBDIR)).unwrap(); std::fs::create_dir_all(tmp.path().join(CODEX_DIR)).unwrap(); std::fs::create_dir_all(tmp.path().join(GEMINI_DIR)).unwrap(); + std::fs::create_dir_all( + tmp.path() + .join(HERMES_DIR) + .join(HERMES_PLUGINS_SUBDIR) + .join(HERMES_PLUGIN_NAME), + ) + .unwrap(); assert!(!other_integration_installed(tmp.path())); } diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index cd3c82d1e1..36825e3c5e 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -107,11 +107,11 @@ fn get_rewritten(cmd: &str) -> Option { return None; } - let excluded = crate::core::config::Config::load() - .map(|c| c.hooks.exclude_commands) + let (excluded, transparent_prefixes) = crate::core::config::Config::load() + .map(|c| (c.hooks.exclude_commands, c.hooks.transparent_prefixes)) .unwrap_or_default(); - let rewritten = rewrite_command(cmd, &excluded)?; + let rewritten = rewrite_command(cmd, &excluded, &transparent_prefixes)?; if rewritten == cmd { return None; @@ -211,11 +211,11 @@ pub fn run_gemini() -> Result<()> { return Ok(()); } - let excluded = crate::core::config::Config::load() - .map(|c| c.hooks.exclude_commands) + let (excluded, transparent_prefixes) = crate::core::config::Config::load() + .map(|c| (c.hooks.exclude_commands, c.hooks.transparent_prefixes)) .unwrap_or_default(); - match rewrite_command(cmd, &excluded) { + match rewrite_command(cmd, &excluded, &transparent_prefixes) { Some(ref rewritten) => { audit_log("rewrite", cmd, rewritten); print_rewrite(rewritten); @@ -399,11 +399,23 @@ fn run_claude_inner(input: &str) -> Option { // ── Cursor native hook ───────────────────────────────────────── +/// Cursor on Windows ships hook payloads with one or more leading +/// UTF-8 BOMs (`EF BB BF`, sometimes doubled), which serde_json +/// refuses to parse. Strip them defensively so the rewrite path keeps +/// working instead of silently returning `{}`. +fn strip_leading_bom(input: &str) -> &str { + let mut s = input; + while let Some(rest) = s.strip_prefix('\u{feff}') { + s = rest; + } + s +} + /// Run the Cursor Agent hook natively. pub fn run_cursor() -> Result<()> { let input = read_stdin_limited()?; - let input = input.trim(); + let input = strip_leading_bom(&input).trim(); if input.is_empty() { let _ = writeln!(io::stdout(), "{{}}"); return Ok(()); @@ -444,14 +456,20 @@ pub fn run_cursor() -> Result<()> { } }; - let decision = match verdict { - PermissionVerdict::Allow => "allow", - _ => "ask", - }; + // Cursor preToolUse currently enforces allow/deny only and can ignore + // updated_input when permission is "ask". Use "allow" for rewritten + // commands unless the command is explicitly denied above. + let decision = "allow"; audit_log("rewrite", &cmd, &rewritten); + // `continue: true` mirrors the shape of every other Cursor hook + // (afterShellExecution, beforeSubmitPrompt, stop, ...). Cursor's + // preToolUse panel renders the JSON it received; without this field + // the panel collapses to `Output: {}` even though the rewrite ran, + // which makes the hook look broken to users. let output = json!({ + "continue": true, "permission": decision, "updated_input": { "command": rewritten } }); @@ -471,6 +489,7 @@ fn run_cursor_inner_with_rules( ask_rules: &[String], allow_rules: &[String], ) -> String { + let input = strip_leading_bom(input); let v: Value = match serde_json::from_str(input) { Ok(v) => v, Err(_) => return "{}".to_string(), @@ -492,11 +511,9 @@ fn run_cursor_inner_with_rules( match get_rewritten(&cmd) { Some(rewritten) => { - let decision = match verdict { - PermissionVerdict::Allow => "allow", - _ => "ask", - }; + let decision = "allow"; let output = json!({ + "continue": true, "permission": decision, "updated_input": { "command": rewritten } }); @@ -510,6 +527,10 @@ fn run_cursor_inner_with_rules( mod tests { use super::*; + fn rewrite_command_no_prefixes(cmd: &str, excluded: &[String]) -> Option { + crate::discover::registry::rewrite_command(cmd, excluded, &[]) + } + // --- Copilot format detection --- fn vscode_input(tool: &str, cmd: &str) -> Value { @@ -608,26 +629,29 @@ mod tests { #[test] fn test_gemini_hook_uses_rewrite_command() { assert_eq!( - rewrite_command("git status", &[]), + rewrite_command_no_prefixes("git status", &[]), Some("rtk git status".into()) ); assert_eq!( - rewrite_command("cargo test", &[]), + rewrite_command_no_prefixes("cargo test", &[]), Some("rtk cargo test".into()) ); assert_eq!( - rewrite_command("rtk git status", &[]), + rewrite_command_no_prefixes("rtk git status", &[]), Some("rtk git status".into()) ); - assert_eq!(rewrite_command("cat < Result<()> { + let InitContext { dry_run, .. } = ctx; // Validation: Codex mode conflicts if codex { if install_opencode { @@ -253,59 +274,69 @@ pub fn run( if matches!(patch_mode, PatchMode::Skip) { anyhow::bail!("--codex cannot be combined with --no-patch"); } - return run_codex_mode(global, verbose); - } - - // Validation: Global-only features - if install_opencode && !global { - anyhow::bail!("OpenCode plugin is global-only. Use: rtk init -g --opencode"); - } - - if install_cursor && !global { - anyhow::bail!("Cursor hooks are global-only. Use: rtk init -g --agent cursor"); - } + run_codex_mode(global, ctx)?; + } else { + // Validation: Global-only features + if install_opencode && !global { + anyhow::bail!("OpenCode plugin is global-only. Use: rtk init -g --opencode"); + } - if install_windsurf && !global { - anyhow::bail!("Windsurf support is global-only. Use: rtk init -g --agent windsurf"); - } + if install_cursor && !global { + anyhow::bail!("Cursor hooks are global-only. Use: rtk init -g --agent cursor"); + } - // Windsurf-only mode - if install_windsurf { - return run_windsurf_mode(verbose); - } + if install_windsurf && !global { + anyhow::bail!("Windsurf support is global-only. Use: rtk init -g --agent windsurf"); + } - // Cline-only mode - if install_cline { - return run_cline_mode(verbose); - } + if install_windsurf { + run_windsurf_mode(ctx)?; + } else if install_cline { + run_cline_mode(ctx)?; + } else { + // Mode selection (Claude Code / OpenCode) + match (install_claude, install_opencode, claude_md, hook_only) { + (false, true, _, _) => run_opencode_only_mode(ctx)?, + (true, opencode, true, _) => run_claude_md_mode(global, opencode, ctx)?, + (true, opencode, false, true) => { + run_hook_only_mode(global, patch_mode, opencode, ctx)? + } + (true, opencode, false, false) => { + run_default_mode(global, patch_mode, opencode, ctx)? + } + (false, false, _, _) => { + if !install_cursor { + anyhow::bail!( + "at least one of install_claude or install_opencode must be true" + ) + } + } + } - // Mode selection (Claude Code / OpenCode) - match (install_claude, install_opencode, claude_md, hook_only) { - (false, true, _, _) => run_opencode_only_mode(verbose)?, - (true, opencode, true, _) => run_claude_md_mode(global, verbose, opencode)?, - (true, opencode, false, true) => run_hook_only_mode(global, patch_mode, verbose, opencode)?, - (true, opencode, false, false) => run_default_mode(global, patch_mode, verbose, opencode)?, - (false, false, _, _) => { - if !install_cursor { - anyhow::bail!("at least one of install_claude or install_opencode must be true") + // Cursor hooks (additive, installed alongside Claude Code) + if install_cursor { + install_cursor_hooks(ctx)?; } } } - // Cursor hooks (additive, installed alongside Claude Code) - if install_cursor { - install_cursor_hooks(verbose)?; + if !dry_run { + prompt_telemetry_consent()?; } - prompt_telemetry_consent()?; - - println!(); + if dry_run { + print_dry_run_footer(); + } else { + println!(); + } Ok(()) } -/// Idempotent file write: create or update if content differs -fn write_if_changed(path: &Path, content: &str, name: &str, verbose: u8) -> Result { +/// Idempotent file write: create or update if content differs. +/// When `dry_run` is true, prints the intended action and does not touch the filesystem. +fn write_if_changed(path: &Path, content: &str, name: &str, ctx: InitContext) -> Result { + let InitContext { verbose, dry_run } = ctx; if path.exists() { let existing = fs::read_to_string(path) .with_context(|| format!("Failed to read {}: {}", name, path.display()))?; @@ -316,18 +347,32 @@ fn write_if_changed(path: &Path, content: &str, name: &str, verbose: u8) -> Resu } Ok(false) } else { - atomic_write(path, content) - .with_context(|| format!("Failed to write {}: {}", name, path.display()))?; - if verbose > 0 { - eprintln!("Updated {}: {}", name, path.display()); + if dry_run { + println!("[dry-run] would update {}: {}", name, path.display()); + if verbose > 0 { + println!("[dry-run] content:\n{}", content); + } + } else { + atomic_write(path, content) + .with_context(|| format!("Failed to write {}: {}", name, path.display()))?; + if verbose > 0 { + eprintln!("Updated {}: {}", name, path.display()); + } } Ok(true) } } else { - atomic_write(path, content) - .with_context(|| format!("Failed to write {}: {}", name, path.display()))?; - if verbose > 0 { - eprintln!("Created {}: {}", name, path.display()); + if dry_run { + println!("[dry-run] would create {}: {}", name, path.display()); + if verbose > 0 { + println!("[dry-run] content:\n{}", content); + } + } else { + atomic_write(path, content) + .with_context(|| format!("Failed to write {}: {}", name, path.display()))?; + if verbose > 0 { + eprintln!("Created {}: {}", name, path.display()); + } } Ok(true) } @@ -421,7 +466,7 @@ fn prompt_telemetry_consent() -> Result<()> { eprintln!(" Who: RTK AI Labs, contact@rtk-ai.app"); eprintln!(" Rights: disable anytime with `rtk telemetry disable`,"); eprintln!(" request erasure with `rtk telemetry forget`"); - eprintln!(" Details: https://github.com/rtk-ai/rtk/blob/main/docs/TELEMETRY.md"); + eprintln!(" Details: https://github.com/rtk-ai/rtk/blob/master/docs/TELEMETRY.md"); eprintln!(); eprint!("Enable anonymous telemetry? [y/N] "); @@ -499,7 +544,8 @@ fn remove_hook_from_json(root: &mut serde_json::Value) -> bool { /// Remove RTK hook from settings.json file /// Backs up before modification, returns true if hook was found and removed -fn remove_hook_from_settings(verbose: u8) -> Result { +fn remove_hook_from_settings(ctx: InitContext) -> Result { + let InitContext { verbose, dry_run } = ctx; let claude_dir = resolve_claude_dir()?; let settings_path = claude_dir.join(SETTINGS_JSON); @@ -523,6 +569,19 @@ fn remove_hook_from_settings(verbose: u8) -> Result { let removed = remove_hook_from_json(&mut root); if removed { + if dry_run { + println!( + "[dry-run] would remove RTK hook entry from {}", + settings_path.display() + ); + if verbose > 0 { + let serialized = serde_json::to_string_pretty(&root) + .context("Failed to serialize settings.json")?; + println!("[dry-run] content:\n{}", serialized); + } + return Ok(true); + } + // Backup original let backup_path = settings_path.with_extension("json.bak"); fs::copy(&settings_path, &backup_path) @@ -542,26 +601,46 @@ fn remove_hook_from_settings(verbose: u8) -> Result { } /// Full uninstall for Claude, Gemini, Codex, or Cursor artifacts. -pub fn uninstall(global: bool, gemini: bool, codex: bool, cursor: bool, verbose: u8) -> Result<()> { +pub fn uninstall( + global: bool, + gemini: bool, + codex: bool, + cursor: bool, + ctx: InitContext, +) -> Result<()> { + let InitContext { verbose, dry_run } = ctx; if codex { - return uninstall_codex(global, verbose); + uninstall_codex(global, ctx)?; + if dry_run { + print_dry_run_footer(); + } + return Ok(()); } if cursor { if !global { anyhow::bail!("Cursor uninstall only works with --global flag"); } - let cursor_removed = - remove_cursor_hooks(verbose).context("Failed to remove Cursor hooks")?; + let cursor_removed = remove_cursor_hooks(ctx).context("Failed to remove Cursor hooks")?; if !cursor_removed.is_empty() { - println!("RTK uninstalled (Cursor):"); + let header = if dry_run { + "[dry-run] would uninstall RTK (Cursor):" + } else { + "RTK uninstalled (Cursor):" + }; + println!("{}", header); for item in &cursor_removed { println!(" - {}", item); } - println!("\nRestart Cursor to apply changes."); + if !dry_run { + println!("\nRestart Cursor to apply changes."); + } } else { println!("RTK Cursor support was not installed (nothing to remove)"); } + if dry_run { + print_dry_run_footer(); + } return Ok(()); } @@ -574,38 +653,65 @@ pub fn uninstall(global: bool, gemini: bool, codex: bool, cursor: bool, verbose: // Also uninstall Gemini artifacts if --gemini or always (clean everything) if gemini { - let gemini_removed = uninstall_gemini(verbose)?; + let gemini_removed = uninstall_gemini(ctx)?; removed.extend(gemini_removed); if !removed.is_empty() { - println!("RTK uninstalled (Gemini):"); + let header = if dry_run { + "[dry-run] would uninstall RTK (Gemini):" + } else { + "RTK uninstalled (Gemini):" + }; + println!("{}", header); for item in &removed { println!(" - {}", item); } - println!("\nRestart Gemini CLI to apply changes."); + if !dry_run { + println!("\nRestart Gemini CLI to apply changes."); + } } else { println!("RTK Gemini support was not installed (nothing to remove)"); } + if dry_run { + print_dry_run_footer(); + } return Ok(()); } // 1. Remove legacy hook file (if exists from old installation) let hook_path = claude_dir.join(HOOKS_SUBDIR).join(REWRITE_HOOK_FILE); if hook_path.exists() { - fs::remove_file(&hook_path) - .with_context(|| format!("Failed to remove hook: {}", hook_path.display()))?; + if dry_run { + println!( + "[dry-run] would remove hook script: {}", + hook_path.display() + ); + } else { + fs::remove_file(&hook_path) + .with_context(|| format!("Failed to remove hook: {}", hook_path.display()))?; + } removed.push(format!("Hook script: {}", hook_path.display())); } // 1b. Remove integrity hash file - if integrity::remove_hash(&hook_path)? { + if dry_run { + // integrity::remove_hash would delete the sidecar file; just report intent. + if integrity::hash_path_for(&hook_path).exists() { + println!("[dry-run] would remove integrity hash sidecar"); + removed.push("Integrity hash: removed".to_string()); + } + } else if integrity::remove_hash(&hook_path)? { removed.push("Integrity hash: removed".to_string()); } // 2. Remove RTK.md let rtk_md_path = claude_dir.join(RTK_MD); if rtk_md_path.exists() { - fs::remove_file(&rtk_md_path) - .with_context(|| format!("Failed to remove RTK.md: {}", rtk_md_path.display()))?; + if dry_run { + println!("[dry-run] would remove RTK.md: {}", rtk_md_path.display()); + } else { + fs::remove_file(&rtk_md_path) + .with_context(|| format!("Failed to remove RTK.md: {}", rtk_md_path.display()))?; + } removed.push(format!("RTK.md: {}", rtk_md_path.display())); } @@ -642,15 +748,30 @@ pub fn uninstall(global: bool, gemini: bool, codex: bool, cursor: bool, verbose: if claude_md_changed { let trimmed = working_content.trim(); if trimmed.is_empty() { - // nosemgrep: filesystem-deletion - fs::remove_file(&claude_md_path).with_context(|| { - format!( - "Failed to remove empty CLAUDE.md: {}", + if dry_run { + println!( + "[dry-run] would remove CLAUDE.md (empty after cleanup): {}", claude_md_path.display() - ) - })?; + ); + } else { + // nosemgrep: filesystem-deletion + fs::remove_file(&claude_md_path).with_context(|| { + format!( + "Failed to remove empty CLAUDE.md: {}", + claude_md_path.display() + ) + })?; + } removed.retain(|r| !r.starts_with("CLAUDE.md:")); removed.push("CLAUDE.md: removed (was empty after cleanup)".to_string()); + } else if dry_run { + println!( + "[dry-run] would update CLAUDE.md: {}", + claude_md_path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", working_content); + } } else { fs::write(&claude_md_path, &working_content).with_context(|| { format!("Failed to write CLAUDE.md: {}", claude_md_path.display()) @@ -660,18 +781,18 @@ pub fn uninstall(global: bool, gemini: bool, codex: bool, cursor: bool, verbose: } // 4. Remove hook entry from settings.json - if remove_hook_from_settings(verbose)? { + if remove_hook_from_settings(ctx)? { removed.push("settings.json: removed RTK hook entry".to_string()); } // 5. Remove OpenCode plugin - let opencode_removed = remove_opencode_plugin(verbose)?; + let opencode_removed = remove_opencode_plugin(ctx)?; for path in opencode_removed { removed.push(format!("OpenCode plugin: {}", path.display())); } // 6. Remove Cursor hooks - let cursor_removed = remove_cursor_hooks(verbose)?; + let cursor_removed = remove_cursor_hooks(ctx)?; removed.extend(cursor_removed); // Report results @@ -682,17 +803,29 @@ pub fn uninstall(global: bool, gemini: bool, codex: bool, cursor: bool, verbose: println!(" Checked: {}", claude_md_path.display()); println!(" Checked: {}", claude_dir.join(SETTINGS_JSON).display()); } else { - println!("RTK uninstalled:"); + let header = if dry_run { + "[dry-run] would uninstall RTK:" + } else { + "RTK uninstalled:" + }; + println!("{}", header); for item in removed { println!(" - {}", item); } - println!("\nRestart Claude Code, OpenCode, and Cursor (if used) to apply changes."); + if !dry_run { + println!("\nRestart Claude Code, OpenCode, and Cursor (if used) to apply changes."); + } + } + + if dry_run { + print_dry_run_footer(); } Ok(()) } -fn uninstall_codex(global: bool, verbose: u8) -> Result<()> { +fn uninstall_codex(global: bool, ctx: InitContext) -> Result<()> { + let InitContext { dry_run, .. } = ctx; if !global { anyhow::bail!( "Uninstall only works with --global flag. For local projects, manually remove RTK from AGENTS.md" @@ -700,12 +833,17 @@ fn uninstall_codex(global: bool, verbose: u8) -> Result<()> { } let codex_dir = resolve_codex_dir()?; - let removed = uninstall_codex_at(&codex_dir, verbose)?; + let removed = uninstall_codex_at(&codex_dir, ctx)?; if removed.is_empty() { println!("RTK was not installed for Codex CLI (nothing to remove)"); } else { - println!("RTK uninstalled for Codex CLI:"); + let header = if dry_run { + "[dry-run] would uninstall RTK for Codex CLI:" + } else { + "RTK uninstalled for Codex CLI:" + }; + println!("{}", header); for item in removed { println!(" - {}", item); } @@ -714,16 +852,21 @@ fn uninstall_codex(global: bool, verbose: u8) -> Result<()> { Ok(()) } -fn uninstall_codex_at(codex_dir: &Path, verbose: u8) -> Result> { +fn uninstall_codex_at(codex_dir: &Path, ctx: InitContext) -> Result> { + let InitContext { verbose, dry_run } = ctx; let mut removed = Vec::new(); let absolute_rtk_md_ref = codex_rtk_md_ref(codex_dir); let rtk_md_path = codex_dir.join(RTK_MD); if rtk_md_path.exists() { - fs::remove_file(&rtk_md_path) - .with_context(|| format!("Failed to remove RTK.md: {}", rtk_md_path.display()))?; - if verbose > 0 { - eprintln!("Removed RTK.md: {}", rtk_md_path.display()); + if dry_run { + println!("[dry-run] would remove RTK.md: {}", rtk_md_path.display()); + } else { + fs::remove_file(&rtk_md_path) + .with_context(|| format!("Failed to remove RTK.md: {}", rtk_md_path.display()))?; + if verbose > 0 { + eprintln!("Removed RTK.md: {}", rtk_md_path.display()); + } } removed.push(format!("RTK.md: {}", rtk_md_path.display())); } @@ -755,7 +898,7 @@ fn uninstall_codex_at(codex_dir: &Path, verbose: u8) -> Result> { if remove_rtk_reference_from_agents( &agents_md_path, &[RTK_MD_REF, absolute_rtk_md_ref.as_str()], - verbose, + ctx, )? { removed.push("AGENTS.md: removed @RTK.md reference".to_string()); } @@ -768,9 +911,10 @@ fn uninstall_codex_at(codex_dir: &Path, verbose: u8) -> Result> { fn patch_settings_json_command( hook_command: &str, mode: PatchMode, - verbose: u8, include_opencode: bool, + ctx: InitContext, ) -> Result { + let InitContext { verbose, dry_run } = ctx; let claude_dir = resolve_claude_dir()?; let settings_path = claude_dir.join(SETTINGS_JSON); @@ -804,7 +948,13 @@ fn patch_settings_json_command( return Ok(PatchResult::Skipped); } PatchMode::Ask => { - if !prompt_user_consent(&settings_path)? { + // Skip the interactive prompt in dry-run: we must not mutate state or block on stdin. + if dry_run { + println!( + "[dry-run] would prompt before patching {}", + settings_path.display() + ); + } else if !prompt_user_consent(&settings_path)? { print_manual_instructions(hook_command, include_opencode); return Ok(PatchResult::Declined); } @@ -816,6 +966,20 @@ fn patch_settings_json_command( insert_hook_entry(&mut root, hook_command)?; + let serialized = + serde_json::to_string_pretty(&root).context("Failed to serialize settings.json")?; + + if dry_run { + println!( + "[dry-run] would patch settings.json: {}", + settings_path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", serialized); + } + return Ok(PatchResult::WouldPatch); + } + // Backup original if settings_path.exists() { let backup_path = settings_path.with_extension("json.bak"); @@ -827,8 +991,6 @@ fn patch_settings_json_command( } // Atomic write - let serialized = - serde_json::to_string_pretty(&root).context("Failed to serialize settings.json")?; atomic_write(&settings_path, &serialized)?; println!("\n settings.json: hook added"); @@ -936,13 +1098,14 @@ fn hook_already_present(root: &serde_json::Value, hook_command: &str) -> bool { fn run_default_mode( global: bool, patch_mode: PatchMode, - verbose: u8, install_opencode: bool, + ctx: InitContext, ) -> Result<()> { + let InitContext { dry_run, .. } = ctx; if !global { // Local init: inject CLAUDE.md + generate project-local filters template - run_claude_md_mode(false, verbose, install_opencode)?; - generate_project_filters_template(verbose)?; + run_claude_md_mode(false, install_opencode, ctx)?; + generate_project_filters_template(ctx)?; return Ok(()); } @@ -951,62 +1114,71 @@ fn run_default_mode( let claude_md_path = claude_dir.join(CLAUDE_MD); // 1. Migrate old hook script if present - migrate_old_hook_script(verbose); + migrate_old_hook_script(ctx); // 2. Write RTK.md - write_if_changed(&rtk_md_path, RTK_SLIM, RTK_MD, verbose)?; + write_if_changed(&rtk_md_path, RTK_SLIM, RTK_MD, ctx)?; let opencode_plugin_path = if install_opencode { let path = prepare_opencode_plugin_path()?; - ensure_opencode_plugin_installed(&path, verbose)?; + ensure_opencode_plugin_installed(&path, ctx)?; Some(path) } else { None }; // 3. Patch CLAUDE.md (add @RTK.md, migrate if needed) - let migrated = patch_claude_md(&claude_md_path, verbose)?; - - // 4. Print success message - println!("\nRTK hook registered (global).\n"); - println!(" Command: {}", CLAUDE_HOOK_COMMAND); - println!(" RTK.md: {} (10 lines)", rtk_md_path.display()); - if let Some(path) = &opencode_plugin_path { - println!(" OpenCode: {}", path.display()); - } - println!(" CLAUDE.md: @RTK.md reference added"); + let migrated = patch_claude_md(&claude_md_path, ctx)?; + + // 4. Print success message (skip in dry-run) + if !dry_run { + println!("\nRTK hook registered (global).\n"); + println!(" Command: {}", CLAUDE_HOOK_COMMAND); + println!(" RTK.md: {} (10 lines)", rtk_md_path.display()); + if let Some(path) = &opencode_plugin_path { + println!(" OpenCode: {}", path.display()); + } + println!(" CLAUDE.md: @RTK.md reference added"); - if migrated { - println!("\n [ok] Migrated: removed 137-line RTK block from CLAUDE.md"); - println!(" replaced with @RTK.md (10 lines)"); + if migrated { + println!("\n [ok] Migrated: removed 137-line RTK block from CLAUDE.md"); + println!(" replaced with @RTK.md (10 lines)"); + } } // 5. Patch settings.json with binary command let patch_result = - patch_settings_json_command(CLAUDE_HOOK_COMMAND, patch_mode, verbose, install_opencode)?; + patch_settings_json_command(CLAUDE_HOOK_COMMAND, patch_mode, install_opencode, ctx)?; // Report result - match patch_result { - PatchResult::Patched => { - // Already printed by patch_settings_json_command - } - PatchResult::AlreadyPresent => { - println!("\n settings.json: hook already present"); - if install_opencode { - println!(" Restart Claude Code and OpenCode. Test with: git status"); - } else { - println!(" Restart Claude Code. Test with: git status"); + if !dry_run { + match patch_result { + PatchResult::Patched => { + // Already printed by patch_settings_json_command + } + PatchResult::AlreadyPresent => { + println!("\n settings.json: hook already present"); + if install_opencode { + println!(" Restart Claude Code and OpenCode. Test with: git status"); + } else { + println!(" Restart Claude Code. Test with: git status"); + } + } + PatchResult::Declined | PatchResult::Skipped => { + // Manual instructions already printed + } + PatchResult::WouldPatch => { + // Cannot happen outside dry_run } - } - PatchResult::Declined | PatchResult::Skipped => { - // Manual instructions already printed } } // 6. Generate user-global filters template (~/.config/rtk/filters.toml) - generate_global_filters_template(verbose)?; + generate_global_filters_template(ctx)?; - println!(); // Final newline + if !dry_run { + println!(); // Final newline + } Ok(()) } @@ -1015,14 +1187,21 @@ fn run_default_mode( /// Deletes `~/.claude/hooks/rtk-rewrite.sh` and `.rtk-hook.sha256` if present, /// and removes the stale settings.json entry so the new `rtk hook claude` entry /// can be registered. -fn migrate_old_hook_script(verbose: u8) { +fn migrate_old_hook_script(ctx: InitContext) { + let InitContext { verbose, dry_run } = ctx; if let Some(home) = dirs::home_dir() { let old_hook = home .join(CLAUDE_DIR) .join(HOOKS_SUBDIR) .join(REWRITE_HOOK_FILE); if old_hook.exists() { - if let Err(e) = std::fs::remove_file(&old_hook) { + if dry_run { + println!( + "[dry-run] would migrate legacy hook script: {}", + old_hook.display() + ); + // nosemgrep: filesystem-deletion + } else if let Err(e) = std::fs::remove_file(&old_hook) { if verbose > 0 { eprintln!(" [warn] Failed to remove old hook script: {e}"); } @@ -1031,7 +1210,7 @@ fn migrate_old_hook_script(verbose: u8) { eprintln!(" [ok] Removed old hook script: {}", old_hook.display()); } // Clean up the stale settings.json entry that pointed to the deleted script - if let Err(e) = remove_legacy_settings_entries(verbose) { + if let Err(e) = remove_legacy_settings_entries(ctx) { if verbose > 0 { eprintln!(" [warn] Failed to clean legacy settings.json entry: {e}"); } @@ -1044,19 +1223,34 @@ fn migrate_old_hook_script(verbose: u8) { .join(HOOKS_SUBDIR) .join(".rtk-hook.sha256"); if hash_file.exists() { - let _ = std::fs::remove_file(&hash_file); + if dry_run { + println!( + "[dry-run] would remove legacy hash file: {}", + hash_file.display() + ); + } else { + let _ = std::fs::remove_file(&hash_file); + } } // Remove Cursor legacy hook let cursor_hook = home.join(CURSOR_DIR).join("hooks").join(REWRITE_HOOK_FILE); if cursor_hook.exists() { - let _ = std::fs::remove_file(&cursor_hook); + if dry_run { + println!( + "[dry-run] would remove legacy Cursor hook: {}", + cursor_hook.display() + ); + } else { + let _ = std::fs::remove_file(&cursor_hook); + } } } } /// Remove only legacy `rtk-rewrite.sh` entries from settings.json. /// Preserves any existing `rtk hook claude` entries (new format). -fn remove_legacy_settings_entries(verbose: u8) -> Result<()> { +fn remove_legacy_settings_entries(ctx: InitContext) -> Result<()> { + let InitContext { verbose, dry_run } = ctx; let claude_dir = resolve_claude_dir()?; let settings_path = claude_dir.join(SETTINGS_JSON); @@ -1077,6 +1271,14 @@ fn remove_legacy_settings_entries(verbose: u8) -> Result<()> { return Ok(()); } + if dry_run { + println!( + "[dry-run] would remove legacy rtk-rewrite.sh entry from {}", + settings_path.display() + ); + return Ok(()); + } + // Backup before modifying let backup_path = settings_path.with_extension("json.bak"); fs::copy(&settings_path, &backup_path) @@ -1125,7 +1327,8 @@ fn remove_legacy_hook_entries_from_json(root: &mut serde_json::Value) -> bool { } /// Generate .rtk/filters.toml template in the current directory if not present. -fn generate_project_filters_template(verbose: u8) -> Result<()> { +fn generate_project_filters_template(ctx: InitContext) -> Result<()> { + let InitContext { verbose, dry_run } = ctx; let rtk_dir = std::path::Path::new(".rtk"); let path = rtk_dir.join("filters.toml"); @@ -1136,6 +1339,14 @@ fn generate_project_filters_template(verbose: u8) -> Result<()> { return Ok(()); } + if dry_run { + println!( + "[dry-run] would create .rtk/filters.toml template: {}", + path.display() + ); + return Ok(()); + } + fs::create_dir_all(rtk_dir) .with_context(|| format!("Failed to create directory: {}", rtk_dir.display()))?; fs::write(&path, FILTERS_TEMPLATE) @@ -1149,7 +1360,8 @@ fn generate_project_filters_template(verbose: u8) -> Result<()> { } /// Generate ~/.config/rtk/filters.toml template if not present. -fn generate_global_filters_template(verbose: u8) -> Result<()> { +fn generate_global_filters_template(ctx: InitContext) -> Result<()> { + let InitContext { verbose, dry_run } = ctx; let config_dir = dirs::config_dir().unwrap_or_else(|| std::path::PathBuf::from(".config")); let rtk_dir = config_dir.join(crate::core::constants::RTK_DATA_DIR); let path = rtk_dir.join("filters.toml"); @@ -1161,6 +1373,14 @@ fn generate_global_filters_template(verbose: u8) -> Result<()> { return Ok(()); } + if dry_run { + println!( + "[dry-run] would create global filters template: {}", + path.display() + ); + return Ok(()); + } + fs::create_dir_all(&rtk_dir) .with_context(|| format!("Failed to create directory: {}", rtk_dir.display()))?; fs::write(&path, FILTERS_GLOBAL_TEMPLATE) @@ -1177,9 +1397,10 @@ fn generate_global_filters_template(verbose: u8) -> Result<()> { fn run_hook_only_mode( global: bool, patch_mode: PatchMode, - verbose: u8, install_opencode: bool, + ctx: InitContext, ) -> Result<()> { + let InitContext { dry_run, .. } = ctx; if !global { eprintln!("[warn] Warning: --hook-only only makes sense with --global"); eprintln!(" For local projects, use default mode or --claude-md"); @@ -1187,61 +1408,71 @@ fn run_hook_only_mode( } // Migrate old hook script if present - migrate_old_hook_script(verbose); + migrate_old_hook_script(ctx); let opencode_plugin_path = if install_opencode { let path = prepare_opencode_plugin_path()?; - ensure_opencode_plugin_installed(&path, verbose)?; + ensure_opencode_plugin_installed(&path, ctx)?; Some(path) } else { None }; - println!("\nRTK hook registered (hook-only mode).\n"); - println!(" Command: {}", CLAUDE_HOOK_COMMAND); - if let Some(path) = &opencode_plugin_path { - println!(" OpenCode: {}", path.display()); + if !dry_run { + println!("\nRTK hook registered (hook-only mode).\n"); + println!(" Command: {}", CLAUDE_HOOK_COMMAND); + if let Some(path) = &opencode_plugin_path { + println!(" OpenCode: {}", path.display()); + } + println!( + " Note: No RTK.md created. Claude won't know about meta commands (gain, discover, proxy)." + ); } - println!( - " Note: No RTK.md created. Claude won't know about meta commands (gain, discover, proxy)." - ); // Patch settings.json with binary command let patch_result = - patch_settings_json_command(CLAUDE_HOOK_COMMAND, patch_mode, verbose, install_opencode)?; + patch_settings_json_command(CLAUDE_HOOK_COMMAND, patch_mode, install_opencode, ctx)?; // Report result - match patch_result { - PatchResult::Patched => { - // Already printed by patch_settings_json_command - } - PatchResult::AlreadyPresent => { - println!("\n settings.json: hook already present"); - if install_opencode { - println!(" Restart Claude Code and OpenCode. Test with: git status"); - } else { - println!(" Restart Claude Code. Test with: git status"); + if !dry_run { + match patch_result { + PatchResult::Patched => { + // Already printed by patch_settings_json_command + } + PatchResult::AlreadyPresent => { + println!("\n settings.json: hook already present"); + if install_opencode { + println!(" Restart Claude Code and OpenCode. Test with: git status"); + } else { + println!(" Restart Claude Code. Test with: git status"); + } + } + PatchResult::Declined | PatchResult::Skipped => { + // Manual instructions already printed + } + PatchResult::WouldPatch => { + // Cannot happen outside dry_run } - } - PatchResult::Declined | PatchResult::Skipped => { - // Manual instructions already printed } } - println!(); // Final newline + if !dry_run { + println!(); // Final newline + } Ok(()) } /// Legacy mode: full 137-line injection into CLAUDE.md -fn run_claude_md_mode(global: bool, verbose: u8, install_opencode: bool) -> Result<()> { +fn run_claude_md_mode(global: bool, install_opencode: bool, ctx: InitContext) -> Result<()> { + let InitContext { verbose, dry_run } = ctx; let path = if global { resolve_claude_dir()?.join(CLAUDE_MD) } else { PathBuf::from(CLAUDE_MD) }; - if global { + if global && !dry_run { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } @@ -1258,18 +1489,31 @@ fn run_claude_md_mode(global: bool, verbose: u8, install_opencode: bool) -> Resu match action { RtkBlockUpsert::Added => { - fs::write(&path, new_content)?; - println!("[ok] Added rtk instructions to existing {}", path.display()); + if dry_run { + println!("[dry-run] would add rtk instructions to {}", path.display()); + } else { + fs::write(&path, new_content)?; + println!("[ok] Added rtk instructions to existing {}", path.display()); + } } RtkBlockUpsert::Updated => { - fs::write(&path, new_content)?; - println!("[ok] Updated rtk instructions in {}", path.display()); + if dry_run { + println!( + "[dry-run] would update rtk instructions in {}", + path.display() + ); + } else { + fs::write(&path, new_content)?; + println!("[ok] Updated rtk instructions in {}", path.display()); + } } RtkBlockUpsert::Unchanged => { - println!( - "[ok] {} already contains up-to-date rtk instructions", - path.display() - ); + if !dry_run { + println!( + "[ok] {} already contains up-to-date rtk instructions", + path.display() + ); + } return Ok(()); } RtkBlockUpsert::Malformed => { @@ -1296,6 +1540,11 @@ fn run_claude_md_mode(global: bool, verbose: u8, install_opencode: bool) -> Resu return Ok(()); } } + } else if dry_run { + println!( + "[dry-run] would create {} with rtk instructions", + path.display() + ); } else { fs::write(&path, RTK_INSTRUCTIONS)?; println!("[ok] Created {} with rtk instructions", path.display()); @@ -1304,14 +1553,18 @@ fn run_claude_md_mode(global: bool, verbose: u8, install_opencode: bool) -> Resu if global { if install_opencode { let opencode_plugin_path = prepare_opencode_plugin_path()?; - ensure_opencode_plugin_installed(&opencode_plugin_path, verbose)?; - println!( - "[ok] OpenCode plugin installed: {}", - opencode_plugin_path.display() - ); + ensure_opencode_plugin_installed(&opencode_plugin_path, ctx)?; + if !dry_run { + println!( + "[ok] OpenCode plugin installed: {}", + opencode_plugin_path.display() + ); + } } - println!(" Claude Code will now use rtk in all sessions"); - } else { + if !dry_run { + println!(" Claude Code will now use rtk in all sessions"); + } + } else if !dry_run { println!(" Claude Code will use rtk in this project"); } @@ -1328,61 +1581,91 @@ const CLINE_RULES: &str = include_str!("../../hooks/cline/rules.md"); // ─── Cline / Roo Code support ───────────────────────────────── -fn run_cline_mode(verbose: u8) -> Result<()> { +fn run_cline_mode(ctx: InitContext) -> Result<()> { + let InitContext { verbose, dry_run } = ctx; // Cline reads .clinerules from the project root (workspace-scoped) let rules_path = PathBuf::from(".clinerules"); let existing = fs::read_to_string(&rules_path).unwrap_or_default(); if existing.contains("RTK") || existing.contains("rtk") { - println!("\nRTK already configured for Cline in this project.\n"); - println!(" Rules: .clinerules (already present)"); + if !dry_run { + println!("\nRTK already configured for Cline in this project.\n"); + println!(" Rules: .clinerules (already present)"); + } } else { let new_content = if existing.trim().is_empty() { CLINE_RULES.to_string() } else { format!("{}\n\n{}", existing.trim(), CLINE_RULES) }; - fs::write(&rules_path, &new_content).context("Failed to write .clinerules")?; + if dry_run { + println!( + "[dry-run] would write .clinerules: {}", + rules_path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", new_content); + } + } else { + fs::write(&rules_path, &new_content).context("Failed to write .clinerules")?; - if verbose > 0 { - eprintln!("Wrote .clinerules"); - } + if verbose > 0 { + eprintln!("Wrote .clinerules"); + } - println!("\nRTK configured for Cline.\n"); - println!(" Rules: .clinerules (installed)"); + println!("\nRTK configured for Cline.\n"); + println!(" Rules: .clinerules (installed)"); + } + } + if !dry_run { + println!(" Cline will now use rtk commands for token savings."); + println!(" Test with: git status\n"); } - println!(" Cline will now use rtk commands for token savings."); - println!(" Test with: git status\n"); Ok(()) } -fn run_windsurf_mode(verbose: u8) -> Result<()> { +fn run_windsurf_mode(ctx: InitContext) -> Result<()> { + let InitContext { verbose, dry_run } = ctx; // Windsurf reads .windsurfrules from the project root (workspace-scoped). // Global rules (~/.codeium/windsurf/memories/global_rules.md) are unreliable. let rules_path = PathBuf::from(".windsurfrules"); let existing = fs::read_to_string(&rules_path).unwrap_or_default(); if existing.contains("RTK") || existing.contains("rtk") { - println!("\nRTK already configured for Windsurf in this project.\n"); - println!(" Rules: .windsurfrules (already present)"); + if !dry_run { + println!("\nRTK already configured for Windsurf in this project.\n"); + println!(" Rules: .windsurfrules (already present)"); + } } else { let new_content = if existing.trim().is_empty() { WINDSURF_RULES.to_string() } else { format!("{}\n\n{}", existing.trim(), WINDSURF_RULES) }; - fs::write(&rules_path, &new_content).context("Failed to write .windsurfrules")?; + if dry_run { + println!( + "[dry-run] would write .windsurfrules: {}", + rules_path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", new_content); + } + } else { + fs::write(&rules_path, &new_content).context("Failed to write .windsurfrules")?; - if verbose > 0 { - eprintln!("Wrote .windsurfrules"); - } + if verbose > 0 { + eprintln!("Wrote .windsurfrules"); + } - println!("\nRTK configured for Windsurf Cascade.\n"); - println!(" Rules: .windsurfrules (installed)"); + println!("\nRTK configured for Windsurf Cascade.\n"); + println!(" Rules: .windsurfrules (installed)"); + } + } + if !dry_run { + println!(" Cascade will now use rtk commands for token savings."); + println!(" Restart Windsurf. Test with: git status\n"); } - println!(" Cascade will now use rtk commands for token savings."); - println!(" Restart Windsurf. Test with: git status\n"); Ok(()) } @@ -1391,38 +1674,56 @@ fn run_windsurf_mode(verbose: u8) -> Result<()> { const KILOCODE_RULES: &str = include_str!("../../hooks/kilocode/rules.md"); -pub fn run_kilocode_mode(verbose: u8) -> Result<()> { - run_kilocode_mode_at(&std::env::current_dir()?, verbose) +pub fn run_kilocode_mode(ctx: InitContext) -> Result<()> { + run_kilocode_mode_at(&std::env::current_dir()?, ctx) } -fn run_kilocode_mode_at(base_dir: &Path, verbose: u8) -> Result<()> { +fn run_kilocode_mode_at(base_dir: &Path, ctx: InitContext) -> Result<()> { + let InitContext { verbose, dry_run } = ctx; // Kilo Code reads .kilocode/rules/ from the project root (workspace-scoped) let target_dir = base_dir.join(".kilocode/rules"); let rules_path = target_dir.join("rtk-rules.md"); let existing = fs::read_to_string(&rules_path).unwrap_or_default(); if existing.contains("RTK") || existing.contains("rtk") { - println!("\nRTK already configured for Kilo Code in this project.\n"); - println!(" Rules: .kilocode/rules/rtk-rules.md (already present)"); + if !dry_run { + println!("\nRTK already configured for Kilo Code in this project.\n"); + println!(" Rules: .kilocode/rules/rtk-rules.md (already present)"); + } } else { - fs::create_dir_all(&target_dir).context("Failed to create .kilocode/rules directory")?; let new_content = if existing.trim().is_empty() { KILOCODE_RULES.to_string() } else { format!("{}\n\n{}", existing.trim(), KILOCODE_RULES) }; - fs::write(&rules_path, &new_content) - .context("Failed to write .kilocode/rules/rtk-rules.md")?; + if dry_run { + println!( + "[dry-run] would write {}: (and create parent dir if missing)", + rules_path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", new_content); + } + } else { + fs::create_dir_all(&target_dir) + .context("Failed to create .kilocode/rules directory")?; + fs::write(&rules_path, &new_content) + .context("Failed to write .kilocode/rules/rtk-rules.md")?; - if verbose > 0 { - eprintln!("Wrote .kilocode/rules/rtk-rules.md"); - } + if verbose > 0 { + eprintln!("Wrote .kilocode/rules/rtk-rules.md"); + } - println!("\nRTK configured for Kilo Code.\n"); - println!(" Rules: .kilocode/rules/rtk-rules.md (installed)"); + println!("\nRTK configured for Kilo Code.\n"); + println!(" Rules: .kilocode/rules/rtk-rules.md (installed)"); + } + } + if dry_run { + print_dry_run_footer(); + } else { + println!(" Kilo Code will now use rtk commands for token savings."); + println!(" Test with: git status\n"); } - println!(" Kilo Code will now use rtk commands for token savings."); - println!(" Test with: git status\n"); Ok(()) } @@ -1431,60 +1732,577 @@ fn run_kilocode_mode_at(base_dir: &Path, verbose: u8) -> Result<()> { const ANTIGRAVITY_RULES: &str = include_str!("../../hooks/antigravity/rules.md"); -pub fn run_antigravity_mode(verbose: u8) -> Result<()> { - run_antigravity_mode_at(&std::env::current_dir()?, verbose) +pub fn run_antigravity_mode(ctx: InitContext) -> Result<()> { + run_antigravity_mode_at(&std::env::current_dir()?, ctx) } -fn run_antigravity_mode_at(base_dir: &Path, verbose: u8) -> Result<()> { +fn run_antigravity_mode_at(base_dir: &Path, ctx: InitContext) -> Result<()> { + let InitContext { verbose, dry_run } = ctx; // Antigravity reads .agents/rules/ from the project root (workspace-scoped) let target_dir = base_dir.join(".agents/rules"); let rules_path = target_dir.join("antigravity-rtk-rules.md"); let existing = fs::read_to_string(&rules_path).unwrap_or_default(); if existing.contains("RTK") || existing.contains("rtk") { - println!("\nRTK already configured for Antigravity in this project.\n"); - println!(" Rules: .agents/rules/antigravity-rtk-rules.md (already present)"); + if !dry_run { + println!("\nRTK already configured for Antigravity in this project.\n"); + println!(" Rules: .agents/rules/antigravity-rtk-rules.md (already present)"); + } } else { - fs::create_dir_all(&target_dir).context("Failed to create .agents/rules directory")?; let new_content = if existing.trim().is_empty() { ANTIGRAVITY_RULES.to_string() } else { format!("{}\n\n{}", existing.trim(), ANTIGRAVITY_RULES) }; - fs::write(&rules_path, &new_content) - .context("Failed to write .agents/rules/antigravity-rtk-rules.md")?; + if dry_run { + println!( + "[dry-run] would write {}: (and create parent dir if missing)", + rules_path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", new_content); + } + } else { + fs::create_dir_all(&target_dir).context("Failed to create .agents/rules directory")?; + fs::write(&rules_path, &new_content) + .context("Failed to write .agents/rules/antigravity-rtk-rules.md")?; - if verbose > 0 { - eprintln!("Wrote .agents/rules/antigravity-rtk-rules.md"); - } + if verbose > 0 { + eprintln!("Wrote .agents/rules/antigravity-rtk-rules.md"); + } - println!("\nRTK configured for Google Antigravity.\n"); - println!(" Rules: .agents/rules/antigravity-rtk-rules.md (installed)"); + println!("\nRTK configured for Google Antigravity.\n"); + println!(" Rules: .agents/rules/antigravity-rtk-rules.md (installed)"); + } + } + if dry_run { + print_dry_run_footer(); + } else { + println!(" Antigravity will now use rtk commands for token savings."); + println!(" Test with: git status\n"); } - println!(" Antigravity will now use rtk commands for token savings."); - println!(" Test with: git status\n"); Ok(()) } -fn run_codex_mode(global: bool, verbose: u8) -> Result<()> { - let (agents_md_path, rtk_md_path) = if global { - let codex_dir = resolve_codex_dir()?; - (codex_dir.join(AGENTS_MD), codex_dir.join(RTK_MD)) +// ─── Hermes support ──────────────────────────────────────────── + +const HERMES_PLUGIN_INIT: &str = include_str!("../../hooks/hermes/rtk-rewrite/__init__.py"); +const HERMES_PLUGIN_YAML: &str = include_str!("../../hooks/hermes/rtk-rewrite/plugin.yaml"); + +pub fn run_hermes_mode(ctx: InitContext) -> Result<()> { + let hermes_home = resolve_hermes_home()?; + run_hermes_mode_at(&hermes_home, ctx) +} + +fn hermes_plugin_dir(hermes_home: &Path) -> PathBuf { + hermes_home + .join(HERMES_PLUGINS_SUBDIR) + .join(HERMES_PLUGIN_NAME) +} + +fn run_hermes_mode_at(hermes_home: &Path, ctx: InitContext) -> Result<()> { + let InitContext { dry_run, .. } = ctx; + let plugin_dir = hermes_plugin_dir(hermes_home); + if !dry_run { + fs::create_dir_all(&plugin_dir).with_context(|| { + format!( + "Failed to create Hermes plugin directory: {}", + plugin_dir.display() + ) + })?; + } + + let init_path = plugin_dir.join(HERMES_PLUGIN_INIT_FILE); + let manifest_path = plugin_dir.join(HERMES_PLUGIN_MANIFEST_FILE); + write_if_changed(&init_path, HERMES_PLUGIN_INIT, "Hermes plugin", ctx)?; + write_if_changed( + &manifest_path, + HERMES_PLUGIN_YAML, + "Hermes plugin manifest", + ctx, + )?; + + let config_path = hermes_home.join("config.yaml"); + let existing_config = if config_path.exists() { + fs::read_to_string(&config_path) + .with_context(|| format!("Failed to read Hermes config: {}", config_path.display()))? } else { - (PathBuf::from(AGENTS_MD), PathBuf::from(RTK_MD)) + String::new() }; + let patched_config = patch_hermes_config(&existing_config); + write_if_changed(&config_path, &patched_config, "Hermes config", ctx)?; + + if dry_run { + print_dry_run_footer(); + } else { + println!("\nRTK configured for Hermes.\n"); + println!(" Plugin: {}", plugin_dir.display()); + println!(" Config: {}", config_path.display()); + println!(" Hermes will now rewrite terminal commands through rtk."); + println!(" Restart Hermes. Test with: git status\n"); + } - run_codex_mode_with_paths(agents_md_path, rtk_md_path, global, verbose) + Ok(()) } -fn run_codex_mode_with_paths( - agents_md_path: PathBuf, +pub fn uninstall_hermes(ctx: InitContext) -> Result<()> { + let InitContext { dry_run, .. } = ctx; + let hermes_home = resolve_hermes_home()?; + let removed = uninstall_hermes_at(&hermes_home, ctx)?; + + if removed.is_empty() { + println!("RTK Hermes support was not installed (nothing to remove)"); + } else { + let header = if dry_run { + "[dry-run] would uninstall RTK for Hermes CLI:" + } else { + "RTK uninstalled for Hermes CLI:" + }; + println!("{}", header); + for item in removed { + println!(" - {}", item); + } + } + + if dry_run { + print_dry_run_footer(); + } + + Ok(()) +} + +fn uninstall_hermes_at(hermes_home: &Path, ctx: InitContext) -> Result> { + let InitContext { verbose, dry_run } = ctx; + let mut removed = Vec::new(); + + let plugin_dir = hermes_plugin_dir(hermes_home); + if plugin_dir.exists() { + if dry_run { + println!( + "[dry-run] would remove Hermes plugin directory: {}", + plugin_dir.display() + ); + } else { + // nosemgrep: filesystem-deletion -- uninstall intentionally removes only RTK's Hermes plugin directory. + fs::remove_dir_all(&plugin_dir).with_context(|| { + format!( + "Failed to remove Hermes plugin directory: {}", + plugin_dir.display() + ) + })?; + if verbose > 0 { + eprintln!("Removed Hermes plugin directory: {}", plugin_dir.display()); + } + } + removed.push(format!("Hermes plugin: {}", plugin_dir.display())); + } + + let config_path = hermes_home.join("config.yaml"); + if config_path.exists() { + let existing_config = fs::read_to_string(&config_path) + .with_context(|| format!("Failed to read Hermes config: {}", config_path.display()))?; + let patched_config = unpatch_hermes_config(&existing_config); + + if patched_config != existing_config { + if dry_run { + println!( + "[dry-run] would update Hermes config: {}", + config_path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", patched_config); + } + } else { + atomic_write(&config_path, &patched_config).with_context(|| { + format!("Failed to write Hermes config: {}", config_path.display()) + })?; + if verbose > 0 { + eprintln!("Updated Hermes config: {}", config_path.display()); + } + } + removed.push("Hermes config: removed RTK plugin entry".to_string()); + } + } + + Ok(removed) +} + +fn patch_hermes_config(existing: &str) -> String { + rewrite_hermes_config(existing, true) +} + +fn unpatch_hermes_config(existing: &str) -> String { + rewrite_hermes_config(existing, false) +} + +fn rewrite_hermes_config(existing: &str, add_rtk: bool) -> String { + if existing.trim().is_empty() { + return if add_rtk { + hermes_plugins_block() + } else { + String::new() + }; + } + + let mut lines = split_yaml_lines(existing); + let Some(plugins_idx) = find_yaml_key_line(&lines, "plugins", 0, None) else { + return if add_rtk { + append_hermes_plugins_block(existing) + } else { + existing.to_string() + }; + }; + + let plugins_indent = yaml_indent(&lines[plugins_idx]); + let plugins_end = yaml_block_end(&lines, plugins_idx, plugins_indent); + let Some(enabled_idx) = find_yaml_key_line( + &lines, + "enabled", + plugins_idx + 1, + Some((plugins_end, plugins_indent)), + ) else { + if add_rtk { + let (enabled_indent, item_indent) = + hermes_missing_enabled_indents(&lines, plugins_idx, plugins_end, plugins_indent); + let enabled_block = format!( + "{}enabled:\n{}- {}\n", + " ".repeat(enabled_indent), + " ".repeat(item_indent), + HERMES_PLUGIN_NAME + ); + ensure_previous_yaml_line_ends_with_newline(&mut lines, plugins_end); + lines.insert(plugins_end, enabled_block); + } + return lines.concat(); + }; + + if yaml_line_without_ending(&lines[enabled_idx]).contains('[') { + rewrite_inline_hermes_enabled(&mut lines, enabled_idx, add_rtk); + return lines.concat(); + } + + rewrite_block_hermes_enabled(&mut lines, enabled_idx, add_rtk); + lines.concat() +} + +fn split_yaml_lines(input: &str) -> Vec { + if input.is_empty() { + Vec::new() + } else { + input.split_inclusive('\n').map(str::to_string).collect() + } +} + +fn ensure_previous_yaml_line_ends_with_newline(lines: &mut [String], insert_idx: usize) { + if insert_idx == 0 { + return; + } + + if let Some(previous) = lines.get_mut(insert_idx - 1) { + if !previous.ends_with('\n') { + previous.push('\n'); + } + } +} + +fn hermes_plugins_block() -> String { + format!("plugins:\n enabled:\n - {}\n", HERMES_PLUGIN_NAME) +} + +fn append_hermes_plugins_block(existing: &str) -> String { + let mut patched = existing.to_string(); + if !patched.ends_with('\n') { + patched.push('\n'); + } + patched.push_str(&hermes_plugins_block()); + patched +} + +fn find_yaml_key_line( + lines: &[String], + key: &str, + start: usize, + block: Option<(usize, usize)>, +) -> Option { + let end = block.map_or(lines.len(), |(end, _)| end); + let min_indent = block.map(|(_, indent)| indent); + + lines[start..end] + .iter() + .enumerate() + .find_map(|(offset, line)| { + let raw = yaml_line_without_ending(line); + let trimmed = raw.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + return None; + } + + if min_indent.is_some_and(|indent| yaml_indent(line) <= indent) { + return None; + } + + let is_key = trimmed == format!("{key}:") || trimmed.starts_with(&format!("{key}:")); + is_key.then_some(start + offset) + }) +} + +fn yaml_block_end(lines: &[String], start: usize, parent_indent: usize) -> usize { + lines[start + 1..] + .iter() + .enumerate() + .find_map(|(offset, line)| { + let raw = yaml_line_without_ending(line); + let trimmed = raw.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + return None; + } + + (yaml_indent(line) <= parent_indent).then_some(start + 1 + offset) + }) + .unwrap_or(lines.len()) +} + +fn rewrite_inline_hermes_enabled(lines: &mut [String], enabled_idx: usize, add_rtk: bool) { + let line_ending = yaml_line_ending(&lines[enabled_idx]); + let raw = yaml_line_without_ending(&lines[enabled_idx]); + let Some((prefix, rest)) = raw.split_once('[') else { + return; + }; + let Some((items_raw, suffix)) = rest.rsplit_once(']') else { + return; + }; + + let mut items = Vec::new(); + let mut saw_rtk = false; + for item in items_raw.split(',') { + let trimmed = item.trim(); + if trimmed.is_empty() { + continue; + } + + if is_hermes_plugin_name(trimmed) { + if add_rtk && !saw_rtk { + items.push(trimmed.to_string()); + saw_rtk = true; + } + } else { + items.push(trimmed.to_string()); + } + } + + if add_rtk && !saw_rtk { + items.push(HERMES_PLUGIN_NAME.to_string()); + } + + let replacement = if items.is_empty() { + format!("{}[]{}{}", prefix, suffix, line_ending) + } else { + format!("{}[{}]{}{}", prefix, items.join(", "), suffix, line_ending) + }; + lines[enabled_idx] = replacement; +} + +fn rewrite_block_hermes_enabled(lines: &mut Vec, enabled_idx: usize, add_rtk: bool) { + let enabled_end = hermes_enabled_list_end(lines, enabled_idx); + let item_indent = hermes_enabled_list_item_indent(lines, enabled_idx, enabled_end); + let mut kept = Vec::with_capacity(lines.len() + 1); + let mut saw_rtk = false; + + for line in &lines[enabled_idx + 1..enabled_end] { + if is_yaml_list_item_named(line, HERMES_PLUGIN_NAME) { + if add_rtk && !saw_rtk { + kept.push(line.clone()); + saw_rtk = true; + } + continue; + } + + kept.push(line.clone()); + } + + if add_rtk && !saw_rtk { + let insert_idx = kept.len(); + ensure_previous_yaml_line_ends_with_newline(&mut kept, insert_idx); + kept.push(format!( + "{}- {}\n", + " ".repeat(item_indent), + HERMES_PLUGIN_NAME + )); + } + + let mut enabled_line = if add_rtk || kept.iter().any(|line| is_yaml_list_item_line(line)) { + lines[enabled_idx].clone() + } else { + collapse_yaml_list_key_to_empty(&lines[enabled_idx]) + }; + + if add_rtk + && kept + .iter() + .any(|line| is_yaml_list_item_named(line, HERMES_PLUGIN_NAME)) + && !enabled_line.ends_with('\n') + { + enabled_line.push('\n'); + } + + let mut patched = Vec::with_capacity(lines.len() + 1); + patched.extend_from_slice(&lines[..enabled_idx]); + patched.push(enabled_line); + patched.extend(kept); + patched.extend_from_slice(&lines[enabled_end..]); + *lines = patched; +} + +fn hermes_enabled_list_end(lines: &[String], enabled_idx: usize) -> usize { + let enabled_indent = yaml_indent(&lines[enabled_idx]); + + lines[enabled_idx + 1..] + .iter() + .enumerate() + .find_map(|(offset, line)| { + let raw = yaml_line_without_ending(line); + let trimmed = raw.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + return None; + } + + let indent = yaml_indent(line); + if indent < enabled_indent + || (indent == enabled_indent && !is_yaml_list_item_line(line)) + { + return Some(enabled_idx + 1 + offset); + } + + None + }) + .unwrap_or(lines.len()) +} + +fn hermes_enabled_list_item_indent( + lines: &[String], + enabled_idx: usize, + enabled_end: usize, +) -> usize { + lines[enabled_idx + 1..enabled_end] + .iter() + .find(|line| is_yaml_list_item_line(line)) + .map(|line| yaml_indent(line)) + .unwrap_or_else(|| yaml_indent(&lines[enabled_idx]) + 2) +} + +fn hermes_missing_enabled_indents( + lines: &[String], + plugins_idx: usize, + plugins_end: usize, + plugins_indent: usize, +) -> (usize, usize) { + let child_indent = lines[plugins_idx + 1..plugins_end] + .iter() + .filter_map(|line| { + let raw = yaml_line_without_ending(line); + let trimmed = raw.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + return None; + } + + let indent = yaml_indent(line); + (indent > plugins_indent).then_some(indent) + }) + .min() + .unwrap_or(plugins_indent + 2); + + let uses_indentationless_sequences = lines[plugins_idx + 1..plugins_end] + .iter() + .any(|line| is_yaml_list_item_line(line) && yaml_indent(line) == child_indent); + + let item_indent = if uses_indentationless_sequences { + child_indent + } else { + child_indent + 2 + }; + + (child_indent, item_indent) +} + +fn yaml_line_without_ending(line: &str) -> &str { + line.trim_end_matches(['\r', '\n']) +} + +fn yaml_line_ending(line: &str) -> &str { + if line.ends_with("\r\n") { + "\r\n" + } else if line.ends_with('\n') { + "\n" + } else { + "" + } +} + +fn yaml_indent(line: &str) -> usize { + yaml_line_without_ending(line) + .chars() + .take_while(|ch| ch.is_whitespace()) + .count() +} + +fn is_yaml_list_item_named(line: &str, expected: &str) -> bool { + let trimmed = yaml_line_without_ending(line).trim(); + let Some(item) = trimmed.strip_prefix("- ") else { + return false; + }; + + normalized_yaml_scalar(item).is_some_and(|item| item == expected) +} + +fn is_yaml_list_item_line(line: &str) -> bool { + yaml_line_without_ending(line).trim().starts_with("- ") +} + +fn is_hermes_plugin_name(value: &str) -> bool { + normalized_yaml_scalar(value).is_some_and(|item| item == HERMES_PLUGIN_NAME) +} + +fn collapse_yaml_list_key_to_empty(line: &str) -> String { + let raw = yaml_line_without_ending(line); + let indent = yaml_indent(line); + let Some((key, suffix)) = raw.split_once(':') else { + return format!("{}enabled: []\n", " ".repeat(indent)); + }; + + let comment = suffix + .find('#') + .map(|idx| format!(" {}", suffix[idx..].trim_start())) + .unwrap_or_default(); + + format!("{}: []{}\n", key, comment) +} + +fn normalized_yaml_scalar(value: &str) -> Option { + let without_comment = value.split_once('#').map_or(value, |(item, _)| item); + let trimmed = without_comment.trim().trim_matches(['\'', '"']); + (!trimmed.is_empty()).then(|| trimmed.to_string()) +} + +fn run_codex_mode(global: bool, ctx: InitContext) -> Result<()> { + let (agents_md_path, rtk_md_path) = if global { + let codex_dir = resolve_codex_dir()?; + (codex_dir.join(AGENTS_MD), codex_dir.join(RTK_MD)) + } else { + (PathBuf::from(AGENTS_MD), PathBuf::from(RTK_MD)) + }; + + run_codex_mode_with_paths(agents_md_path, rtk_md_path, global, ctx) +} + +fn run_codex_mode_with_paths( + agents_md_path: PathBuf, rtk_md_path: PathBuf, global: bool, - verbose: u8, + ctx: InitContext, ) -> Result<()> { - if global { + let InitContext { dry_run, .. } = ctx; + if global && !dry_run { if let Some(parent) = agents_md_path.parent() { fs::create_dir_all(parent).with_context(|| { format!( @@ -1508,26 +2326,28 @@ fn run_codex_mode_with_paths( RTK_MD_REF.to_string() }; - write_if_changed(&rtk_md_path, RTK_SLIM_CODEX, RTK_MD, verbose)?; - let added_ref = patch_agents_md(&agents_md_path, &rtk_md_ref, verbose)?; + write_if_changed(&rtk_md_path, RTK_SLIM_CODEX, RTK_MD, ctx)?; + let added_ref = patch_agents_md(&agents_md_path, &rtk_md_ref, ctx)?; - println!("\nRTK configured for Codex CLI.\n"); - println!(" RTK.md: {}", rtk_md_path.display()); - if added_ref { - println!(" AGENTS.md: {} reference added", rtk_md_ref); - } else { - println!(" AGENTS.md: {} reference already present", rtk_md_ref); - } - if global { - println!( - "\n Codex global instructions path: {}", - agents_md_path.display() - ); - } else { - println!( - "\n Codex project instructions path: {}", - agents_md_path.display() - ); + if !dry_run { + println!("\nRTK configured for Codex CLI.\n"); + println!(" RTK.md: {}", rtk_md_path.display()); + if added_ref { + println!(" AGENTS.md: {} reference added", rtk_md_ref); + } else { + println!(" AGENTS.md: {} reference already present", rtk_md_ref); + } + if global { + println!( + "\n Codex global instructions path: {}", + agents_md_path.display() + ); + } else { + println!( + "\n Codex project instructions path: {}", + agents_md_path.display() + ); + } } Ok(()) @@ -1597,7 +2417,8 @@ fn upsert_rtk_block(content: &str, block: &str) -> (String, RtkBlockUpsert) { } /// Patch CLAUDE.md: add @RTK.md, migrate if old block exists -fn patch_claude_md(path: &Path, verbose: u8) -> Result { +fn patch_claude_md(path: &Path, ctx: InitContext) -> Result { + let InitContext { verbose, dry_run } = ctx; let mut content = if path.exists() { fs::read_to_string(path)? } else { @@ -1624,7 +2445,14 @@ fn patch_claude_md(path: &Path, verbose: u8) -> Result { eprintln!("@RTK.md reference already present in CLAUDE.md"); } if migrated { - fs::write(path, content)?; + if dry_run { + println!( + "[dry-run] would migrate old RTK block in CLAUDE.md: {}", + path.display() + ); + } else { + fs::write(path, content)?; + } } return Ok(migrated); } @@ -1636,17 +2464,28 @@ fn patch_claude_md(path: &Path, verbose: u8) -> Result { format!("{}\n\n@RTK.md\n", content.trim()) }; - fs::write(path, new_content)?; + if dry_run { + println!( + "[dry-run] would add @RTK.md reference to CLAUDE.md: {}", + path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", new_content); + } + } else { + fs::write(path, new_content)?; - if verbose > 0 { - eprintln!("Added @RTK.md reference to CLAUDE.md"); + if verbose > 0 { + eprintln!("Added @RTK.md reference to CLAUDE.md"); + } } Ok(migrated) } /// Patch AGENTS.md: add @RTK.md (or absolute path), migrate old inline block if present -fn patch_agents_md(path: &Path, rtk_md_ref: &str, verbose: u8) -> Result { +fn patch_agents_md(path: &Path, rtk_md_ref: &str, ctx: InitContext) -> Result { + let InitContext { verbose, dry_run } = ctx; let mut content = if path.exists() { fs::read_to_string(path) .with_context(|| format!("Failed to read AGENTS.md: {}", path.display()))? @@ -1675,16 +2514,32 @@ fn patch_agents_md(path: &Path, rtk_md_ref: &str, verbose: u8) -> Result { if rtk_md_ref != RTK_MD_REF && content.contains(RTK_MD_REF) && !content.contains(rtk_md_ref) { content = content.replace(RTK_MD_REF, rtk_md_ref); - atomic_write(path, &content) - .with_context(|| format!("Failed to write AGENTS.md: {}", path.display()))?; - if verbose > 0 { - eprintln!("Migrated {} to {}", RTK_MD_REF, rtk_md_ref); + if dry_run { + println!( + "[dry-run] would migrate {} to {} in {}", + RTK_MD_REF, + rtk_md_ref, + path.display() + ); + } else { + atomic_write(path, &content) + .with_context(|| format!("Failed to write AGENTS.md: {}", path.display()))?; + if verbose > 0 { + eprintln!("Migrated {} to {}", RTK_MD_REF, rtk_md_ref); + } } return Ok(true); } if migrated { - atomic_write(path, &content) - .with_context(|| format!("Failed to write AGENTS.md: {}", path.display()))?; + if dry_run { + println!( + "[dry-run] would write migrated AGENTS.md: {}", + path.display() + ); + } else { + atomic_write(path, &content) + .with_context(|| format!("Failed to write AGENTS.md: {}", path.display()))?; + } } return Ok(false); } @@ -1695,10 +2550,21 @@ fn patch_agents_md(path: &Path, rtk_md_ref: &str, verbose: u8) -> Result { format!("{}\n\n{}\n", content.trim(), rtk_md_ref) }; - atomic_write(path, &new_content) - .with_context(|| format!("Failed to write AGENTS.md: {}", path.display()))?; - if verbose > 0 { - eprintln!("Added {} reference to AGENTS.md", rtk_md_ref); + if dry_run { + println!( + "[dry-run] would add {} reference to AGENTS.md: {}", + rtk_md_ref, + path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", new_content); + } + } else { + atomic_write(path, &new_content) + .with_context(|| format!("Failed to write AGENTS.md: {}", path.display()))?; + if verbose > 0 { + eprintln!("Added {} reference to AGENTS.md", rtk_md_ref); + } } Ok(true) @@ -1711,7 +2577,8 @@ fn has_rtk_reference(content: &str, refs: &[&str]) -> bool { .any(|line| refs.contains(&line)) } -fn remove_rtk_reference_from_agents(path: &Path, refs: &[&str], verbose: u8) -> Result { +fn remove_rtk_reference_from_agents(path: &Path, refs: &[&str], ctx: InitContext) -> Result { + let InitContext { verbose, dry_run } = ctx; if !path.exists() { return Ok(false); } @@ -1731,6 +2598,18 @@ fn remove_rtk_reference_from_agents(path: &Path, refs: &[&str], verbose: u8) -> .collect::>() .join("\n"); let cleaned = clean_double_blanks(&new_content); + + if dry_run { + println!( + "[dry-run] would remove RTK.md reference from AGENTS.md: {}", + path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", cleaned); + } + return Ok(true); + } + atomic_write(path, &cleaned) .with_context(|| format!("Failed to write AGENTS.md: {}", path.display()))?; @@ -1818,6 +2697,23 @@ fn resolve_codex_dir_from( .context("Cannot determine Codex config directory. Set $CODEX_HOME or $HOME.") } +fn resolve_hermes_home() -> Result { + resolve_hermes_home_from_env(dirs::home_dir(), std::env::var_os("HERMES_HOME")) +} + +fn resolve_hermes_home_from_env( + home_dir: Option, + hermes_home: Option, +) -> Result { + if let Some(path) = hermes_home.filter(|value| !value.is_empty()) { + return Ok(PathBuf::from(path)); + } + + home_dir + .map(|home| home.join(HERMES_DIR)) + .context("Cannot determine Hermes home directory. Set $HERMES_HOME or $HOME.") +} + fn codex_rtk_md_ref(codex_dir: &Path) -> String { format!("@{}", codex_dir.join(RTK_MD).display()) } @@ -1835,33 +2731,43 @@ fn opencode_plugin_path(opencode_dir: &Path) -> PathBuf { fn prepare_opencode_plugin_path() -> Result { let opencode_dir = resolve_opencode_dir()?; let path = opencode_plugin_path(&opencode_dir); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).with_context(|| { - format!( - "Failed to create OpenCode plugin directory: {}", - parent.display() - ) - })?; - } + // Directory creation is deferred to install time (caller guards on dry_run). Ok(path) } /// Write OpenCode plugin file if missing or outdated -fn ensure_opencode_plugin_installed(path: &Path, verbose: u8) -> Result { - write_if_changed(path, OPENCODE_PLUGIN, "OpenCode plugin", verbose) +fn ensure_opencode_plugin_installed(path: &Path, ctx: InitContext) -> Result { + let InitContext { dry_run, .. } = ctx; + // Ensure parent dir exists (skip in dry-run) + if !dry_run { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| { + format!( + "Failed to create OpenCode plugin directory: {}", + parent.display() + ) + })?; + } + } + write_if_changed(path, OPENCODE_PLUGIN, "OpenCode plugin", ctx) } /// Remove OpenCode plugin file -fn remove_opencode_plugin(verbose: u8) -> Result> { +fn remove_opencode_plugin(ctx: InitContext) -> Result> { + let InitContext { verbose, dry_run } = ctx; let opencode_dir = resolve_opencode_dir()?; let path = opencode_plugin_path(&opencode_dir); let mut removed = Vec::new(); if path.exists() { - fs::remove_file(&path) - .with_context(|| format!("Failed to remove OpenCode plugin: {}", path.display()))?; - if verbose > 0 { - eprintln!("Removed OpenCode plugin: {}", path.display()); + if dry_run { + println!("[dry-run] would remove OpenCode plugin: {}", path.display()); + } else { + fs::remove_file(&path) + .with_context(|| format!("Failed to remove OpenCode plugin: {}", path.display()))?; + if verbose > 0 { + eprintln!("Removed OpenCode plugin: {}", path.display()); + } } removed.push(path); } @@ -1876,22 +2782,30 @@ fn resolve_cursor_dir() -> Result { } /// Install Cursor hooks: register binary command in hooks.json -fn install_cursor_hooks(verbose: u8) -> Result<()> { +fn install_cursor_hooks(ctx: InitContext) -> Result<()> { + let InitContext { verbose, dry_run } = ctx; let cursor_dir = resolve_cursor_dir()?; // Migrate old hook script if present let old_hook = cursor_dir.join("hooks").join(REWRITE_HOOK_FILE); if old_hook.exists() { - let _ = fs::remove_file(&old_hook); - if verbose > 0 { - eprintln!( - " [ok] Removed old Cursor hook script: {}", + if dry_run { + println!( + "[dry-run] would remove old Cursor hook script: {}", old_hook.display() ); + } else { + let _ = fs::remove_file(&old_hook); + if verbose > 0 { + eprintln!( + " [ok] Removed old Cursor hook script: {}", + old_hook.display() + ); + } } // Clean stale hooks.json entry pointing to the deleted script let hooks_json_path = cursor_dir.join(HOOKS_JSON); - if let Err(e) = remove_legacy_cursor_hooks_json_entries(&hooks_json_path, verbose) { + if let Err(e) = remove_legacy_cursor_hooks_json_entries(&hooks_json_path, ctx) { if verbose > 0 { eprintln!(" [warn] Failed to clean legacy Cursor hooks.json entry: {e}"); } @@ -1900,27 +2814,30 @@ fn install_cursor_hooks(verbose: u8) -> Result<()> { // Create or patch hooks.json with binary command let hooks_json_path = cursor_dir.join(HOOKS_JSON); - let patched = patch_cursor_hooks_json(&hooks_json_path, verbose)?; + let patched = patch_cursor_hooks_json(&hooks_json_path, ctx)?; - // Report - println!("\nCursor hook registered (global).\n"); - println!(" Command: {}", CURSOR_HOOK_COMMAND); - println!(" hooks.json: {}", hooks_json_path.display()); + // Report (skip in dry-run) + if !dry_run { + println!("\nCursor hook registered (global).\n"); + println!(" Command: {}", CURSOR_HOOK_COMMAND); + println!(" hooks.json: {}", hooks_json_path.display()); - if patched { - println!(" hooks.json: RTK preToolUse entry added"); - } else { - println!(" hooks.json: RTK preToolUse entry already present"); - } + if patched { + println!(" hooks.json: RTK preToolUse entry added"); + } else { + println!(" hooks.json: RTK preToolUse entry already present"); + } - println!(" Cursor reloads hooks.json automatically. Test with: git status\n"); + println!(" Cursor reloads hooks.json automatically. Test with: git status\n"); + } Ok(()) } /// Patch ~/.cursor/hooks.json to add RTK preToolUse hook. /// Returns true if the file was modified. -fn patch_cursor_hooks_json(path: &Path, verbose: u8) -> Result { +fn patch_cursor_hooks_json(path: &Path, ctx: InitContext) -> Result { + let InitContext { verbose, dry_run } = ctx; let mut root = if path.exists() { let content = fs::read_to_string(path) .with_context(|| format!("Failed to read {}", path.display()))?; @@ -1944,6 +2861,20 @@ fn patch_cursor_hooks_json(path: &Path, verbose: u8) -> Result { insert_cursor_hook_entry(&mut root)?; + let serialized = + serde_json::to_string_pretty(&root).context("Failed to serialize hooks.json")?; + + if dry_run { + println!( + "[dry-run] would patch Cursor hooks.json: {}", + path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", serialized); + } + return Ok(true); + } + // Backup if exists if path.exists() { let backup_path = path.with_extension("json.bak"); @@ -1955,8 +2886,6 @@ fn patch_cursor_hooks_json(path: &Path, verbose: u8) -> Result { } // Atomic write - let serialized = - serde_json::to_string_pretty(&root).context("Failed to serialize hooks.json")?; atomic_write(path, &serialized)?; Ok(true) @@ -2015,7 +2944,8 @@ fn insert_cursor_hook_entry(root: &mut serde_json::Value) -> Result<()> { /// Remove only legacy `rtk-rewrite.sh` entries from Cursor hooks.json. /// Preserves any existing `rtk hook cursor` entries (new format). -fn remove_legacy_cursor_hooks_json_entries(path: &Path, verbose: u8) -> Result<()> { +fn remove_legacy_cursor_hooks_json_entries(path: &Path, ctx: InitContext) -> Result<()> { + let InitContext { verbose, dry_run } = ctx; if !path.exists() { return Ok(()); } @@ -2033,6 +2963,14 @@ fn remove_legacy_cursor_hooks_json_entries(path: &Path, verbose: u8) -> Result<( return Ok(()); } + if dry_run { + println!( + "[dry-run] would remove legacy rtk-rewrite.sh entry from Cursor hooks.json: {}", + path.display() + ); + return Ok(()); + } + let serialized = serde_json::to_string_pretty(&root).context("Failed to serialize hooks.json")?; atomic_write(path, &serialized)?; @@ -2068,15 +3006,25 @@ fn remove_legacy_cursor_hook_entries_from_json(root: &mut serde_json::Value) -> } /// Remove Cursor RTK artifacts: hook script + hooks.json entry -fn remove_cursor_hooks(verbose: u8) -> Result> { +fn remove_cursor_hooks(ctx: InitContext) -> Result> { + let InitContext { verbose, dry_run } = ctx; let cursor_dir = resolve_cursor_dir()?; let mut removed = Vec::new(); // 1. Remove hook script let hook_path = cursor_dir.join(HOOKS_SUBDIR).join(REWRITE_HOOK_FILE); if hook_path.exists() { - fs::remove_file(&hook_path) - .with_context(|| format!("Failed to remove Cursor hook: {}", hook_path.display()))?; + if dry_run { + println!( + "[dry-run] would remove Cursor hook: {}", + hook_path.display() + ); + } else { + // nosemgrep: filesystem-deletion + fs::remove_file(&hook_path).with_context(|| { + format!("Failed to remove Cursor hook: {}", hook_path.display()) + })?; + } removed.push(format!("Cursor hook: {}", hook_path.display())); } @@ -2089,18 +3037,24 @@ fn remove_cursor_hooks(verbose: u8) -> Result> { if !content.trim().is_empty() { if let Ok(mut root) = serde_json::from_str::(&content) { if remove_cursor_hook_from_json(&mut root) { - let backup_path = hooks_json_path.with_extension("json.bak"); - fs::copy(&hooks_json_path, &backup_path).ok(); - - let serialized = serde_json::to_string_pretty(&root) - .context("Failed to serialize hooks.json")?; - atomic_write(&hooks_json_path, &serialized)?; - - removed.push("Cursor hooks.json: removed RTK entry".to_string()); - - if verbose > 0 { - eprintln!("Removed RTK hook from Cursor hooks.json"); + if dry_run { + println!( + "[dry-run] would remove RTK entry from Cursor hooks.json: {}", + hooks_json_path.display() + ); + } else { + let backup_path = hooks_json_path.with_extension("json.bak"); + fs::copy(&hooks_json_path, &backup_path).ok(); + + let serialized = serde_json::to_string_pretty(&root) + .context("Failed to serialize hooks.json")?; + atomic_write(&hooks_json_path, &serialized)?; + + if verbose > 0 { + eprintln!("Removed RTK hook from Cursor hooks.json"); + } } + removed.push("Cursor hooks.json: removed RTK entry".to_string()); } } } @@ -2429,12 +3383,15 @@ fn show_codex_config() -> Result<()> { Ok(()) } -fn run_opencode_only_mode(verbose: u8) -> Result<()> { +fn run_opencode_only_mode(ctx: InitContext) -> Result<()> { + let InitContext { dry_run, .. } = ctx; let opencode_plugin_path = prepare_opencode_plugin_path()?; - ensure_opencode_plugin_installed(&opencode_plugin_path, verbose)?; - println!("\nOpenCode plugin installed (global).\n"); - println!(" OpenCode: {}", opencode_plugin_path.display()); - println!(" Restart OpenCode. Test with: git status\n"); + ensure_opencode_plugin_installed(&opencode_plugin_path, ctx)?; + if !dry_run { + println!("\nOpenCode plugin installed (global).\n"); + println!(" OpenCode: {}", opencode_plugin_path.display()); + println!(" Restart OpenCode. Test with: git status\n"); + } Ok(()) } @@ -2450,53 +3407,70 @@ fn resolve_gemini_dir() -> Result { } /// Entry point for `rtk init --gemini` -pub fn run_gemini(global: bool, hook_only: bool, patch_mode: PatchMode, verbose: u8) -> Result<()> { +pub fn run_gemini( + global: bool, + hook_only: bool, + patch_mode: PatchMode, + ctx: InitContext, +) -> Result<()> { + let InitContext { dry_run, .. } = ctx; if !global { anyhow::bail!("Gemini support is global-only. Use: rtk init -g --gemini"); } let gemini_dir = resolve_gemini_dir()?; - fs::create_dir_all(&gemini_dir).with_context(|| { - format!( - "Failed to create Gemini config dir: {}", - gemini_dir.display() - ) - })?; + if !dry_run { + fs::create_dir_all(&gemini_dir).with_context(|| { + format!( + "Failed to create Gemini config dir: {}", + gemini_dir.display() + ) + })?; + } // 1. Install hook script let hook_dir = gemini_dir.join("hooks"); - fs::create_dir_all(&hook_dir) - .with_context(|| format!("Failed to create hook dir: {}", hook_dir.display()))?; + if !dry_run { + fs::create_dir_all(&hook_dir) + .with_context(|| format!("Failed to create hook dir: {}", hook_dir.display()))?; + } let hook_path = hook_dir.join(GEMINI_HOOK_FILE); - write_if_changed(&hook_path, GEMINI_HOOK_SCRIPT, "Gemini hook", verbose)?; + 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 - integrity::store_hash(&hook_path) - .with_context(|| format!("Failed to store integrity hash for {}", hook_path.display()))?; + // Store integrity baseline for tamper detection (skip in dry-run) + if !dry_run { + integrity::store_hash(&hook_path).with_context(|| { + format!("Failed to store integrity hash for {}", hook_path.display()) + })?; + } // 2. Install GEMINI.md (RTK awareness for Gemini) if !hook_only { let gemini_md_path = gemini_dir.join(GEMINI_MD); // Reuse the same slim RTK awareness content - write_if_changed(&gemini_md_path, RTK_SLIM, GEMINI_MD, verbose)?; + write_if_changed(&gemini_md_path, RTK_SLIM, GEMINI_MD, ctx)?; } // 3. Patch ~/.gemini/settings.json - patch_gemini_settings(&gemini_dir, &hook_path, patch_mode, verbose)?; + patch_gemini_settings(&gemini_dir, &hook_path, patch_mode, ctx)?; - println!("\nGemini CLI hook installed (global).\n"); - println!(" Hook: {}", hook_path.display()); - if !hook_only { - println!(" GEMINI.md: {}", gemini_dir.join(GEMINI_MD).display()); + if dry_run { + print_dry_run_footer(); + } else { + println!("\nGemini CLI hook installed (global).\n"); + println!(" Hook: {}", hook_path.display()); + if !hook_only { + println!(" GEMINI.md: {}", gemini_dir.join(GEMINI_MD).display()); + } + println!(" Restart Gemini CLI. Test with: git status\n"); } - println!(" Restart Gemini CLI. Test with: git status\n"); Ok(()) } @@ -2505,8 +3479,9 @@ fn patch_gemini_settings( gemini_dir: &Path, hook_path: &Path, patch_mode: PatchMode, - verbose: u8, + ctx: InitContext, ) -> Result<()> { + let InitContext { verbose, dry_run } = ctx; let settings_path = gemini_dir.join(SETTINGS_JSON); let hook_cmd = hook_path.to_string_lossy().to_string(); @@ -2546,13 +3521,20 @@ fn patch_gemini_settings( } if patch_mode == PatchMode::Ask { - print!("Patch {} with RTK hook? [y/N] ", settings_path.display()); - std::io::Write::flush(&mut std::io::stdout())?; - let mut answer = String::new(); - std::io::stdin().read_line(&mut answer)?; - if !answer.trim().eq_ignore_ascii_case("y") { - println!("Skipped. Add hook manually later."); - return Ok(()); + if dry_run { + println!( + "[dry-run] would prompt before patching {}", + settings_path.display() + ); + } else { + print!("Patch {} with RTK hook? [y/N] ", settings_path.display()); + std::io::Write::flush(&mut std::io::stdout())?; + let mut answer = String::new(); + std::io::stdin().read_line(&mut answer)?; + if !answer.trim().eq_ignore_ascii_case("y") { + println!("Skipped. Add hook manually later."); + return Ok(()); + } } } @@ -2583,8 +3565,20 @@ fn patch_gemini_settings( .context("BeforeTool is not an array")? .push(hook_entry); - // Write atomically let content = serde_json::to_string_pretty(&settings)?; + + if dry_run { + println!( + "[dry-run] would patch Gemini settings.json: {}", + settings_path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", content); + } + return Ok(()); + } + + // Write atomically let tmp = NamedTempFile::new_in(gemini_dir)?; fs::write(tmp.path(), &content)?; tmp.persist(&settings_path) @@ -2598,7 +3592,8 @@ fn patch_gemini_settings( } /// Remove Gemini artifacts during uninstall -fn uninstall_gemini(verbose: u8) -> Result> { +fn uninstall_gemini(ctx: InitContext) -> Result> { + let InitContext { verbose, dry_run } = ctx; let mut removed = Vec::new(); let gemini_dir = match resolve_gemini_dir() { Ok(d) => d, @@ -2608,16 +3603,27 @@ fn uninstall_gemini(verbose: u8) -> Result> { // Remove hook let hook_path = gemini_dir.join(HOOKS_SUBDIR).join(GEMINI_HOOK_FILE); if hook_path.exists() { - fs::remove_file(&hook_path) - .with_context(|| format!("Failed to remove {}", hook_path.display()))?; + if dry_run { + println!( + "[dry-run] would remove Gemini hook: {}", + hook_path.display() + ); + } else { + fs::remove_file(&hook_path) + .with_context(|| format!("Failed to remove {}", hook_path.display()))?; + } removed.push(format!("Gemini hook: {}", hook_path.display())); } // Remove GEMINI.md let gemini_md = gemini_dir.join(GEMINI_MD); if gemini_md.exists() { - fs::remove_file(&gemini_md) - .with_context(|| format!("Failed to remove {}", gemini_md.display()))?; + if dry_run { + println!("[dry-run] would remove GEMINI.md: {}", gemini_md.display()); + } else { + fs::remove_file(&gemini_md) + .with_context(|| format!("Failed to remove {}", gemini_md.display()))?; + } removed.push(format!("GEMINI.md: {}", gemini_md.display())); } @@ -2638,8 +3644,15 @@ fn uninstall_gemini(verbose: u8) -> Result> { .is_some_and(|c| c.contains("rtk")) }); if arr.len() < before { - let new_content = serde_json::to_string_pretty(&settings)?; - fs::write(&settings_path, new_content)?; + if dry_run { + println!( + "[dry-run] would remove RTK hook from Gemini settings.json: {}", + settings_path.display() + ); + } else { + let new_content = serde_json::to_string_pretty(&settings)?; + fs::write(&settings_path, new_content)?; + } removed.push("Gemini settings.json: removed RTK hook entry".to_string()); } } @@ -2697,21 +3710,19 @@ rtk proxy # Run raw (no filtering) but track usage "#; /// Entry point for `rtk init --copilot` -pub fn run_copilot(verbose: u8) -> Result<()> { +pub fn run_copilot(ctx: InitContext) -> Result<()> { + let InitContext { dry_run, .. } = ctx; // Install in current project's .github/ directory let github_dir = Path::new(".github"); let hooks_dir = github_dir.join("hooks"); - fs::create_dir_all(&hooks_dir).context("Failed to create .github/hooks/ directory")?; + if !dry_run { + fs::create_dir_all(&hooks_dir).context("Failed to create .github/hooks/ directory")?; + } // 1. Write hook config let hook_path = hooks_dir.join("rtk-rewrite.json"); - write_if_changed( - &hook_path, - COPILOT_HOOK_JSON, - "Copilot hook config", - verbose, - )?; + write_if_changed(&hook_path, COPILOT_HOOK_JSON, "Copilot hook config", ctx)?; // 2. Write instructions let instructions_path = github_dir.join("copilot-instructions.md"); @@ -2719,15 +3730,19 @@ pub fn run_copilot(verbose: u8) -> Result<()> { &instructions_path, COPILOT_INSTRUCTIONS, "Copilot instructions", - verbose, + ctx, )?; - println!("\nGitHub Copilot integration installed (project-scoped).\n"); - println!(" Hook config: {}", hook_path.display()); - println!(" Instructions: {}", instructions_path.display()); - println!("\n Works with VS Code Copilot Chat (transparent rewrite)"); - println!(" and Copilot CLI (deny-with-suggestion)."); - println!("\n Restart your IDE or Copilot CLI session to activate.\n"); + if dry_run { + print_dry_run_footer(); + } else { + println!("\nGitHub Copilot integration installed (project-scoped).\n"); + println!(" Hook config: {}", hook_path.display()); + println!(" Instructions: {}", instructions_path.display()); + println!("\n Works with VS Code Copilot Chat (transparent rewrite)"); + println!(" and Copilot CLI (deny-with-suggestion)."); + println!("\n Restart your IDE or Copilot CLI session to activate.\n"); + } Ok(()) } @@ -2798,258 +3813,744 @@ mod tests { fs::create_dir_all(plugin_path.parent().unwrap()).unwrap(); assert!(!plugin_path.exists()); - let changed = ensure_opencode_plugin_installed(&plugin_path, 0).unwrap(); + let changed = + ensure_opencode_plugin_installed(&plugin_path, InitContext::default()).unwrap(); assert!(changed); let content = fs::read_to_string(&plugin_path).unwrap(); assert_eq!(content, OPENCODE_PLUGIN); fs::write(&plugin_path, "// old").unwrap(); - let changed_again = ensure_opencode_plugin_installed(&plugin_path, 0).unwrap(); + let changed_again = + ensure_opencode_plugin_installed(&plugin_path, InitContext::default()).unwrap(); assert!(changed_again); let content_updated = fs::read_to_string(&plugin_path).unwrap(); assert_eq!(content_updated, OPENCODE_PLUGIN); } #[test] - fn test_opencode_plugin_remove() { - let temp = TempDir::new().unwrap(); - let opencode_dir = temp.path().join("opencode"); - let plugin_path = opencode_plugin_path(&opencode_dir); - fs::create_dir_all(plugin_path.parent().unwrap()).unwrap(); - fs::write(&plugin_path, OPENCODE_PLUGIN).unwrap(); + fn test_opencode_plugin_remove() { + let temp = TempDir::new().unwrap(); + let opencode_dir = temp.path().join("opencode"); + let plugin_path = opencode_plugin_path(&opencode_dir); + fs::create_dir_all(plugin_path.parent().unwrap()).unwrap(); + fs::write(&plugin_path, OPENCODE_PLUGIN).unwrap(); + + assert!(plugin_path.exists()); + fs::remove_file(&plugin_path).unwrap(); + assert!(!plugin_path.exists()); + } + + #[test] + fn test_migration_warns_on_missing_end_marker() { + let input = format!("{} v2 -->\nOLD STUFF\nNo end marker", RTK_BLOCK_START); + let (result, migrated) = remove_rtk_block(&input); + assert!(!migrated); + assert_eq!(result, input); + } + + #[test] + fn test_default_mode_creates_rtk_md() { + let temp = TempDir::new().unwrap(); + let rtk_md_path = temp.path().join("RTK.md"); + + fs::write(&rtk_md_path, RTK_SLIM).unwrap(); + assert!(rtk_md_path.exists()); + + let content = fs::read_to_string(&rtk_md_path).unwrap(); + assert_eq!(content, RTK_SLIM); + } + + #[test] + fn test_claude_md_mode_creates_full_injection() { + // Just verify RTK_INSTRUCTIONS constant has the right content + assert!(RTK_INSTRUCTIONS.contains(RTK_BLOCK_START)); + assert!(RTK_INSTRUCTIONS.contains("rtk cargo test")); + assert!(RTK_INSTRUCTIONS.contains(RTK_BLOCK_END)); + assert!(RTK_INSTRUCTIONS.len() > 4000); + } + + // --- upsert_rtk_block tests --- + + #[test] + fn test_upsert_rtk_block_appends_when_missing() { + let input = "# Team instructions"; + let (content, action) = upsert_rtk_block(input, RTK_INSTRUCTIONS); + assert_eq!(action, RtkBlockUpsert::Added); + assert!(content.contains("# Team instructions")); + assert!(content.contains(RTK_BLOCK_START)); + } + + #[test] + fn test_upsert_rtk_block_updates_stale_block() { + let input = format!( + "# Team instructions\n\n{} v1 -->\nOLD RTK CONTENT\n{}\n\nMore notes\n", + RTK_BLOCK_START, RTK_BLOCK_END + ); + + let (content, action) = upsert_rtk_block(&input, RTK_INSTRUCTIONS); + assert_eq!(action, RtkBlockUpsert::Updated); + assert!(!content.contains("OLD RTK CONTENT")); + assert!(content.contains("rtk cargo test")); // from current RTK_INSTRUCTIONS + assert!(content.contains("# Team instructions")); + assert!(content.contains("More notes")); + } + + #[test] + fn test_upsert_rtk_block_noop_when_already_current() { + let input = format!( + "# Team instructions\n\n{}\n\nMore notes\n", + RTK_INSTRUCTIONS + ); + let (content, action) = upsert_rtk_block(&input, RTK_INSTRUCTIONS); + assert_eq!(action, RtkBlockUpsert::Unchanged); + assert_eq!(content, input); + } + + #[test] + fn test_upsert_rtk_block_detects_malformed_block() { + let input = format!("{} v2 -->\npartial", RTK_BLOCK_START); + let (content, action) = upsert_rtk_block(&input, RTK_INSTRUCTIONS); + assert_eq!(action, RtkBlockUpsert::Malformed); + assert_eq!(content, input); + } + + #[test] + fn test_init_is_idempotent() { + let temp = TempDir::new().unwrap(); + let claude_md = temp.path().join("CLAUDE.md"); + + fs::write(&claude_md, "# My stuff\n\n@RTK.md\n").unwrap(); + + let content = fs::read_to_string(&claude_md).unwrap(); + let count = content.matches("@RTK.md").count(); + assert_eq!(count, 1); + } + + #[test] + fn test_patch_agents_md_adds_reference_once() { + let temp = TempDir::new().unwrap(); + let agents_md = temp.path().join("AGENTS.md"); + + fs::write(&agents_md, "# Team rules\n").unwrap(); + let first_added = patch_agents_md(&agents_md, RTK_MD_REF, InitContext::default()).unwrap(); + let second_added = patch_agents_md(&agents_md, RTK_MD_REF, InitContext::default()).unwrap(); + + assert!(first_added); + assert!(!second_added); + + let content = fs::read_to_string(&agents_md).unwrap(); + assert_eq!(content.matches("@RTK.md").count(), 1); + } + + #[test] + fn test_codex_mode_rejects_auto_patch() { + let err = run( + false, + false, + false, + false, + false, + false, + false, + false, + true, + PatchMode::Auto, + InitContext::default(), + ) + .unwrap_err(); + assert_eq!( + err.to_string(), + "--codex cannot be combined with --auto-patch" + ); + } + + #[test] + fn test_codex_mode_rejects_no_patch() { + let err = run( + false, + false, + false, + false, + false, + false, + false, + false, + true, + PatchMode::Skip, + InitContext::default(), + ) + .unwrap_err(); + assert_eq!( + err.to_string(), + "--codex cannot be combined with --no-patch" + ); + } + + #[test] + fn test_kilocode_mode_creates_rules_file() { + let temp = TempDir::new().unwrap(); + run_kilocode_mode_at(temp.path(), InitContext::default()).unwrap(); + + let rules_path = temp.path().join(".kilocode/rules/rtk-rules.md"); + assert!(rules_path.exists(), "Rules file should be created"); + let content = fs::read_to_string(&rules_path).unwrap(); + assert!(content.contains("RTK"), "Rules file should contain RTK"); + } + + #[test] + fn test_kilocode_mode_is_idempotent() { + let temp = TempDir::new().unwrap(); + run_kilocode_mode_at(temp.path(), InitContext::default()).unwrap(); + + let path = temp.path().join(".kilocode/rules/rtk-rules.md"); + let first = fs::read_to_string(&path).unwrap(); + + // Second run should not overwrite + run_kilocode_mode_at(temp.path(), InitContext::default()).unwrap(); + let second = fs::read_to_string(&path).unwrap(); + assert_eq!(first, second, "Idempotent: content should not change"); + } + + #[test] + fn test_antigravity_mode_creates_rules_file() { + let temp = TempDir::new().unwrap(); + run_antigravity_mode_at(temp.path(), InitContext::default()).unwrap(); + + let rules_path = temp.path().join(".agents/rules/antigravity-rtk-rules.md"); + assert!(rules_path.exists(), "Rules file should be created"); + let content = fs::read_to_string(&rules_path).unwrap(); + assert!(content.contains("RTK"), "Rules file should contain RTK"); + } + + #[test] + fn test_antigravity_mode_is_idempotent() { + let temp = TempDir::new().unwrap(); + run_antigravity_mode_at(temp.path(), InitContext::default()).unwrap(); + + let path = temp.path().join(".agents/rules/antigravity-rtk-rules.md"); + let first = fs::read_to_string(&path).unwrap(); + + // Second run should not overwrite + run_antigravity_mode_at(temp.path(), InitContext::default()).unwrap(); + let second = fs::read_to_string(&path).unwrap(); + assert_eq!(first, second, "Idempotent: content should not change"); + } + + #[test] + fn test_patch_agents_md_creates_missing_file() { + let temp = TempDir::new().unwrap(); + let agents_md = temp.path().join("AGENTS.md"); + + let added = patch_agents_md(&agents_md, RTK_MD_REF, InitContext::default()).unwrap(); + + assert!(added); + let content = fs::read_to_string(&agents_md).unwrap(); + assert_eq!(content, "@RTK.md\n"); + } + + #[test] + fn test_patch_agents_md_migrates_inline_block() { + let temp = TempDir::new().unwrap(); + let agents_md = temp.path().join("AGENTS.md"); + fs::write( + &agents_md, + format!( + "# Team rules\n\n{} v2 -->\nold\n{}\n", + RTK_BLOCK_START, RTK_BLOCK_END + ), + ) + .unwrap(); + + let added = patch_agents_md(&agents_md, RTK_MD_REF, InitContext::default()).unwrap(); + + assert!(added); + let content = fs::read_to_string(&agents_md).unwrap(); + assert!(!content.contains("old")); + assert_eq!(content.matches("@RTK.md").count(), 1); + } + + #[test] + fn test_hermes_mode_creates_plugin_files() { + let temp = TempDir::new().unwrap(); + run_hermes_mode_at(temp.path(), InitContext::default()).unwrap(); + + let plugin_dir = temp.path().join("plugins/rtk-rewrite"); + let init_path = plugin_dir.join("__init__.py"); + let manifest_path = plugin_dir.join("plugin.yaml"); + let config_path = temp.path().join("config.yaml"); + + assert!(init_path.exists(), "Python plugin should be created"); + assert!(manifest_path.exists(), "Plugin manifest should be created"); + assert_eq!( + fs::read_to_string(&init_path).unwrap(), + include_str!("../../hooks/hermes/rtk-rewrite/__init__.py") + ); + assert_eq!( + fs::read_to_string(&manifest_path).unwrap(), + include_str!("../../hooks/hermes/rtk-rewrite/plugin.yaml") + ); + + let config = fs::read_to_string(&config_path).unwrap(); + assert!(config.contains("plugins:\n")); + assert!(config.contains(" enabled:\n")); + assert_eq!(config.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_mode_preserves_config_and_is_idempotent() { + let temp = TempDir::new().unwrap(); + let config_path = temp.path().join("config.yaml"); + fs::write( + &config_path, + "theme: dark\nplugins:\n enabled:\n - existing-plugin\n search_path: ./plugins\nother: true\n", + ) + .unwrap(); + + run_hermes_mode_at(temp.path(), InitContext::default()).unwrap(); + let first = fs::read_to_string(&config_path).unwrap(); + run_hermes_mode_at(temp.path(), InitContext::default()).unwrap(); + let second = fs::read_to_string(&config_path).unwrap(); + + assert_eq!(first, second, "Hermes config patch should be idempotent"); + assert!(first.contains("theme: dark\n")); + assert!(first.contains(" - existing-plugin\n")); + assert!(first.contains(" search_path: ./plugins\n")); + assert!(first.contains("other: true\n")); + assert_eq!(first.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_mode_preserves_pyyaml_same_indent_config_and_is_idempotent() { + let temp = TempDir::new().unwrap(); + let config_path = temp.path().join("config.yaml"); + fs::write( + &config_path, + "theme: dark\nplugins:\n disabled:\n - google_meet\n - spotify\n enabled:\n - disk-cleanup\n search_path: ./plugins\nother: true\n", + ) + .unwrap(); + + run_hermes_mode_at(temp.path(), InitContext::default()).unwrap(); + let first = fs::read_to_string(&config_path).unwrap(); + run_hermes_mode_at(temp.path(), InitContext::default()).unwrap(); + let second = fs::read_to_string(&config_path).unwrap(); + + let expected = "theme: dark\nplugins:\n disabled:\n - google_meet\n - spotify\n enabled:\n - disk-cleanup\n - rtk-rewrite\n search_path: ./plugins\nother: true\n"; + assert_eq!(first, expected); + assert_eq!( + second, expected, + "Hermes PyYAML config patch should be idempotent" + ); + assert_eq!(first.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_mode_patches_and_uninstalls_pyyaml_same_indent_missing_enabled_idempotently() { + let temp = TempDir::new().unwrap(); + let hermes_home = temp.path(); + let plugin_dir = hermes_home.join("plugins").join(HERMES_PLUGIN_NAME); + let other_plugin_dir = hermes_home.join("plugins/keep-me"); + let other_plugin_file = other_plugin_dir.join("plugin.yaml"); + let config_path = hermes_home.join("config.yaml"); + + fs::create_dir_all(&other_plugin_dir).unwrap(); + fs::write(&other_plugin_file, "keep").unwrap(); + fs::write( + &config_path, + "theme: dark\nplugins:\n disabled:\n - google_meet\n - spotify\n search_path: ./plugins\nother: true\n", + ) + .unwrap(); + + run_hermes_mode_at(hermes_home, InitContext::default()).unwrap(); + let first = fs::read_to_string(&config_path).unwrap(); + run_hermes_mode_at(hermes_home, InitContext::default()).unwrap(); + let second = fs::read_to_string(&config_path).unwrap(); + + let installed = "theme: dark\nplugins:\n disabled:\n - google_meet\n - spotify\n search_path: ./plugins\n enabled:\n - rtk-rewrite\nother: true\n"; + assert_eq!(first, installed); + assert_eq!(second, installed); + assert_eq!(first.matches("rtk-rewrite").count(), 1); + assert!(plugin_dir.exists()); + assert_eq!(fs::read_to_string(&other_plugin_file).unwrap(), "keep"); + + let removed_first = uninstall_hermes_at(hermes_home, InitContext::default()).unwrap(); + let removed_second = uninstall_hermes_at(hermes_home, InitContext::default()).unwrap(); + + assert_eq!(removed_first.len(), 2); + assert!(removed_second.is_empty()); + assert!(!plugin_dir.exists()); + assert!(other_plugin_dir.exists()); + assert_eq!(fs::read_to_string(&other_plugin_file).unwrap(), "keep"); + + let uninstalled = fs::read_to_string(&config_path).unwrap(); + assert_eq!( + uninstalled, + "theme: dark\nplugins:\n disabled:\n - google_meet\n - spotify\n search_path: ./plugins\n enabled: []\nother: true\n" + ); + assert!(!uninstalled.contains("\n - \n")); + assert!(!uninstalled.contains("\n -\n")); + assert_eq!(uninstalled.matches("rtk-rewrite").count(), 0); + } + + #[test] + fn test_uninstall_hermes_at_removes_plugin_dir_and_cleans_config() { + let temp = TempDir::new().unwrap(); + let hermes_home = temp.path(); + let plugin_dir = hermes_home.join("plugins").join(HERMES_PLUGIN_NAME); + let nested_plugin_file = plugin_dir.join("nested/marker.txt"); + let other_plugin_dir = hermes_home.join("plugins/keep-me"); + let other_plugin_file = other_plugin_dir.join("plugin.yaml"); + let config_path = hermes_home.join("config.yaml"); + + fs::create_dir_all(nested_plugin_file.parent().unwrap()).unwrap(); + fs::write(&nested_plugin_file, "rtk").unwrap(); + fs::create_dir_all(&other_plugin_dir).unwrap(); + fs::write(&other_plugin_file, "keep").unwrap(); + fs::write( + &config_path, + "theme: dark\nplugins:\n enabled:\n - existing-plugin\n - rtk-rewrite\n search_path: ./plugins\nother: true\n", + ) + .unwrap(); + + let removed_first = uninstall_hermes_at(hermes_home, InitContext::default()).unwrap(); + let removed_second = uninstall_hermes_at(hermes_home, InitContext::default()).unwrap(); + + assert_eq!(removed_first.len(), 2); + assert!(removed_second.is_empty()); + assert!(!plugin_dir.exists()); + assert!(other_plugin_dir.exists()); + assert_eq!(fs::read_to_string(&other_plugin_file).unwrap(), "keep"); + + let config = fs::read_to_string(&config_path).unwrap(); + assert!(config.contains("theme: dark\n")); + assert!(config.contains(" - existing-plugin\n")); + assert!(config.contains(" search_path: ./plugins\n")); + assert!(config.contains("other: true\n")); + assert_eq!(config.matches("rtk-rewrite").count(), 0); + } + + #[test] + fn test_uninstall_hermes_at_cleans_pyyaml_same_indent_config_idempotently() { + let temp = TempDir::new().unwrap(); + let hermes_home = temp.path(); + let plugin_dir = hermes_home.join("plugins").join(HERMES_PLUGIN_NAME); + let nested_plugin_file = plugin_dir.join("nested/marker.txt"); + let other_plugin_dir = hermes_home.join("plugins/keep-me"); + let other_plugin_file = other_plugin_dir.join("plugin.yaml"); + let config_path = hermes_home.join("config.yaml"); + + fs::create_dir_all(nested_plugin_file.parent().unwrap()).unwrap(); + fs::write(&nested_plugin_file, "rtk").unwrap(); + fs::create_dir_all(&other_plugin_dir).unwrap(); + fs::write(&other_plugin_file, "keep").unwrap(); + fs::write( + &config_path, + "theme: dark\nplugins:\n disabled:\n - google_meet\n - spotify\n enabled:\n - disk-cleanup\n - rtk-rewrite\n search_path: ./plugins\nother: true\n", + ) + .unwrap(); + + let removed_first = uninstall_hermes_at(hermes_home, InitContext::default()).unwrap(); + let removed_second = uninstall_hermes_at(hermes_home, InitContext::default()).unwrap(); + + assert_eq!(removed_first.len(), 2); + assert!(removed_second.is_empty()); + assert!(!plugin_dir.exists()); + assert!(other_plugin_dir.exists()); + assert_eq!(fs::read_to_string(&other_plugin_file).unwrap(), "keep"); + + let config = fs::read_to_string(&config_path).unwrap(); + assert_eq!( + config, + "theme: dark\nplugins:\n disabled:\n - google_meet\n - spotify\n enabled:\n - disk-cleanup\n search_path: ./plugins\nother: true\n" + ); + assert!(!config.contains("\n - \n")); + assert!(!config.contains("\n -\n")); + assert_eq!(config.matches("rtk-rewrite").count(), 0); + } + + #[test] + fn test_uninstall_hermes_at_missing_files_is_idempotent() { + let temp = TempDir::new().unwrap(); + let hermes_home = temp.path(); + + let removed_first = uninstall_hermes_at(hermes_home, InitContext::default()).unwrap(); + let removed_second = uninstall_hermes_at(hermes_home, InitContext::default()).unwrap(); + + assert!(removed_first.is_empty()); + assert!(removed_second.is_empty()); + assert!(!hermes_home.join("plugins").exists()); + assert!(!hermes_home.join("config.yaml").exists()); + } + + #[test] + fn test_hermes_config_patch_adds_missing_enabled_list() { + let existing = "theme: dark\nplugins:\n search_path: ./plugins\nother: true\n"; + let patched = patch_hermes_config(existing); + + assert!(patched.contains("theme: dark\n")); + assert!(patched.contains("plugins:\n")); + assert!(patched.contains(" search_path: ./plugins\n")); + assert!(patched.contains(" enabled:\n - rtk-rewrite\n")); + assert!(patched.contains("other: true\n")); + assert_eq!(patched.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_config_patch_removes_duplicate_rtk_rewrite() { + let existing = "plugins:\n enabled:\n - rtk-rewrite\n - other\n - rtk-rewrite\n"; + let patched = patch_hermes_config(existing); + + assert!(patched.contains(" - other\n")); + assert_eq!(patched.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_config_patch_pyyaml_indentationless_enabled_list() { + let existing = + "plugins:\n disabled:\n - google_meet\n - spotify\n enabled:\n - disk-cleanup\n"; + + let patched = patch_hermes_config(existing); + + assert_eq!( + patched, + "plugins:\n disabled:\n - google_meet\n - spotify\n enabled:\n - disk-cleanup\n - rtk-rewrite\n" + ); + assert_eq!(patched.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_config_patch_pyyaml_default_compact_enabled_list() { + let existing = "plugins:\n enabled:\n - foo\n"; + + let patched = patch_hermes_config(existing); + + assert_eq!(patched, "plugins:\n enabled:\n - foo\n - rtk-rewrite\n"); + assert_eq!(patched.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_config_patch_pyyaml_indentationless_missing_enabled_list() { + let existing = + "plugins:\n disabled:\n - google_meet\n - spotify\n search_path: ./plugins\n"; + + let patched = patch_hermes_config(existing); + + assert_eq!( + patched, + "plugins:\n disabled:\n - google_meet\n - spotify\n search_path: ./plugins\n enabled:\n - rtk-rewrite\n" + ); + assert_eq!(patched.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_config_patch_pyyaml_indentationless_enabled_is_idempotent() { + let existing = "plugins:\n enabled:\n - disk-cleanup\n disabled:\n - spotify\n"; + + let patched_once = patch_hermes_config(existing); + let patched_twice = patch_hermes_config(&patched_once); + + assert_eq!( + patched_once, + "plugins:\n enabled:\n - disk-cleanup\n - rtk-rewrite\n disabled:\n - spotify\n" + ); + assert_eq!(patched_twice, patched_once); + assert_eq!(patched_once.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_config_patch_pyyaml_indentationless_final_line_without_newline() { + let existing = "plugins:\n enabled:\n - disk-cleanup"; + + let patched = patch_hermes_config(existing); + + assert_eq!( + patched, + "plugins:\n enabled:\n - disk-cleanup\n - rtk-rewrite\n" + ); + assert_eq!(patched.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_config_patch_block_enabled_final_line_without_newline() { + let existing = "plugins:\n enabled:\n - existing-plugin"; + + let patched = patch_hermes_config(existing); + + assert_eq!( + patched, + "plugins:\n enabled:\n - existing-plugin\n - rtk-rewrite\n" + ); + assert_eq!(patched.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_config_patch_missing_enabled_after_final_child_without_newline() { + let existing = "plugins:\n search_path: ./plugins"; - assert!(plugin_path.exists()); - fs::remove_file(&plugin_path).unwrap(); - assert!(!plugin_path.exists()); + let patched = patch_hermes_config(existing); + + assert_eq!( + patched, + "plugins:\n search_path: ./plugins\n enabled:\n - rtk-rewrite\n" + ); + assert_eq!(patched.matches("rtk-rewrite").count(), 1); } #[test] - fn test_migration_warns_on_missing_end_marker() { - let input = format!("{} v2 -->\nOLD STUFF\nNo end marker", RTK_BLOCK_START); - let (result, migrated) = remove_rtk_block(&input); - assert!(!migrated); - assert_eq!(result, input); + fn test_hermes_config_patch_empty_enabled_final_line_without_newline() { + let existing = "plugins:\n enabled:"; + + let patched = patch_hermes_config(existing); + + assert_eq!(patched, "plugins:\n enabled:\n - rtk-rewrite\n"); + assert_eq!(patched.matches("rtk-rewrite").count(), 1); } #[test] - fn test_default_mode_creates_rtk_md() { - let temp = TempDir::new().unwrap(); - let rtk_md_path = temp.path().join("RTK.md"); + fn test_hermes_config_patch_inline_enabled_is_idempotent() { + let existing = "theme: dark\nplugins:\n enabled: [existing-plugin, rtk-rewrite] # keep\n search_path: ./plugins\nother: true\n"; - fs::write(&rtk_md_path, RTK_SLIM).unwrap(); - assert!(rtk_md_path.exists()); + let patched = patch_hermes_config(existing); - let content = fs::read_to_string(&rtk_md_path).unwrap(); - assert_eq!(content, RTK_SLIM); + assert_eq!(patched, existing); + assert_eq!(patch_hermes_config(&patched), patched); + assert_eq!(patched.matches("rtk-rewrite").count(), 1); } #[test] - fn test_claude_md_mode_creates_full_injection() { - // Just verify RTK_INSTRUCTIONS constant has the right content - assert!(RTK_INSTRUCTIONS.contains(RTK_BLOCK_START)); - assert!(RTK_INSTRUCTIONS.contains("rtk cargo test")); - assert!(RTK_INSTRUCTIONS.contains(RTK_BLOCK_END)); - assert!(RTK_INSTRUCTIONS.len() > 4000); - } + fn test_hermes_config_patch_inline_enabled_without_final_newline_is_idempotent() { + let existing = "plugins:\n enabled: [existing-plugin, rtk-rewrite]"; - // --- upsert_rtk_block tests --- + let patched = patch_hermes_config(existing); - #[test] - fn test_upsert_rtk_block_appends_when_missing() { - let input = "# Team instructions"; - let (content, action) = upsert_rtk_block(input, RTK_INSTRUCTIONS); - assert_eq!(action, RtkBlockUpsert::Added); - assert!(content.contains("# Team instructions")); - assert!(content.contains(RTK_BLOCK_START)); + assert_eq!(patched, existing); + assert_eq!(patch_hermes_config(&patched), patched); + assert_eq!(patched.matches("rtk-rewrite").count(), 1); } #[test] - fn test_upsert_rtk_block_updates_stale_block() { - let input = format!( - "# Team instructions\n\n{} v1 -->\nOLD RTK CONTENT\n{}\n\nMore notes\n", - RTK_BLOCK_START, RTK_BLOCK_END - ); + fn test_hermes_config_unpatch_inline_enabled_without_rtk_preserves_missing_final_newline() { + let existing = "plugins:\n enabled: [existing-plugin]"; - let (content, action) = upsert_rtk_block(&input, RTK_INSTRUCTIONS); - assert_eq!(action, RtkBlockUpsert::Updated); - assert!(!content.contains("OLD RTK CONTENT")); - assert!(content.contains("rtk cargo test")); // from current RTK_INSTRUCTIONS - assert!(content.contains("# Team instructions")); - assert!(content.contains("More notes")); + let patched = unpatch_hermes_config(existing); + + assert_eq!(patched, existing); } #[test] - fn test_upsert_rtk_block_noop_when_already_current() { - let input = format!( - "# Team instructions\n\n{}\n\nMore notes\n", - RTK_INSTRUCTIONS + fn test_hermes_config_unpatch_inline_enabled_preserves_unrelated_entries() { + let existing = "theme: dark\nplugins:\n enabled: [alpha, rtk-rewrite, beta] # keep comment\n search_path: ./plugins\nother: true\n"; + + let patched = unpatch_hermes_config(existing); + + assert_eq!( + patched, + "theme: dark\nplugins:\n enabled: [alpha, beta] # keep comment\n search_path: ./plugins\nother: true\n" ); - let (content, action) = upsert_rtk_block(&input, RTK_INSTRUCTIONS); - assert_eq!(action, RtkBlockUpsert::Unchanged); - assert_eq!(content, input); + assert_eq!(patched.matches("rtk-rewrite").count(), 0); } #[test] - fn test_upsert_rtk_block_detects_malformed_block() { - let input = format!("{} v2 -->\npartial", RTK_BLOCK_START); - let (content, action) = upsert_rtk_block(&input, RTK_INSTRUCTIONS); - assert_eq!(action, RtkBlockUpsert::Malformed); - assert_eq!(content, input); + fn test_hermes_config_unpatch_inline_enabled_final_line_without_newline() { + let existing = "plugins:\n enabled: [existing-plugin, rtk-rewrite]"; + + let patched = unpatch_hermes_config(existing); + + assert_eq!(patched, "plugins:\n enabled: [existing-plugin]"); + assert_eq!(patched.matches("rtk-rewrite").count(), 0); } #[test] - fn test_init_is_idempotent() { - let temp = TempDir::new().unwrap(); - let claude_md = temp.path().join("CLAUDE.md"); + fn test_hermes_config_unpatch_removes_duplicate_inline_rtk_rewrite() { + let existing = "plugins:\n enabled: [alpha, rtk-rewrite, beta, rtk-rewrite]\n"; - fs::write(&claude_md, "# My stuff\n\n@RTK.md\n").unwrap(); + let patched = unpatch_hermes_config(existing); - let content = fs::read_to_string(&claude_md).unwrap(); - let count = content.matches("@RTK.md").count(); - assert_eq!(count, 1); + assert_eq!(patched, "plugins:\n enabled: [alpha, beta]\n"); + assert_eq!(patched.matches("rtk-rewrite").count(), 0); } #[test] - fn test_patch_agents_md_adds_reference_once() { - let temp = TempDir::new().unwrap(); - let agents_md = temp.path().join("AGENTS.md"); - - fs::write(&agents_md, "# Team rules\n").unwrap(); - let first_added = patch_agents_md(&agents_md, RTK_MD_REF, 0).unwrap(); - let second_added = patch_agents_md(&agents_md, RTK_MD_REF, 0).unwrap(); + fn test_hermes_config_unpatch_removes_duplicate_block_rtk_rewrite() { + let existing = "plugins:\n enabled:\n - rtk-rewrite\n - other\n - rtk-rewrite\n"; - assert!(first_added); - assert!(!second_added); + let patched = unpatch_hermes_config(existing); - let content = fs::read_to_string(&agents_md).unwrap(); - assert_eq!(content.matches("@RTK.md").count(), 1); + assert_eq!(patched, "plugins:\n enabled:\n - other\n"); + assert_eq!(patched.matches("rtk-rewrite").count(), 0); } #[test] - fn test_codex_mode_rejects_auto_patch() { - let err = run( - false, - false, - false, - false, - false, - false, - false, - false, - true, - PatchMode::Auto, - 0, - ) - .unwrap_err(); - assert_eq!( - err.to_string(), - "--codex cannot be combined with --auto-patch" - ); - } + fn test_hermes_config_unpatch_pyyaml_indentationless_enabled_list() { + let existing = "plugins:\n disabled:\n - google_meet\n - spotify\n enabled:\n - disk-cleanup\n - rtk-rewrite\n search_path: ./plugins\n"; + + let patched = unpatch_hermes_config(existing); - #[test] - fn test_codex_mode_rejects_no_patch() { - let err = run( - false, - false, - false, - false, - false, - false, - false, - false, - true, - PatchMode::Skip, - 0, - ) - .unwrap_err(); assert_eq!( - err.to_string(), - "--codex cannot be combined with --no-patch" + patched, + "plugins:\n disabled:\n - google_meet\n - spotify\n enabled:\n - disk-cleanup\n search_path: ./plugins\n" ); + assert_eq!(patched.matches("rtk-rewrite").count(), 0); } #[test] - fn test_kilocode_mode_creates_rules_file() { - let temp = TempDir::new().unwrap(); - run_kilocode_mode_at(temp.path(), 0).unwrap(); + fn test_hermes_config_unpatch_pyyaml_indentationless_only_rtk_collapses_to_empty() { + let existing = "plugins:\n enabled:\n - rtk-rewrite\n search_path: ./plugins\n"; - let rules_path = temp.path().join(".kilocode/rules/rtk-rules.md"); - assert!(rules_path.exists(), "Rules file should be created"); - let content = fs::read_to_string(&rules_path).unwrap(); - assert!(content.contains("RTK"), "Rules file should contain RTK"); + let patched = unpatch_hermes_config(existing); + + assert_eq!(patched, "plugins:\n enabled: []\n search_path: ./plugins\n"); + assert_eq!(patched.matches("rtk-rewrite").count(), 0); } #[test] - fn test_kilocode_mode_is_idempotent() { - let temp = TempDir::new().unwrap(); - run_kilocode_mode_at(temp.path(), 0).unwrap(); + fn test_hermes_config_unpatch_block_enabled_final_line_without_newline() { + let existing = "plugins:\n enabled:\n - existing-plugin\n - rtk-rewrite"; - let path = temp.path().join(".kilocode/rules/rtk-rules.md"); - let first = fs::read_to_string(&path).unwrap(); + let patched = unpatch_hermes_config(existing); - // Second run should not overwrite - run_kilocode_mode_at(temp.path(), 0).unwrap(); - let second = fs::read_to_string(&path).unwrap(); - assert_eq!(first, second, "Idempotent: content should not change"); + assert_eq!(patched, "plugins:\n enabled:\n - existing-plugin\n"); + assert_eq!(patched.matches("rtk-rewrite").count(), 0); } #[test] - fn test_antigravity_mode_creates_rules_file() { - let temp = TempDir::new().unwrap(); - run_antigravity_mode_at(temp.path(), 0).unwrap(); + fn test_hermes_config_unpatch_block_enabled_without_rtk_preserves_missing_final_newline() { + let existing = "plugins:\n enabled:\n - existing-plugin"; - let rules_path = temp.path().join(".agents/rules/antigravity-rtk-rules.md"); - assert!(rules_path.exists(), "Rules file should be created"); - let content = fs::read_to_string(&rules_path).unwrap(); - assert!(content.contains("RTK"), "Rules file should contain RTK"); + let patched = unpatch_hermes_config(existing); + + assert_eq!(patched, existing); } #[test] - fn test_antigravity_mode_is_idempotent() { - let temp = TempDir::new().unwrap(); - run_antigravity_mode_at(temp.path(), 0).unwrap(); + fn test_hermes_config_unpatch_preserves_quoted_exact_values() { + let existing = "plugins:\n enabled:\n - 'alpha'\n - \"rtk-rewrite\"\n - 'beta'\n search_path: ./plugins\n"; - let path = temp.path().join(".agents/rules/antigravity-rtk-rules.md"); - let first = fs::read_to_string(&path).unwrap(); + let patched = unpatch_hermes_config(existing); - // Second run should not overwrite - run_antigravity_mode_at(temp.path(), 0).unwrap(); - let second = fs::read_to_string(&path).unwrap(); - assert_eq!(first, second, "Idempotent: content should not change"); + assert_eq!( + patched, + "plugins:\n enabled:\n - 'alpha'\n - 'beta'\n search_path: ./plugins\n" + ); + assert_eq!(patched.matches("rtk-rewrite").count(), 0); } #[test] - fn test_patch_agents_md_creates_missing_file() { - let temp = TempDir::new().unwrap(); - let agents_md = temp.path().join("AGENTS.md"); + fn test_hermes_config_unpatch_leaves_missing_enabled_list_unchanged() { + let existing = "theme: dark\nplugins:\n search_path: ./plugins\nother: true\n"; - let added = patch_agents_md(&agents_md, RTK_MD_REF, 0).unwrap(); + let patched = unpatch_hermes_config(existing); - assert!(added); - let content = fs::read_to_string(&agents_md).unwrap(); - assert_eq!(content, "@RTK.md\n"); + assert_eq!(patched, existing); } #[test] - fn test_patch_agents_md_migrates_inline_block() { - let temp = TempDir::new().unwrap(); - let agents_md = temp.path().join("AGENTS.md"); - fs::write( - &agents_md, - format!( - "# Team rules\n\n{} v2 -->\nold\n{}\n", - RTK_BLOCK_START, RTK_BLOCK_END - ), - ) - .unwrap(); + fn test_hermes_config_unpatch_collapses_empty_enabled_list() { + let existing = "plugins:\n enabled:\n - rtk-rewrite\n"; - let added = patch_agents_md(&agents_md, RTK_MD_REF, 0).unwrap(); + let patched = unpatch_hermes_config(existing); - assert!(added); - let content = fs::read_to_string(&agents_md).unwrap(); - assert!(!content.contains("old")); - assert_eq!(content.matches("@RTK.md").count(), 1); + assert_eq!(patched, "plugins:\n enabled: []\n"); + assert_eq!(patched.matches("rtk-rewrite").count(), 0); } #[test] @@ -3058,7 +4559,13 @@ mod tests { let agents_md = temp.path().join("AGENTS.md"); let rtk_md = temp.path().join("RTK.md"); - run_codex_mode_with_paths(agents_md.clone(), rtk_md.clone(), true, 0).unwrap(); + run_codex_mode_with_paths( + agents_md.clone(), + rtk_md.clone(), + true, + InitContext::default(), + ) + .unwrap(); assert!(rtk_md.exists()); assert_eq!(fs::read_to_string(&rtk_md).unwrap(), RTK_SLIM_CODEX); @@ -3084,6 +4591,30 @@ mod tests { assert_eq!(missing_falls_back, home_dir.join(".codex")); } + #[test] + fn test_resolve_hermes_home_prefers_hermes_home() { + let hermes_home = OsString::from("~/custom hermes home"); + let home_dir = PathBuf::from("/tmp/home"); + + let resolved = + resolve_hermes_home_from_env(Some(home_dir), Some(hermes_home.clone())).unwrap(); + + assert_eq!(resolved, PathBuf::from(hermes_home)); + } + + #[test] + fn test_resolve_hermes_home_empty_env_falls_back_to_home() { + let home_dir = PathBuf::from("/tmp/home"); + + let empty_falls_back = + resolve_hermes_home_from_env(Some(home_dir.clone()), Some(OsString::new())).unwrap(); + let missing_falls_back = + resolve_hermes_home_from_env(Some(home_dir.clone()), None).unwrap(); + + assert_eq!(empty_falls_back, home_dir.join(".hermes")); + assert_eq!(missing_falls_back, home_dir.join(".hermes")); + } + #[test] fn test_uninstall_codex_at_is_idempotent() { let temp = TempDir::new().unwrap(); @@ -3094,8 +4625,8 @@ mod tests { fs::write(&agents_md, "# Team rules\n\n@RTK.md\n").unwrap(); fs::write(&rtk_md, "codex config").unwrap(); - let removed_first = uninstall_codex_at(codex_dir, 0).unwrap(); - let removed_second = uninstall_codex_at(codex_dir, 0).unwrap(); + let removed_first = uninstall_codex_at(codex_dir, InitContext::default()).unwrap(); + let removed_second = uninstall_codex_at(codex_dir, InitContext::default()).unwrap(); assert_eq!(removed_first.len(), 2); assert!(removed_second.is_empty()); @@ -3117,7 +4648,7 @@ mod tests { fs::write(&agents_md, format!("# Team rules\n\n{}\n", absolute_ref)).unwrap(); fs::write(&rtk_md, "codex config").unwrap(); - let removed = uninstall_codex_at(codex_dir, 0).unwrap(); + let removed = uninstall_codex_at(codex_dir, InitContext::default()).unwrap(); assert_eq!(removed.len(), 2); let content = fs::read_to_string(&agents_md).unwrap(); @@ -3125,6 +4656,87 @@ mod tests { assert!(content.contains("# Team rules")); } + #[test] + fn test_write_if_changed_dry_run_does_not_create_file() { + let temp = TempDir::new().unwrap(); + let target = temp.path().join("rtk-test.md"); + + let changed = write_if_changed( + &target, + "some content", + "test file", + InitContext { + dry_run: true, + ..Default::default() + }, + ) + .unwrap(); + + assert!( + changed, + "dry-run should report would-change for missing file" + ); + assert!( + !target.exists(), + "dry-run must not create file: {}", + target.display() + ); + } + + #[test] + fn test_write_if_changed_dry_run_does_not_modify_existing_file() { + let temp = TempDir::new().unwrap(); + let target = temp.path().join("rtk-test.md"); + fs::write(&target, "original").unwrap(); + + let changed = write_if_changed( + &target, + "new content", + "test file", + InitContext { + dry_run: true, + ..Default::default() + }, + ) + .unwrap(); + + assert!(changed, "dry-run should report would-change"); + assert_eq!( + fs::read_to_string(&target).unwrap(), + "original", + "dry-run must not modify file contents" + ); + } + + #[test] + fn test_run_codex_mode_dry_run_writes_nothing() { + let temp = TempDir::new().unwrap(); + let agents_md = temp.path().join("AGENTS.md"); + let rtk_md = temp.path().join("RTK.md"); + + run_codex_mode_with_paths( + agents_md.clone(), + rtk_md.clone(), + true, + InitContext { + dry_run: true, + ..Default::default() + }, + ) + .unwrap(); + + assert!( + !rtk_md.exists(), + "dry-run must not create RTK.md: {}", + rtk_md.display() + ); + assert!( + !agents_md.exists(), + "dry-run must not create AGENTS.md: {}", + agents_md.display() + ); + } + #[test] fn test_uninstall_codex_at_removes_rtk_instructions_block() { let temp = TempDir::new().unwrap(); @@ -3142,7 +4754,7 @@ mod tests { .unwrap(); fs::write(&rtk_md, "codex config").unwrap(); - let removed = uninstall_codex_at(codex_dir, 0).unwrap(); + let removed = uninstall_codex_at(codex_dir, InitContext::default()).unwrap(); let content = fs::read_to_string(&agents_md).unwrap(); assert!(!content.contains("OLD RTK STUFF")); @@ -3759,7 +5371,7 @@ mod tests { fn test_global_default_mode_creates_artifacts() { let tmp = TempDir::new().unwrap(); with_claude_dir_override(&tmp, |claude_dir| { - run_default_mode(true, PatchMode::Auto, 0, false).unwrap(); + run_default_mode(true, PatchMode::Auto, false, InitContext::default()).unwrap(); assert!(claude_dir.join(RTK_MD).exists(), "RTK.md must be created"); assert!( @@ -3781,8 +5393,8 @@ mod tests { fn test_global_uninstall_removes_artifacts() { let tmp = TempDir::new().unwrap(); with_claude_dir_override(&tmp, |claude_dir| { - run_default_mode(true, PatchMode::Auto, 0, false).unwrap(); - uninstall(true, false, false, false, 0).unwrap(); + run_default_mode(true, PatchMode::Auto, false, InitContext::default()).unwrap(); + uninstall(true, false, false, false, InitContext::default()).unwrap(); assert!(!claude_dir.join(RTK_MD).exists(), "RTK.md must be removed"); let settings_content = @@ -3798,8 +5410,8 @@ mod tests { fn test_global_default_mode_idempotent() { let tmp = TempDir::new().unwrap(); with_claude_dir_override(&tmp, |claude_dir| { - run_default_mode(true, PatchMode::Auto, 0, false).unwrap(); - run_default_mode(true, PatchMode::Auto, 0, false).unwrap(); + run_default_mode(true, PatchMode::Auto, false, InitContext::default()).unwrap(); + run_default_mode(true, PatchMode::Auto, false, InitContext::default()).unwrap(); let settings = fs::read_to_string(claude_dir.join(SETTINGS_JSON)).unwrap(); let count = settings.matches(CLAUDE_HOOK_COMMAND).count(); @@ -3811,14 +5423,14 @@ mod tests { fn test_upgrade_from_claude_md_to_hook_mode() { let tmp = TempDir::new().unwrap(); with_claude_dir_override(&tmp, |claude_dir| { - run_claude_md_mode(true, 0, false).unwrap(); + run_claude_md_mode(true, false, InitContext::default()).unwrap(); let claude_md_content = fs::read_to_string(claude_dir.join(CLAUDE_MD)).unwrap(); assert!( claude_md_content.contains(RTK_BLOCK_START), "pre-condition: old block must exist" ); - run_default_mode(true, PatchMode::Auto, 0, false).unwrap(); + run_default_mode(true, PatchMode::Auto, false, InitContext::default()).unwrap(); assert!(claude_dir.join(RTK_MD).exists(), "RTK.md must be created"); let settings = fs::read_to_string(claude_dir.join(SETTINGS_JSON)).unwrap(); @@ -3835,7 +5447,7 @@ mod tests { let cwd = std::env::current_dir().unwrap(); std::env::set_current_dir(tmp.path()).unwrap(); - let result = run_default_mode(false, PatchMode::Auto, 0, false); + let result = run_default_mode(false, PatchMode::Auto, false, InitContext::default()); std::env::set_current_dir(&cwd).unwrap(); result.unwrap(); @@ -3853,7 +5465,7 @@ mod tests { fn test_global_hook_only_mode_creates_settings() { let tmp = TempDir::new().unwrap(); with_claude_dir_override(&tmp, |claude_dir| { - run_hook_only_mode(true, PatchMode::Auto, 0, false).unwrap(); + run_hook_only_mode(true, PatchMode::Auto, false, InitContext::default()).unwrap(); assert!( !claude_dir.join(RTK_MD).exists(), @@ -3867,6 +5479,72 @@ mod tests { }); } + #[test] + fn test_run_default_mode_dry_run_writes_nothing() { + let tmp = TempDir::new().unwrap(); + with_claude_dir_override(&tmp, |claude_dir| { + let dry = InitContext { + dry_run: true, + ..Default::default() + }; + run_default_mode(true, PatchMode::Auto, false, dry).unwrap(); + + assert!( + !claude_dir.join(RTK_MD).exists(), + "dry-run must not create RTK.md" + ); + assert!( + !claude_dir.join(CLAUDE_MD).exists(), + "dry-run must not create CLAUDE.md" + ); + assert!( + !claude_dir.join(SETTINGS_JSON).exists(), + "dry-run must not create settings.json" + ); + }); + } + + #[test] + fn test_uninstall_dry_run_preserves_artifacts() { + let tmp = TempDir::new().unwrap(); + with_claude_dir_override(&tmp, |claude_dir| { + // Stage a real install first + run_default_mode(true, PatchMode::Auto, false, InitContext::default()).unwrap(); + assert!(claude_dir.join(RTK_MD).exists()); + assert!(claude_dir.join(SETTINGS_JSON).exists()); + + let settings_before = fs::read_to_string(claude_dir.join(SETTINGS_JSON)).unwrap(); + let rtk_md_before = fs::read_to_string(claude_dir.join(RTK_MD)).unwrap(); + + // Dry-run uninstall + let dry = InitContext { + dry_run: true, + ..Default::default() + }; + uninstall(true, false, false, false, dry).unwrap(); + + // Files must still exist with identical content + assert!( + claude_dir.join(RTK_MD).exists(), + "dry-run uninstall must not remove RTK.md" + ); + assert!( + claude_dir.join(SETTINGS_JSON).exists(), + "dry-run uninstall must not remove settings.json" + ); + assert_eq!( + fs::read_to_string(claude_dir.join(RTK_MD)).unwrap(), + rtk_md_before, + "dry-run uninstall must not modify RTK.md" + ); + assert_eq!( + fs::read_to_string(claude_dir.join(SETTINGS_JSON)).unwrap(), + settings_before, + "dry-run uninstall must not modify settings.json" + ); + }); + } + #[test] fn test_uninstall_removes_rtk_instructions_block() { let temp = TempDir::new().unwrap(); diff --git a/src/hooks/integrity.rs b/src/hooks/integrity.rs index 5b0721d0f4..1101fe26a9 100644 --- a/src/hooks/integrity.rs +++ b/src/hooks/integrity.rs @@ -53,6 +53,11 @@ fn hash_path(hook_path: &Path) -> PathBuf { .join(HASH_FILENAME) } +/// Public accessor for the hash sidecar path (used by dry-run existence checks). +pub fn hash_path_for(hook_path: &Path) -> PathBuf { + hash_path(hook_path) +} + /// Store SHA-256 hash of the hook script after installation. /// /// Format is compatible with `sha256sum -c`: diff --git a/src/hooks/rewrite_cmd.rs b/src/hooks/rewrite_cmd.rs index 172d758998..215693be6b 100644 --- a/src/hooks/rewrite_cmd.rs +++ b/src/hooks/rewrite_cmd.rs @@ -16,8 +16,8 @@ use std::io::Write; /// | 2 | (none) | Deny rule matched — hook defers to Claude Code native deny. | /// | 3 | rewritten| Ask rule matched — hook rewrites but lets Claude Code prompt.| pub fn run(cmd: &str) -> anyhow::Result<()> { - let excluded = crate::core::config::Config::load() - .map(|c| c.hooks.exclude_commands) + let (excluded, transparent_prefixes) = crate::core::config::Config::load() + .map(|c| (c.hooks.exclude_commands, c.hooks.transparent_prefixes)) .unwrap_or_default(); // SECURITY: check deny/ask BEFORE rewrite so non-RTK commands are also covered. @@ -27,7 +27,7 @@ pub fn run(cmd: &str) -> anyhow::Result<()> { std::process::exit(2); } - match registry::rewrite_command(cmd, &excluded) { + match registry::rewrite_command(cmd, &excluded, &transparent_prefixes) { Some(rewritten) => match verdict { PermissionVerdict::Allow => { print!("{}", rewritten); @@ -53,20 +53,24 @@ pub fn run(cmd: &str) -> anyhow::Result<()> { mod tests { use super::*; + fn rewrite_command_no_prefixes(cmd: &str) -> Option { + registry::rewrite_command(cmd, &[], &[]) + } + #[test] fn test_run_supported_command_succeeds() { - assert!(registry::rewrite_command("git status", &[]).is_some()); + assert!(rewrite_command_no_prefixes("git status").is_some()); } #[test] fn test_run_unsupported_returns_none() { - assert!(registry::rewrite_command("htop", &[]).is_none()); + assert!(rewrite_command_no_prefixes("htop").is_none()); } #[test] fn test_run_already_rtk_returns_some() { assert_eq!( - registry::rewrite_command("rtk git status", &[]), + rewrite_command_no_prefixes("rtk git status"), Some("rtk git status".into()) ); } @@ -148,7 +152,7 @@ mod tests { // Verify the rewrite exists (so the hook would output it), // but the exit code forces user confirmation. - assert!(registry::rewrite_command("git status", &[]).is_some()); + assert!(registry::rewrite_command("git status", &[], &[]).is_some()); assert_eq!(expected_exit_code(&verdict), 3); } diff --git a/src/learn/detector.rs b/src/learn/detector.rs index 81ebade847..867e035a6b 100644 --- a/src/learn/detector.rs +++ b/src/learn/detector.rs @@ -345,7 +345,7 @@ pub fn deduplicate_corrections(pairs: Vec) -> Vec, }, + /// Android Gradle wrapper with compact output (build, test, lint) + #[command(name = "gradlew")] + Gradlew { + /// Gradle tasks and arguments (e.g., assembleDebug, testDebugUnitTest, lint, --info) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Show hook rewrite audit metrics (requires RTK_HOOK_AUDIT=1) #[command(name = "hook-audit")] HookAudit { @@ -1335,6 +1350,27 @@ fn main() { std::process::exit(code); } +fn uninstall_init_dispatch( + agent: Option, + global: bool, + gemini: bool, + codex: bool, + ctx: hooks::init::InitContext, + uninstall_hermes: UninstallHermes, + uninstall_standard: UninstallStandard, +) -> Result<()> +where + UninstallHermes: FnOnce(hooks::init::InitContext) -> Result<()>, + UninstallStandard: FnOnce(bool, bool, bool, bool, hooks::init::InitContext) -> Result<()>, +{ + if agent == Some(AgentTarget::Hermes) { + uninstall_hermes(ctx) + } else { + let cursor = agent == Some(AgentTarget::Cursor); + uninstall_standard(global, gemini, codex, cursor, ctx) + } +} + fn run_cli() -> Result { // Fire-and-forget telemetry ping (1/day, non-blocking) core::telemetry::maybe_ping(); @@ -1754,12 +1790,24 @@ fn run_cli() -> Result { uninstall, codex, copilot, + dry_run, } => { + let ctx = hooks::init::InitContext { + verbose: cli.verbose, + dry_run, + }; if show { hooks::init::show_config(codex)?; } else if uninstall { - let cursor = agent == Some(AgentTarget::Cursor); - hooks::init::uninstall(global, gemini, codex, cursor, cli.verbose)?; + uninstall_init_dispatch( + agent, + global, + gemini, + codex, + ctx, + hooks::init::uninstall_hermes, + hooks::init::uninstall, + )?; } else if gemini { let patch_mode = if auto_patch { hooks::init::PatchMode::Auto @@ -1768,21 +1816,23 @@ fn run_cli() -> Result { } else { hooks::init::PatchMode::Ask }; - hooks::init::run_gemini(global, hook_only, patch_mode, cli.verbose)?; + hooks::init::run_gemini(global, hook_only, patch_mode, ctx)?; } else if copilot { - hooks::init::run_copilot(cli.verbose)?; + hooks::init::run_copilot(ctx)?; } else if agent == Some(AgentTarget::Kilocode) { if global { anyhow::bail!("Kilo Code is project-scoped. Use: rtk init --agent kilocode"); } - hooks::init::run_kilocode_mode(cli.verbose)?; + hooks::init::run_kilocode_mode(ctx)?; } else if agent == Some(AgentTarget::Antigravity) { if global { anyhow::bail!( "Antigravity is project-scoped. Use: rtk init --agent antigravity" ); } - hooks::init::run_antigravity_mode(cli.verbose)?; + hooks::init::run_antigravity_mode(ctx)?; + } else if agent == Some(AgentTarget::Hermes) { + hooks::init::run_hermes_mode(ctx)?; } else { let install_opencode = opencode; let install_claude = !opencode; @@ -1808,7 +1858,7 @@ fn run_cli() -> Result { hook_only, codex, patch_mode, - cli.verbose, + ctx, )?; } 0 @@ -2092,6 +2142,8 @@ fn run_cli() -> Result { Commands::GolangciLint { args } => golangci_cmd::run(&args, cli.verbose)?, + Commands::Gradlew { args } => gradlew_cmd::run(&args, cli.verbose)?, + Commands::HookAudit { since } => { hooks::hook_audit_cmd::run(since, cli.verbose)?; 0 @@ -2117,10 +2169,10 @@ fn run_cli() -> Result { HookCommands::Check { agent: _, command } => { use crate::discover::registry::rewrite_command; let raw = command.join(" "); - let excluded = crate::core::config::Config::load() - .map(|c| c.hooks.exclude_commands) + let (excluded, transparent_prefixes) = crate::core::config::Config::load() + .map(|c| (c.hooks.exclude_commands, c.hooks.transparent_prefixes)) .unwrap_or_default(); - match rewrite_command(&raw, &excluded) { + match rewrite_command(&raw, &excluded, &transparent_prefixes) { Some(rewritten) => { println!("{}", rewritten); 0 @@ -2439,6 +2491,7 @@ fn is_operational_command(cmd: &Commands) -> bool { mod tests { use super::*; use clap::Parser; + use std::cell::Cell; #[test] fn test_git_commit_single_message() { @@ -2573,6 +2626,63 @@ mod tests { assert!(result.is_ok(), "git status should parse successfully"); } + #[test] + fn test_try_parse_init_agent_hermes() { + let cli = Cli::try_parse_from(["rtk", "init", "--agent", "hermes"]).unwrap(); + match cli.command { + Commands::Init { agent, .. } => { + assert_eq!(agent, Some(AgentTarget::Hermes)); + } + _ => panic!("Expected Init command"), + } + } + + #[test] + fn test_try_parse_init_agent_hermes_uninstall() { + let cli = Cli::try_parse_from(["rtk", "init", "--agent", "hermes", "--uninstall"]).unwrap(); + match cli.command { + Commands::Init { + agent, uninstall, .. + } => { + assert_eq!(agent, Some(AgentTarget::Hermes)); + assert!(uninstall); + } + _ => panic!("Expected Init command"), + } + } + + #[test] + fn test_init_uninstall_dispatch_routes_hermes_to_hermes_cleanup() { + let hermes_called = Cell::new(false); + let standard_called = Cell::new(false); + let ctx = hooks::init::InitContext { + verbose: 2, + dry_run: true, + }; + + let result = uninstall_init_dispatch( + Some(AgentTarget::Hermes), + true, + false, + false, + ctx, + |ctx| { + hermes_called.set(true); + assert_eq!(ctx.verbose, 2); + assert!(ctx.dry_run); + Ok(()) + }, + |_, _, _, _, _| { + standard_called.set(true); + Ok(()) + }, + ); + + assert!(result.is_ok()); + assert!(hermes_called.get()); + assert!(!standard_called.get()); + } + #[test] fn test_try_parse_help_is_display_help() { match Cli::try_parse_from(["rtk", "--help"]) { @@ -2723,6 +2833,30 @@ mod tests { } } + #[test] + fn test_hook_check_preserves_double_dash_in_command() { + let cli = Cli::try_parse_from([ + "rtk", + "hook", + "check", + "shadowenv", + "exec", + "--", + "git", + "status", + ]) + .unwrap(); + match cli.command { + Commands::Hook { + command: HookCommands::Check { agent, command }, + } => { + assert_eq!(agent, "claude"); + assert_eq!(command, vec!["shadowenv", "exec", "--", "git", "status"]); + } + _ => panic!("Expected Hook Check command"), + } + } + #[test] fn test_meta_command_list_is_complete() { // Verify all meta-commands are in the guard list by checking they parse with valid syntax diff --git a/tests/fixtures/gradlew_build_failed_raw.txt b/tests/fixtures/gradlew_build_failed_raw.txt new file mode 100644 index 0000000000..824d5ce777 --- /dev/null +++ b/tests/fixtures/gradlew_build_failed_raw.txt @@ -0,0 +1,24 @@ +> Configure project :app +> Task :app:preBuild UP-TO-DATE +> Task :app:generateDebugBuildConfig UP-TO-DATE +> Task :app:compileDebugKotlin FAILED + +FAILURE: Build failed with an exception. + +* What went wrong: +Execution failed for task ':app:compileDebugKotlin'. +> A failure occurred while executing org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork + > Compilation error. See log for more details + +e: /Users/user/MyApp/app/src/main/java/com/example/myapp/MainActivity.kt: (42, 5): Unresolved reference: MyService +e: /Users/user/MyApp/app/src/main/java/com/example/myapp/MainActivity.kt: (56, 17): Type mismatch: inferred type is String but Int was expected + +* Try: +> Run with --stacktrace option to get the stack trace. +> Run with --info or --debug option to get more log output. +> Run with --scan to get full insights. + +* Get more help at https://help.gradle.org + +BUILD FAILED in 12s +2 actionable tasks: 2 executed diff --git a/tests/fixtures/gradlew_build_raw.txt b/tests/fixtures/gradlew_build_raw.txt new file mode 100644 index 0000000000..9ec9b283bd --- /dev/null +++ b/tests/fixtures/gradlew_build_raw.txt @@ -0,0 +1,33 @@ +Starting a Gradle Daemon (subsequent builds will be faster) +Daemon will be stopped at the end of the build after running out of JVM memory + +> Configure project :app +> Task :app:preBuild UP-TO-DATE +> Task :app:generateDebugBuildConfig UP-TO-DATE +> Task :app:generateDebugResValues UP-TO-DATE +> Task :app:generateDebugResources UP-TO-DATE +> Task :app:mergeDebugResources UP-TO-DATE +> Task :app:processDebugMainManifest UP-TO-DATE +> Task :app:processDebugManifest UP-TO-DATE +> Task :app:processDebugManifestForPackage UP-TO-DATE +> Task :app:processDebugResources UP-TO-DATE +> Task :app:compileDebugKotlin UP-TO-DATE +> Task :app:compileDebugJavaWithJavac UP-TO-DATE +> Task :app:compileDebugSources UP-TO-DATE +> Task :app:lintVitalAnalyzeDebug UP-TO-DATE +> Task :app:mergeDebugShaders UP-TO-DATE +> Task :app:compileDebugShaders UP-TO-DATE +> Task :app:generateDebugAssets UP-TO-DATE +> Task :app:mergeDebugAssets UP-TO-DATE +> Task :app:mergeDebugJniLibFolders UP-TO-DATE +> Task :app:mergeDebugNativeLibs NO-SOURCE +> Task :app:stripDebugDebugSymbols NO-SOURCE +> Task :app:validateSigningDebug UP-TO-DATE +> Task :app:writeDebugAppMetadata UP-TO-DATE +> Task :app:writeDebugSigningConfigVersions UP-TO-DATE +> Task :app:packageDebug UP-TO-DATE +> Task :app:createDebugApkListingFileRedirect UP-TO-DATE +> Task :app:assembleDebug UP-TO-DATE + +BUILD SUCCESSFUL in 3s +28 actionable tasks: 28 up-to-date diff --git a/tests/fixtures/gradlew_connected_raw.txt b/tests/fixtures/gradlew_connected_raw.txt new file mode 100644 index 0000000000..d92a795aff --- /dev/null +++ b/tests/fixtures/gradlew_connected_raw.txt @@ -0,0 +1,33 @@ +Starting 2 tests on Pixel_6_API_33(AVD) - 13 +Installing APK 'app-debug.apk' on 'Pixel_6_API_33(AVD) - 13'... +Installing APK 'app-debug-androidTest.apk' on 'Pixel_6_API_33(AVD) - 13'... + +> Task :app:connectedDebugAndroidTest +INSTRUMENTATION_STATUS: class=com.example.myapp.MainActivityTest +INSTRUMENTATION_STATUS: current=1 +INSTRUMENTATION_STATUS: id=AndroidJUnitRunner +INSTRUMENTATION_STATUS: numtests=2 +INSTRUMENTATION_STATUS: stream= +INSTRUMENTATION_STATUS: test=exampleInstrumentedTest +INSTRUMENTATION_STATUS_CODE: 1 +INSTRUMENTATION_STATUS: class=com.example.myapp.MainActivityTest +INSTRUMENTATION_STATUS: current=1 +INSTRUMENTATION_STATUS: id=AndroidJUnitRunner +INSTRUMENTATION_STATUS: numtests=2 +INSTRUMENTATION_STATUS: stream= +. +INSTRUMENTATION_STATUS: test=exampleInstrumentedTest +INSTRUMENTATION_STATUS_CODE: 0 +com.example.myapp.MainActivityTest > exampleInstrumentedTest[Pixel_6_API_33(AVD) - 13] PASSED +INSTRUMENTATION_STATUS: class=com.example.myapp.MainActivityTest +INSTRUMENTATION_STATUS: current=2 +INSTRUMENTATION_STATUS: test=anotherTest +INSTRUMENTATION_STATUS_CODE: 1 +com.example.myapp.MainActivityTest > anotherTest[Pixel_6_API_33(AVD) - 13] PASSED +INSTRUMENTATION_STATUS_CODE: 0 +INSTRUMENTATION_RESULT: stream= +Tests run: 2, Failures: 0 +INSTRUMENTATION_CODE: -1 + +BUILD SUCCESSFUL in 45s +3 actionable tasks: 1 executed, 2 up-to-date diff --git a/tests/fixtures/gradlew_lint_raw.txt b/tests/fixtures/gradlew_lint_raw.txt new file mode 100644 index 0000000000..06cf3645e3 --- /dev/null +++ b/tests/fixtures/gradlew_lint_raw.txt @@ -0,0 +1,48 @@ +Starting a Gradle Daemon (subsequent builds will be faster) +Daemon will be stopped at the end of the build after running out of JVM memory + +> Configure project :app +> Configure project :core + +> Task :app:preBuild UP-TO-DATE +> Task :app:preDebugBuild UP-TO-DATE +> Task :app:compileDebugAidl NO-SOURCE +> Task :app:compileDebugRenderscript NO-SOURCE +> Task :app:generateDebugBuildConfig UP-TO-DATE +> Task :app:generateDebugResValues UP-TO-DATE +> Task :app:generateDebugResources UP-TO-DATE +> Task :app:mergeDebugResources UP-TO-DATE +> Task :app:processDebugMainManifest UP-TO-DATE +> Task :app:processDebugManifest UP-TO-DATE +> Task :app:processDebugManifestForPackage UP-TO-DATE +> Task :app:processDebugResources UP-TO-DATE +> Task :app:compileDebugKotlin UP-TO-DATE +> Task :app:compileDebugJavaWithJavac UP-TO-DATE +> Task :app:lintVitalAnalyzeDebug UP-TO-DATE +> Task :app:lint +Ran lint on variant debug: 0 issues found + +Wrote HTML report to file:///Users/user/MyApp/app/build/reports/lint-results-debug.html +Wrote XML report to file:///Users/user/MyApp/app/build/reports/lint-results-debug.xml + +> Task :app:lintDebug + +Ran lint on variant debug: 3 issues found + +src/main/java/com/example/myapp/MainActivity.kt:45: Error: Format string invalid [StringFormatInvalid] + String.format(getString(R.string.template), arg1, arg2) + ^ + This format string placeholder index (2) does not correspond to an argument + +src/main/java/com/example/myapp/Utils.kt:89: Warning: HardcodedText [HardcodedText] + return "Hello World" + ~~~~~~~~~~~~~ + +src/main/res/layout/activity_main.xml:15: Warning: Missing contentDescription attribute on image [ContentDescription] + Task :app:testDebugUnitTest +com.example.myapp.CalculatorTest > testAddition PASSED +com.example.myapp.CalculatorTest > testSubtraction FAILED + java.lang.AssertionError: expected:<3> but was:<-1> + at org.junit.Assert.fail(Assert.java:89) + at org.junit.Assert.assertEquals(Assert.java:197) + at com.example.myapp.CalculatorTest.testSubtraction(CalculatorTest.kt:25) +com.example.myapp.CalculatorTest > testMultiplication PASSED +com.example.myapp.MainViewModelTest > loadDataSuccess PASSED +com.example.myapp.MainViewModelTest > loadDataError FAILED + kotlin.NotImplementedError: An operation is not implemented: TODO + at com.example.myapp.MainViewModelTest.loadDataError(MainViewModelTest.kt:45) + +5 tests completed, 2 failed + +There were failing tests. See the report at: file:///Users/user/MyApp/app/build/reports/tests/testDebugUnitTest/index.html + +BUILD FAILED in 22s +4 actionable tasks: 1 executed, 3 up-to-date diff --git a/tests/fixtures/gradlew_test_raw.txt b/tests/fixtures/gradlew_test_raw.txt new file mode 100644 index 0000000000..e4c4b52b1f --- /dev/null +++ b/tests/fixtures/gradlew_test_raw.txt @@ -0,0 +1,12 @@ +> Task :app:testDebugUnitTest +com.example.myapp.CalculatorTest > testAddition PASSED +com.example.myapp.CalculatorTest > testSubtraction PASSED +com.example.myapp.CalculatorTest > testMultiplication PASSED +com.example.myapp.CalculatorTest > testDivision PASSED +com.example.myapp.MainViewModelTest > loadDataSuccess PASSED +com.example.myapp.MainViewModelTest > loadDataError PASSED + +6 tests completed, 0 failed + +BUILD SUCCESSFUL in 18s +4 actionable tasks: 1 executed, 3 up-to-date