diff --git a/Cargo.lock b/Cargo.lock
index 8773a0899..e26e2fa51 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -334,6 +334,15 @@ dependencies = [
"syn",
]
+[[package]]
+name = "encoding_rs"
+version = "0.8.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
+dependencies = [
+ "cfg-if",
+]
+
[[package]]
name = "env_home"
version = "0.1.0"
@@ -900,6 +909,7 @@ dependencies = [
"clap",
"colored",
"dirs",
+ "encoding_rs",
"flate2",
"getrandom 0.4.2",
"ignore",
diff --git a/Cargo.toml b/Cargo.toml
index 15d76d88b..a11782a95 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -34,6 +34,7 @@ flate2 = "1.0"
quick-xml = "0.37"
which = "8"
automod = "1"
+encoding_rs = "0.8"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
diff --git a/README.md b/README.md
index d91e21b24..2ada83d6d 100644
--- a/README.md
+++ b/README.md
@@ -28,8 +28,7 @@
中文 •
日本語 •
한국어 •
- Espanol •
- Português
+ Espanol
---
@@ -120,6 +119,36 @@ git status # Automatically rewritten to rtk git status
Hook-based agents rewrite Bash commands (e.g., `git status` -> `rtk git status`) before execution. Plugin-based agents, including Hermes, use their plugin API to rewrite commands before execution. The agent receives compact output without needing to call `rtk` explicitly.
+## Grep (agent-friendly)
+
+```bash
+rtk grep "Foo" . --files-only
+rtk grep "Foo" . --count-by-file
+rtk grep "Foo" . --top-files 10
+rtk grep "Foo" . --agent-safe
+rtk grep "Foo" . --agent-safe --max-per-file 30
+rtk grep "Foo" . --json
+rtk grep "Foo" . --agent-safe --json
+rtk grep "Foo" . --all --full-lines
+
+# Opt-in preset for agents (grep only in this slice):
+RTK_AGENT_SAFE=1 rtk grep "Foo" .
+```
+
+Notes:
+- `--files-only`: locator mode (paths only)
+- `--count-by-file`: counts per file
+- `--top-files N`: ranked file summary (top N files)
+- `--agent-safe`: caps match spam + adds summary/hints (flags override env/config)
+- `--json`: machine-readable JSON only (no human text)
+- `--all`: disables match caps
+- `--full-lines`: disables line clipping
+
+PowerShell:
+```powershell
+$env:RTK_AGENT_SAFE="1"; rtk grep "Foo" src
+```
+
**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.
## How It Works
@@ -147,9 +176,15 @@ Four strategies applied per command type:
rtk ls . # Token-optimized directory tree
rtk read file.rs # Smart file reading
rtk read file.rs -l aggressive # Signatures only (strips bodies)
+rtk read file.rs --lines 430:540 # Inclusive line range (1-based)
rtk smart file.rs # 2-line heuristic code summary
rtk find "*.rs" . # Compact find results
-rtk grep "pattern" . # Grouped search results
+rtk grep "pattern" . # Grouped search results (legacy defaults)
+rtk grep "Foo" . --files-only # Unique matching file paths
+rtk grep "Foo" . --count-by-file # Counts per file
+rtk grep "Foo" . --agent-safe # Token-safe preset (caps + clipping + summary)
+rtk grep "Foo" . --agent-safe --max-per-file 30
+rtk grep "Foo" . --all --full-lines # Legacy full output (uncapped + unclipped)
rtk diff file1 file2 # Condensed diff
```
@@ -209,6 +244,26 @@ rtk bundle install # Ruby gems (strip Using lines)
rtk prisma generate # Schema generation (no ASCII art)
```
+### C++ / CMake / MSBuild
+```bash
+rtk cmake --build # Build errors only (-85%)
+rtk cmake -B # Configure: errors + key settings only (-70%)
+rtk make [-j] # Make errors only (-80%)
+rtk ninja [-C ] # Ninja build errors only (-80%)
+rtk ctest [--test-dir dir] # Failed tests only (-85%)
+rtk msbuild [sln] [flags] # MSVC/LNK errors only, no project noise (-80%)
+rtk codegraph index # Summary only, no per-file progress (-90%)
+```
+
+### PowerShell
+```powershell
+Select-String -Path f -Pattern p # auto-rewritten -> rtk grep
+Get-Content # auto-rewritten -> rtk read
+GC # auto-rewritten -> rtk read
+rtk read --lines 430:540 # Prefer over Get-Content line-range loops
+Remove-Item -Force # -> ok
+```
+
### AWS
```bash
rtk aws sts get-caller-identity # One-line identity
@@ -238,6 +293,7 @@ rtk json config.json # Structure without values
rtk deps # Dependencies summary
rtk env -f AWS # Filtered env vars
rtk log app.log # Deduplicated logs
+rtk log ERRORLOG.TXT --events 20 # Tail key error/assert events (deduped)
rtk curl # Truncate + save full output
rtk wget # Download, strip progress bars
rtk summary # Heuristic summary
@@ -314,6 +370,22 @@ rtk init --show # Verify installation
After install, **restart Claude Code**.
+### Commands Rewritten
+
+Selected entries (full set covered by the hook):
+
+| Raw Command | Rewritten To |
+|---|---|
+| `cmake --build / cmake -B` | `rtk cmake ...` |
+| `ctest` | `rtk ctest` |
+| `make` (non-lifecycle targets) | `rtk make` |
+| `ninja` | `rtk ninja` |
+| `msbuild` | `rtk msbuild` |
+| `codegraph index/update/search/...` | `rtk codegraph ...` |
+| `Select-String -Path f -Pattern p` | `rtk grep p f` |
+| `Get-Content / GC` | `rtk read` |
+| `Remove-Item` | `rtk remove-item` |
+
## Windows
RTK works on Windows with some limitations. The auto-rewrite hook (`rtk-rewrite.sh`) requires a Unix shell, so on native Windows RTK falls back to **CLAUDE.md injection mode** — your AI assistant receives RTK instructions but commands are not rewritten automatically.
diff --git a/src/cmds/cpp/cmake_cmd.rs b/src/cmds/cpp/cmake_cmd.rs
new file mode 100644
index 000000000..c45adaf61
--- /dev/null
+++ b/src/cmds/cpp/cmake_cmd.rs
@@ -0,0 +1,278 @@
+//! Filters cmake build/configure output — keep diagnostics, drop progress noise.
+
+use crate::core::runner::{self, RunOptions};
+use crate::core::utils::resolved_command;
+use anyhow::Result;
+use lazy_static::lazy_static;
+use regex::Regex;
+
+lazy_static! {
+ // GCC/Clang diagnostic: file.cpp:line:col: error|warning|note: message
+ static ref GCC_DIAG_RE: Regex =
+ Regex::new(r"^[^:\s].*:\d+:\d+:\s+(?:error|warning|note|fatal error):").unwrap();
+ // make[N]: *** error
+ static ref MAKE_ERR_RE: Regex = Regex::new(r"^make(\[\d+\])?:\s+\*\*\*").unwrap();
+ // [ N%] Building CXX object ... or [ N%] Linking ...
+ static ref CMAKE_PROGRESS_RE: Regex =
+ Regex::new(r"^\[\s*\d+%\]\s+(Building|Linking|Built target|Generating|Built)").unwrap();
+ // ninja-style progress: [N/M] Building ...
+ static ref NINJA_PROGRESS_RE: Regex =
+ Regex::new(r"^\[\d+/\d+\]\s+(Building|Linking|Generating)").unwrap();
+ // CMake configure noise lines
+ static ref CMAKE_PROBE_RE: Regex = Regex::new(
+ r"^-- (Check for working|Detecting|Looking for|Found|Performing Test|Checking|Could NOT find)"
+ )
+ .unwrap();
+}
+
+pub fn run(args: &[String], verbose: u8) -> Result {
+ let mut cmd = resolved_command("cmake");
+ for a in args {
+ cmd.arg(a);
+ }
+
+ if verbose > 0 {
+ eprintln!("Running: cmake {}", args.join(" "));
+ }
+
+ let is_build = args.iter().any(|a| a == "--build");
+ let args_owned = args.to_vec();
+ runner::run_filtered(
+ cmd,
+ "cmake",
+ &args.join(" "),
+ move |raw| {
+ if is_build {
+ filter_build(raw, &args_owned)
+ } else {
+ filter_configure(raw)
+ }
+ },
+ RunOptions::with_tee("cmake"),
+ )
+}
+
+fn filter_build(raw: &str, args: &[String]) -> String {
+ let mut out = Vec::new();
+ let mut diag_context = 0usize;
+ let mut emitted_diag = false;
+ let mut file_count = 0usize;
+
+ for line in raw.lines() {
+ if CMAKE_PROGRESS_RE.is_match(line) || NINJA_PROGRESS_RE.is_match(line) {
+ file_count += 1;
+ continue;
+ }
+ if line.contains("Entering directory") || line.contains("Leaving directory") {
+ continue;
+ }
+
+ if GCC_DIAG_RE.is_match(line) {
+ out.push(line.to_string());
+ diag_context = 3;
+ emitted_diag = true;
+ continue;
+ }
+ if MAKE_ERR_RE.is_match(line) {
+ out.push(line.to_string());
+ emitted_diag = true;
+ diag_context = 0;
+ continue;
+ }
+ if line.contains("error:") || line.contains("undefined reference") {
+ out.push(line.to_string());
+ emitted_diag = true;
+ diag_context = 2;
+ continue;
+ }
+ if diag_context > 0 {
+ // Source context lines (typical clang/gcc): ' 42 | code'
+ // ' | ^~~~'
+ let trimmed = line.trim_start();
+ if trimmed.is_empty()
+ || trimmed.starts_with('|')
+ || line.starts_with(' ')
+ || line.starts_with('\t')
+ || trimmed.chars().take_while(|c| c.is_ascii_digit()).count() > 0
+ {
+ out.push(line.to_string());
+ diag_context -= 1;
+ continue;
+ }
+ diag_context = 0;
+ }
+ }
+
+ if !emitted_diag {
+ let target = args
+ .iter()
+ .position(|a| a == "--target")
+ .and_then(|i| args.get(i + 1))
+ .map(String::as_str)
+ .unwrap_or_else(|| {
+ args.iter()
+ .position(|a| a == "--build")
+ .and_then(|i| args.get(i + 1))
+ .map(|s| s.trim_start_matches("./"))
+ .unwrap_or("")
+ });
+ if target.is_empty() {
+ return format!("cmake: ok ({} files)", file_count);
+ }
+ return format!("cmake: ok {} ({} files)", target, file_count);
+ }
+
+ out.join("\n")
+}
+
+fn filter_configure(raw: &str) -> String {
+ let mut out = Vec::new();
+ let mut had_error = false;
+
+ for line in raw.lines() {
+ let trimmed = line.trim();
+ if trimmed.is_empty() {
+ continue;
+ }
+ if trimmed.starts_with("CMake Error") || trimmed.starts_with("CMake Warning") {
+ out.push(line.to_string());
+ had_error = trimmed.starts_with("CMake Error") || had_error;
+ continue;
+ }
+ if line.starts_with("ERROR") || line.contains("error:") {
+ out.push(line.to_string());
+ had_error = true;
+ continue;
+ }
+ if let Some(rest) = line.strip_prefix("-- ") {
+ if CMAKE_PROBE_RE.is_match(line) {
+ continue;
+ }
+ // Keep notable lines: Configuring done, Build files written, Build type, Install prefix, etc.
+ if rest.starts_with("Configuring done")
+ || rest.starts_with("Generating done")
+ || rest.starts_with("Build files have been written")
+ || rest.starts_with("Build type")
+ || rest.starts_with("Install prefix")
+ || rest.starts_with("The C compiler identification")
+ || rest.starts_with("The CXX compiler identification")
+ || rest.starts_with("Configuring incomplete")
+ {
+ out.push(line.to_string());
+ }
+ continue;
+ }
+ }
+
+ if out.is_empty() {
+ let _ = had_error;
+ return "cmake: configure ok".to_string();
+ }
+ out.join("\n")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn count_tokens(s: &str) -> usize {
+ s.split_whitespace().count()
+ }
+
+ #[test]
+ fn test_build_success_summary() {
+ let raw = "[ 10%] Building CXX object CMakeFiles/myapp.dir/main.cpp.o\n\
+ [ 50%] Building CXX object CMakeFiles/myapp.dir/util.cpp.o\n\
+ [100%] Linking CXX executable myapp\n\
+ [100%] Built target myapp\n";
+ let args = vec!["--build".to_string(), "build".to_string()];
+ let out = filter_build(raw, &args);
+ assert!(out.starts_with("cmake: ok"));
+ assert!(out.contains("4 files"));
+ }
+
+ #[test]
+ fn test_build_failure_keeps_diag() {
+ let raw = "[ 50%] Building CXX object CMakeFiles/x.dir/main.cpp.o\n\
+ /tmp/main.cpp:3:5: error: 'foo' was not declared in this scope\n\
+ 3 | foo();\n\
+ | ^~~\n\
+ make[2]: *** [CMakeFiles/x.dir/main.cpp.o] Error 1\n\
+ make[1]: *** [CMakeFiles/x.dir/all] Error 2\n";
+ let args = vec!["--build".to_string(), "build".to_string()];
+ let out = filter_build(raw, &args);
+ assert!(out.contains("error: 'foo'"));
+ assert!(out.contains("make[2]: ***"));
+ assert!(!out.contains("Building CXX"));
+ }
+
+ #[test]
+ fn test_configure_strips_probes() {
+ let raw = "-- The C compiler identification is GNU 13\n\
+ -- Detecting C compiler ABI info\n\
+ -- Detecting C compiler ABI info - done\n\
+ -- Check for working C compiler: /usr/bin/cc\n\
+ -- Looking for sys/types.h\n\
+ -- Looking for sys/types.h - found\n\
+ -- Configuring done\n\
+ -- Generating done\n\
+ -- Build files have been written to: /tmp/build\n";
+ let out = filter_configure(raw);
+ assert!(out.contains("Configuring done"));
+ assert!(out.contains("Build files have been written"));
+ assert!(!out.contains("Detecting"));
+ assert!(!out.contains("Looking for"));
+ }
+
+ #[test]
+ fn test_configure_keeps_errors() {
+ let raw = "-- Configuring incomplete, errors occurred!\n\
+ CMake Error at CMakeLists.txt:5 (find_package):\n\
+ Could not find FooBar.\n";
+ let out = filter_configure(raw);
+ assert!(out.contains("CMake Error"));
+ assert!(out.contains("Configuring incomplete"));
+ }
+
+ #[test]
+ fn test_fixture_build_success() {
+ let raw = include_str!("../../../tests/fixtures/cpp/cmake_build_success.txt");
+ let args = vec!["--build".to_string(), "build".to_string()];
+ let out = filter_build(raw, &args);
+ assert!(out.starts_with("cmake: ok"));
+ let savings =
+ 100.0 - (count_tokens(&out) as f64 / count_tokens(raw) as f64 * 100.0);
+ assert!(savings >= 60.0, "expected >=60%, got {:.1}%", savings);
+ }
+
+ #[test]
+ fn test_fixture_build_failure() {
+ let raw = include_str!("../../../tests/fixtures/cpp/cmake_build_failure.txt");
+ let args = vec!["--build".to_string(), "build".to_string()];
+ let out = filter_build(raw, &args);
+ assert!(out.contains("error:"));
+ assert!(out.contains("make[2]: ***"));
+ assert!(!out.contains("Building CXX object"));
+ }
+
+ #[test]
+ fn test_fixture_configure() {
+ let raw = include_str!("../../../tests/fixtures/cpp/cmake_configure.txt");
+ let out = filter_configure(raw);
+ assert!(out.contains("Configuring done"));
+ assert!(!out.contains("Detecting"));
+ assert!(!out.contains("Looking for"));
+ }
+
+ #[test]
+ fn test_savings_build_success() {
+ let raw = (0..50)
+ .map(|i| format!("[{:>3}%] Building CXX object CMakeFiles/lib.dir/file{}.cpp.o", i * 2, i))
+ .collect::>()
+ .join("\n");
+ let args = vec!["--build".to_string(), "build".to_string()];
+ let out = filter_build(&raw, &args);
+ let savings = 100.0 - (count_tokens(&out) as f64 / count_tokens(&raw) as f64 * 100.0);
+ assert!(savings >= 60.0, "expected >=60%, got {:.1}%", savings);
+ }
+}
diff --git a/src/cmds/cpp/codegraph_cmd.rs b/src/cmds/cpp/codegraph_cmd.rs
new file mode 100644
index 000000000..d2c9c3907
--- /dev/null
+++ b/src/cmds/cpp/codegraph_cmd.rs
@@ -0,0 +1,289 @@
+//! Filters codegraph CLI output — strips per-file progress, truncates result lists.
+//!
+//! IMPORTANT: `codegraph affected-tests` is NEVER truncated — its output is consumed
+//! by CI scripts. The MCP server (`codegraph serve`) is never routed here.
+
+use crate::core::runner::{self, RunOptions};
+use crate::core::utils::resolved_command;
+use anyhow::Result;
+use lazy_static::lazy_static;
+use regex::Regex;
+
+const RESULT_LIMIT: usize = 20;
+
+lazy_static! {
+ static ref PARSING_RE: Regex = Regex::new(r"^Parsing\s+\S+\s+ok\s+\(\d+\s+symbols?\)").unwrap();
+ static ref UPDATE_FILE_RE: Regex =
+ Regex::new(r"^(Updating|Reparsing|Removing)\s+\S+").unwrap();
+ static ref STATS_LINE_RE: Regex =
+ Regex::new(r"^\s*(Files|Symbols|Edges|Errors|Languages|Repos|Indexed)\s*:").unwrap();
+ static ref ERROR_RE: Regex = Regex::new(r"(?i)\b(error|failed)\b").unwrap();
+}
+
+pub fn run_index(args: &[String], verbose: u8) -> Result {
+ run_index_like("index", args, verbose)
+}
+
+pub fn run_update(args: &[String], verbose: u8) -> Result {
+ run_index_like("update", args, verbose)
+}
+
+fn run_index_like(sub: &'static str, args: &[String], verbose: u8) -> Result {
+ let mut cmd = resolved_command("codegraph");
+ cmd.arg(sub);
+ for a in args {
+ cmd.arg(a);
+ }
+ if verbose > 0 {
+ eprintln!("Running: codegraph {} {}", sub, args.join(" "));
+ }
+ runner::run_filtered(
+ cmd,
+ &format!("codegraph {}", sub),
+ &args.join(" "),
+ move |raw| filter_index(raw, sub),
+ RunOptions::with_tee("codegraph"),
+ )
+}
+
+pub fn run_stats(args: &[String], verbose: u8) -> Result {
+ let mut cmd = resolved_command("codegraph");
+ cmd.arg("stats");
+ for a in args {
+ cmd.arg(a);
+ }
+ if verbose > 0 {
+ eprintln!("Running: codegraph stats {}", args.join(" "));
+ }
+ runner::run_filtered(
+ cmd,
+ "codegraph stats",
+ &args.join(" "),
+ filter_stats,
+ RunOptions::default(),
+ )
+}
+
+pub fn run_search_like(sub: &'static str, args: &[String], verbose: u8) -> Result {
+ let mut cmd = resolved_command("codegraph");
+ cmd.arg(sub);
+ for a in args {
+ cmd.arg(a);
+ }
+ if verbose > 0 {
+ eprintln!("Running: codegraph {} {}", sub, args.join(" "));
+ }
+ runner::run_filtered(
+ cmd,
+ &format!("codegraph {}", sub),
+ &args.join(" "),
+ |raw| filter_results(raw, RESULT_LIMIT),
+ RunOptions::default(),
+ )
+}
+
+pub fn run_affected_tests(args: &[String], verbose: u8) -> Result {
+ let mut cmd = resolved_command("codegraph");
+ cmd.arg("affected-tests");
+ for a in args {
+ cmd.arg(a);
+ }
+ if verbose > 0 {
+ eprintln!("Running: codegraph affected-tests {}", args.join(" "));
+ }
+ // Pass-through completely unchanged — output is consumed by CI scripts.
+ runner::run_filtered(
+ cmd,
+ "codegraph affected-tests",
+ &args.join(" "),
+ |raw| raw.trim_end().to_string(),
+ RunOptions::default(),
+ )
+}
+
+fn filter_index(raw: &str, sub: &str) -> String {
+ let mut errors = Vec::new();
+ let mut summary = SummaryStats::default();
+
+ for line in raw.lines() {
+ let trimmed = line.trim();
+ if PARSING_RE.is_match(trimmed) || UPDATE_FILE_RE.is_match(trimmed) {
+ continue;
+ }
+ if let Some(rest) = trimmed.strip_prefix("Files:") {
+ summary.files = parse_first_num(rest);
+ } else if let Some(rest) = trimmed.strip_prefix("Files changed:") {
+ summary.files = parse_first_num(rest);
+ summary.has_changed = true;
+ } else if let Some(rest) = trimmed.strip_prefix("Symbols:") {
+ summary.symbols = parse_first_num(rest);
+ } else if let Some(rest) = trimmed.strip_prefix("New symbols:") {
+ summary.new_symbols = parse_first_num(rest);
+ } else if let Some(rest) = trimmed.strip_prefix("Edges:") {
+ summary.edges = parse_first_num(rest);
+ } else if let Some(rest) = trimmed.strip_prefix("Errors:") {
+ summary.errors = parse_first_num(rest);
+ } else if let Some(rest) = trimmed.strip_prefix("Time:") {
+ summary.time = rest.trim().to_string();
+ } else if trimmed.contains("error:") || trimmed.starts_with("Error") {
+ errors.push(trimmed.to_string());
+ }
+ }
+
+ let mut out = String::new();
+ if sub == "update" {
+ out.push_str(&format!(
+ "codegraph update: {} files changed",
+ summary.files.unwrap_or(0)
+ ));
+ if let Some(n) = summary.new_symbols {
+ out.push_str(&format!(" +{} symbols", n));
+ }
+ } else {
+ out.push_str(&format!(
+ "codegraph index: {} files",
+ summary.files.unwrap_or(0)
+ ));
+ if let Some(n) = summary.symbols {
+ out.push_str(&format!(" {} symbols", n));
+ }
+ if let Some(n) = summary.edges {
+ out.push_str(&format!(" {} edges", n));
+ }
+ }
+ if !summary.time.is_empty() {
+ out.push_str(&format!(" {}", summary.time));
+ }
+ if let Some(n) = summary.errors {
+ if n > 0 {
+ out.push_str(&format!("\nErrors: {}", n));
+ for e in errors.iter().take(20) {
+ out.push('\n');
+ out.push_str(e);
+ }
+ }
+ }
+ out
+}
+
+#[derive(Default)]
+struct SummaryStats {
+ files: Option,
+ symbols: Option,
+ new_symbols: Option,
+ edges: Option,
+ errors: Option,
+ time: String,
+ #[allow(dead_code)]
+ has_changed: bool,
+}
+
+fn parse_first_num(s: &str) -> Option {
+ s.split(|c: char| !c.is_ascii_digit())
+ .find(|t| !t.is_empty())
+ .and_then(|t| t.parse().ok())
+}
+
+fn filter_stats(raw: &str) -> String {
+ let mut out = Vec::new();
+ for line in raw.lines() {
+ let trimmed = line.trim_end();
+ if trimmed.is_empty() {
+ continue;
+ }
+ let stripped = trimmed.trim_start();
+ if stripped.chars().all(|c| matches!(c, '-' | '=' | '*' | '+')) {
+ continue;
+ }
+ out.push(trimmed.to_string());
+ }
+ out.join("\n")
+}
+
+fn filter_results(raw: &str, limit: usize) -> String {
+ let mut lines: Vec<&str> = raw
+ .lines()
+ .filter(|l| !l.trim().is_empty())
+ .collect();
+ let total = lines.len();
+ if total <= limit {
+ return raw.trim_end().to_string();
+ }
+ lines.truncate(limit);
+ let mut out = lines.join("\n");
+ out.push_str(&format!(
+ "\n(+{} more — run codegraph search directly for full results)",
+ total - limit
+ ));
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_index_strips_parsing_lines() {
+ let raw = "Parsing src/foo.rs ok (12 symbols)\n\
+ Parsing src/bar.rs ok (8 symbols)\n\
+ Parsing src/baz.rs ok (3 symbols)\n\
+ Files: 3\n\
+ Symbols: 23\n\
+ Edges: 47\n\
+ Errors: 0\n\
+ Time: 0.4s\n";
+ let out = filter_index(raw, "index");
+ assert!(out.starts_with("codegraph index:"));
+ assert!(out.contains("3 files"));
+ assert!(out.contains("23 symbols"));
+ assert!(!out.contains("Parsing"));
+ }
+
+ #[test]
+ fn test_search_truncates() {
+ let raw = (1..=50)
+ .map(|i| format!("result_{}: foo at file{}.rs:10", i, i))
+ .collect::>()
+ .join("\n");
+ let out = filter_results(&raw, 20);
+ assert!(out.contains("result_1:"));
+ assert!(out.contains("result_20:"));
+ assert!(!out.contains("result_21:"));
+ assert!(out.contains("(+30 more"));
+ }
+
+ #[test]
+ fn test_search_no_truncate_under_limit() {
+ let raw = "a\nb\nc";
+ assert_eq!(filter_results(raw, 20), "a\nb\nc");
+ }
+
+ #[test]
+ fn test_fixture_index_verbose() {
+ let raw = include_str!("../../../tests/fixtures/cpp/codegraph_index_verbose.txt");
+ let out = filter_index(raw, "index");
+ assert!(out.starts_with("codegraph index:"));
+ assert!(out.contains("21 files"));
+ assert!(out.contains("771 symbols"));
+ assert!(out.contains("2412 edges"));
+ assert!(!out.contains("Parsing src/"));
+ }
+
+ #[test]
+ fn test_fixture_search_results_truncate() {
+ let raw = include_str!("../../../tests/fixtures/cpp/codegraph_search_results.txt");
+ let out = filter_results(raw, RESULT_LIMIT);
+ assert!(out.contains("(+30 more"));
+ assert!(out.contains("parse_expr at src/parser.rs:42"));
+ }
+
+ #[test]
+ fn test_stats_strips_separators() {
+ let raw = "==========\nFiles: 47\n----------\nSymbols: 2841\n";
+ let out = filter_stats(raw);
+ assert!(out.contains("Files: 47"));
+ assert!(out.contains("Symbols: 2841"));
+ assert!(!out.contains("===="));
+ assert!(!out.contains("----"));
+ }
+}
diff --git a/src/cmds/cpp/ctest_cmd.rs b/src/cmds/cpp/ctest_cmd.rs
new file mode 100644
index 000000000..30f8ecf21
--- /dev/null
+++ b/src/cmds/cpp/ctest_cmd.rs
@@ -0,0 +1,361 @@
+//! Filters ctest output — keep GoogleTest failures and final summaries.
+
+use crate::core::runner::{self, RunOptions};
+use crate::core::utils::resolved_command;
+use anyhow::Result;
+
+pub fn run(args: &[String], verbose: u8) -> Result {
+ let mut cmd = resolved_command("ctest");
+ for a in args {
+ cmd.arg(a);
+ }
+ if verbose > 0 {
+ eprintln!("Running: ctest {}", args.join(" "));
+ }
+ runner::run_filtered(
+ cmd,
+ "ctest",
+ &args.join(" "),
+ filter_ctest_output,
+ RunOptions::with_tee("ctest"),
+ )
+}
+
+pub(crate) fn filter_ctest_output(output: &str) -> String {
+ let mut out: Vec = Vec::new();
+ let mut current_test: Option = None;
+ let mut emitting_failure_block = false;
+ let mut emitted_test_header_for_block = false;
+
+ for line in output.lines() {
+ let trimmed = line.trim_end();
+ let t = trimmed.trim();
+ let m = normalize_ctest_verbose_prefix(t);
+
+ if t.is_empty() {
+ if emitting_failure_block {
+ out.push(String::new());
+ }
+ continue;
+ }
+
+ if let Some(name) = parse_gtest_run(m) {
+ current_test = Some(name);
+ emitting_failure_block = false;
+ emitted_test_header_for_block = false;
+ continue;
+ }
+
+ if is_gtest_ok(m) {
+ emitting_failure_block = false;
+ emitted_test_header_for_block = false;
+ current_test = None;
+ continue;
+ }
+
+ if is_gtest_failure_header(m) {
+ if !emitted_test_header_for_block {
+ if let Some(name) = current_test.as_deref() {
+ out.push(name.to_string());
+ }
+ emitted_test_header_for_block = true;
+ }
+ emitting_failure_block = true;
+ out.push(trimmed.to_string());
+ continue;
+ }
+
+ if is_gtest_failed_summary_line(m) {
+ emitting_failure_block = false;
+ emitted_test_header_for_block = false;
+ out.push(trimmed.to_string());
+ continue;
+ }
+
+ if is_ctest_summary_line(m) {
+ emitting_failure_block = false;
+ emitted_test_header_for_block = false;
+ out.push(trimmed.to_string());
+ continue;
+ }
+
+ if is_important_non_gtest_line(m) {
+ out.push(trimmed.to_string());
+ continue;
+ }
+
+ if emitting_failure_block {
+ if is_gtest_failed_test_line(m) {
+ out.push(trimmed.to_string());
+ emitting_failure_block = false;
+ emitted_test_header_for_block = false;
+ current_test = None;
+ continue;
+ }
+
+ if is_noisy_separator(m) {
+ continue;
+ }
+
+ out.push(trimmed.to_string());
+ }
+ }
+
+ compact_lines(out).join("\n")
+}
+
+fn normalize_ctest_verbose_prefix(line: &str) -> &str {
+ let s = line.trim_start();
+ let mut i = 0;
+ for (idx, ch) in s.char_indices() {
+ if ch.is_ascii_digit() {
+ i = idx + ch.len_utf8();
+ continue;
+ }
+ i = idx;
+ break;
+ }
+ if i == 0 {
+ return line;
+ }
+ let rest = &s[i..];
+ if !rest.starts_with(':') {
+ return line;
+ }
+ let rest = &rest[1..];
+ let rest = rest.strip_prefix(' ').unwrap_or(rest);
+ if rest.is_empty() {
+ line
+ } else {
+ rest
+ }
+}
+
+fn parse_gtest_run(line: &str) -> Option {
+ if !line.starts_with("[ RUN") {
+ return None;
+ }
+ let idx = line.find("]")?;
+ let rest = line[idx + 1..].trim();
+ if rest.is_empty() {
+ None
+ } else {
+ Some(rest.to_string())
+ }
+}
+
+fn is_gtest_ok(line: &str) -> bool {
+ line.starts_with("[ OK ]")
+}
+
+fn is_gtest_failure_header(line: &str) -> bool {
+ line.ends_with(": Failure")
+}
+
+fn is_gtest_failed_test_line(line: &str) -> bool {
+ line.starts_with("[ FAILED ]") && line.contains('(')
+}
+
+fn is_gtest_failed_summary_line(line: &str) -> bool {
+ if line.starts_with("[ FAILED ]") {
+ return true;
+ }
+ if line.starts_with("[ PASSED ]") && line.contains("tests") {
+ return true;
+ }
+ false
+}
+
+fn is_ctest_summary_line(line: &str) -> bool {
+ let l = line.to_lowercase();
+ l.contains("tests failed")
+ || l.contains("% tests passed")
+ || l.contains("the following tests failed")
+ || l.contains("errors while running ctest")
+ || l.contains("ctest error")
+}
+
+fn is_important_non_gtest_line(line: &str) -> bool {
+ let l = line.to_lowercase();
+ l.contains("addresssanitizer")
+ || l.contains("runtime error")
+ || l.contains("undefined behavior")
+ || l.contains("segmentation fault")
+ || l.contains("abort")
+ || l.starts_with("error:")
+ || l.contains("fatal error")
+ || l.contains("cmake error")
+ || l.contains("clang: error")
+ || l.contains("gcc: error")
+ || l.contains("ld: error")
+}
+
+fn is_noisy_separator(line: &str) -> bool {
+ line.chars().all(|c| c == '=' || c == '-' || c == '─' || c == '═')
+}
+
+fn compact_lines(mut lines: Vec) -> Vec {
+ while lines.first().is_some_and(|l| l.trim().is_empty()) {
+ lines.remove(0);
+ }
+ while lines.last().is_some_and(|l| l.trim().is_empty()) {
+ lines.pop();
+ }
+
+ let mut out: Vec = Vec::with_capacity(lines.len());
+ let mut last_blank = false;
+ for l in lines.drain(..) {
+ let blank = l.trim().is_empty();
+ if blank {
+ if last_blank {
+ continue;
+ }
+ last_blank = true;
+ out.push(String::new());
+ } else {
+ last_blank = false;
+ out.push(l);
+ }
+ }
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn gtest_all_passed_compacts() {
+ let input = r#"
+[==========] Running 2 tests from 1 test suite.
+[ RUN ] Foo.Pass
+[ OK ] Foo.Pass (1 ms)
+[ RUN ] Foo.Pass2
+[ OK ] Foo.Pass2 (1 ms)
+[==========] 2 tests from 1 test suite ran. (2 ms total)
+[ PASSED ] 2 tests.
+"#;
+ let out = filter_ctest_output(input);
+ assert!(!out.contains("Foo.Pass"));
+ assert!(!out.contains("[ RUN"));
+ assert!(out.contains("[ PASSED ] 2 tests."));
+ }
+
+ #[test]
+ fn gtest_one_failed_keeps_block_and_summary() {
+ let input = r#"
+[==========] Running 3 tests from 1 test suite.
+[ RUN ] Foo.Pass
+[ OK ] Foo.Pass (1 ms)
+[ RUN ] Foo.Fail
+/path/foo_test.cc:42: Failure
+Expected equality of these values:
+ actual
+ expected
+[ FAILED ] Foo.Fail (0 ms)
+[ RUN ] Foo.Pass2
+[ OK ] Foo.Pass2 (1 ms)
+[==========] 3 tests from 1 test suite ran.
+[ PASSED ] 2 tests.
+[ FAILED ] 1 test, listed below:
+[ FAILED ] Foo.Fail
+"#;
+ let out = filter_ctest_output(input);
+ assert!(out.contains("Foo.Fail"));
+ assert!(out.contains("/path/foo_test.cc:42: Failure"));
+ assert!(out.contains("Expected equality of these values:"));
+ assert!(out.contains("[ FAILED ] 1 test, listed below:"));
+ assert!(out.contains("[ FAILED ] Foo.Fail"));
+ assert!(!out.contains("Foo.Pass"));
+ assert!(!out.contains("Foo.Pass2"));
+ }
+
+ #[test]
+ fn gtest_multiple_failed_keeps_both_blocks() {
+ let input = r#"
+[ RUN ] A.Fail
+/p/a.cc:1: Failure
+boom
+[ FAILED ] A.Fail (0 ms)
+[ RUN ] B.Fail
+/p/b.cc:2: Failure
+kaboom
+[ FAILED ] B.Fail (0 ms)
+[ FAILED ] 2 tests, listed below:
+[ FAILED ] A.Fail
+[ FAILED ] B.Fail
+"#;
+ let out = filter_ctest_output(input);
+ assert!(out.contains("A.Fail"));
+ assert!(out.contains("/p/a.cc:1: Failure"));
+ assert!(out.contains("B.Fail"));
+ assert!(out.contains("/p/b.cc:2: Failure"));
+ assert!(out.contains("[ FAILED ] A.Fail"));
+ assert!(out.contains("[ FAILED ] B.Fail"));
+ }
+
+ #[test]
+ fn gtest_parameterized_names_survive() {
+ let input = r#"
+[ RUN ] FooTest/0.DoesThing
+/p/t.cc:3: Failure
+nope
+[ FAILED ] FooTest/0.DoesThing (0 ms)
+[ RUN ] FooSuite/BarTest.DoesThing/1
+/p/u.cc:4: Failure
+nope2
+[ FAILED ] FooSuite/BarTest.DoesThing/1 (0 ms)
+[ FAILED ] 2 tests, listed below:
+[ FAILED ] FooTest/0.DoesThing
+[ FAILED ] FooSuite/BarTest.DoesThing/1
+"#;
+ let out = filter_ctest_output(input);
+ assert!(out.contains("FooTest/0.DoesThing"));
+ assert!(out.contains("FooSuite/BarTest.DoesThing/1"));
+ }
+
+ #[test]
+ fn keeps_non_gtest_important_lines() {
+ let input = r#"
+[ RUN ] Foo.Fail
+/p/t.cc:3: Failure
+nope
+AddressSanitizer: heap-use-after-free
+[ FAILED ] Foo.Fail (0 ms)
+"#;
+ let out = filter_ctest_output(input);
+ assert!(out.contains("AddressSanitizer: heap-use-after-free"));
+ assert!(out.contains("/p/t.cc:3: Failure"));
+ }
+
+ #[test]
+ fn ctest_verbose_prefix_gtest_failure_survives_and_pass_noise_drops() {
+ let input = r#"
+1: [ RUN ] Foo.Pass
+1: [ OK ] Foo.Pass (1 ms)
+1: [ RUN ] Foo.Fail
+1: /path/foo_test.cc:42: Failure
+1: Expected equality of these values:
+1: actual
+1: expected
+1: [ FAILED ] Foo.Fail (0 ms)
+1: [ RUN ] Foo.Pass2
+1: [ OK ] Foo.Pass2 (1 ms)
+1: [ FAILED ] 1 test, listed below:
+1: [ FAILED ] Foo.Fail
+"#;
+ let out = filter_ctest_output(input);
+ assert!(out.contains("Foo.Fail"));
+ assert!(out.contains("1: /path/foo_test.cc:42: Failure"));
+ assert!(out.contains("1: Expected equality of these values:"));
+ assert!(out.contains("1: [ FAILED ] Foo.Fail"));
+ assert!(!out.contains("Foo.Pass"));
+ assert!(!out.contains("Foo.Pass2"));
+ }
+
+ #[test]
+ fn ctest_verbose_prefix_normalizer_requires_colon() {
+ assert_eq!(normalize_ctest_verbose_prefix("123 tests failed"), "123 tests failed");
+ assert_eq!(normalize_ctest_verbose_prefix("404 error happened"), "404 error happened");
+ }
+}
diff --git a/src/cmds/cpp/make_cmd.rs b/src/cmds/cpp/make_cmd.rs
new file mode 100644
index 000000000..b884c627c
--- /dev/null
+++ b/src/cmds/cpp/make_cmd.rs
@@ -0,0 +1,141 @@
+//! Filters make/ninja output — strips per-file noise, surfaces compiler diagnostics.
+
+use crate::core::runner::{self, RunOptions};
+use crate::core::utils::resolved_command;
+use anyhow::Result;
+use lazy_static::lazy_static;
+use regex::Regex;
+
+lazy_static! {
+ static ref GCC_DIAG_RE: Regex =
+ Regex::new(r"^[^:\s].*:\d+:\d+:\s+(?:error|warning|note|fatal error):").unwrap();
+ static ref MAKE_ERR_RE: Regex = Regex::new(r"^make(\[\d+\])?:\s+\*\*\*").unwrap();
+ static ref NINJA_PROGRESS_RE: Regex =
+ Regex::new(r"^\[\d+/\d+\]\s+(Building|Linking|Generating|Compiling)").unwrap();
+ static ref MAKE_BUILD_LINE_RE: Regex =
+ Regex::new(r"^(?:cc|gcc|g\+\+|clang|clang\+\+|c\+\+|ld|ar)\b").unwrap();
+}
+
+pub fn run_make(args: &[String], verbose: u8) -> Result {
+ run_inner("make", args, verbose)
+}
+
+pub fn run_ninja(args: &[String], verbose: u8) -> Result {
+ run_inner("ninja", args, verbose)
+}
+
+fn run_inner(tool: &'static str, args: &[String], verbose: u8) -> Result {
+ let mut cmd = resolved_command(tool);
+ for a in args {
+ cmd.arg(a);
+ }
+ if verbose > 0 {
+ eprintln!("Running: {} {}", tool, args.join(" "));
+ }
+ runner::run_filtered(
+ cmd,
+ tool,
+ &args.join(" "),
+ move |raw| filter_output(raw, tool),
+ RunOptions::with_tee("make"),
+ )
+}
+
+fn filter_output(raw: &str, tool: &str) -> String {
+ let mut out = Vec::new();
+ let mut diag_context = 0usize;
+ let mut emitted_diag = false;
+
+ for line in raw.lines() {
+ if NINJA_PROGRESS_RE.is_match(line) {
+ continue;
+ }
+ if line.contains("Entering directory") || line.contains("Leaving directory") {
+ continue;
+ }
+ if MAKE_BUILD_LINE_RE.is_match(line) {
+ continue;
+ }
+
+ if GCC_DIAG_RE.is_match(line) {
+ out.push(line.to_string());
+ diag_context = 3;
+ emitted_diag = true;
+ continue;
+ }
+ if MAKE_ERR_RE.is_match(line) {
+ out.push(line.to_string());
+ emitted_diag = true;
+ diag_context = 0;
+ continue;
+ }
+ if line.contains("undefined reference") {
+ out.push(line.to_string());
+ emitted_diag = true;
+ continue;
+ }
+ if diag_context > 0 {
+ let trimmed = line.trim_start();
+ if trimmed.is_empty()
+ || trimmed.starts_with('|')
+ || line.starts_with(' ')
+ || line.starts_with('\t')
+ {
+ out.push(line.to_string());
+ diag_context -= 1;
+ continue;
+ }
+ diag_context = 0;
+ }
+ }
+
+ if !emitted_diag {
+ return format!("{}: ok", tool);
+ }
+ out.join("\n")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_make_success() {
+ let raw = "make: Entering directory '/tmp/x'\n\
+ cc -c main.c -o main.o\n\
+ cc -o myapp main.o\n\
+ make: Leaving directory '/tmp/x'\n";
+ assert_eq!(filter_output(raw, "make"), "make: ok");
+ }
+
+ #[test]
+ fn test_make_failure_keeps_diag() {
+ let raw = "cc -c main.c -o main.o\n\
+ main.c:5:1: error: expected ';' before 'return'\n\
+ 5 | return 0\n\
+ | ^\n\
+ make[1]: *** [Makefile:10: main.o] Error 1\n\
+ make: *** [all] Error 2\n";
+ let out = filter_output(raw, "make");
+ assert!(out.contains("error:"));
+ assert!(out.contains("make[1]: ***"));
+ assert!(!out.contains("cc -c main.c"));
+ }
+
+ #[test]
+ fn test_fixture_make_failure() {
+ let raw = include_str!("../../../tests/fixtures/cpp/make_failure.txt");
+ let out = filter_output(raw, "make");
+ assert!(out.contains("error:"));
+ assert!(out.contains("make[1]: ***") || out.contains("make: ***"));
+ assert!(!out.contains("cc -Wall"));
+ }
+
+ #[test]
+ fn test_ninja_progress_stripped() {
+ let raw = "[1/3] Building CXX object x.o\n\
+ [2/3] Building CXX object y.o\n\
+ [3/3] Linking myapp\n";
+ assert_eq!(filter_output(raw, "ninja"), "ninja: ok");
+ }
+}
diff --git a/src/cmds/cpp/mod.rs b/src/cmds/cpp/mod.rs
new file mode 100644
index 000000000..a37ca7466
--- /dev/null
+++ b/src/cmds/cpp/mod.rs
@@ -0,0 +1 @@
+automod::dir!(pub "src/cmds/cpp");
diff --git a/src/cmds/cpp/msbuild_cmd.rs b/src/cmds/cpp/msbuild_cmd.rs
new file mode 100644
index 000000000..c8c64e1b6
--- /dev/null
+++ b/src/cmds/cpp/msbuild_cmd.rs
@@ -0,0 +1,711 @@
+//! Filters MSBuild output — keeps cl/link diagnostics, drops project/task noise.
+//!
+//! Captures stdout AND stderr (default for run_filtered) so linker errors from
+//! `link.exe` (which writes to stderr) survive into the filter input.
+
+use crate::core::runner::{self, RunOptions};
+use crate::core::utils::resolved_command;
+use anyhow::Result;
+use lazy_static::lazy_static;
+use regex::Regex;
+use std::collections::HashSet;
+
+lazy_static! {
+ // Compiler: file(line): error|warning C1234: message [project.vcxproj]
+ static ref MSVC_COMPILER_RE: Regex =
+ Regex::new(r"^(.+)\((\d+)\): (error|warning|fatal error) (C\d+): (.+?)(?:\s+\[.+\])?$")
+ .unwrap();
+ // Linker: module : error|fatal error LNK1234: message
+ static ref MSVC_LINKER_RE: Regex =
+ Regex::new(r"^(.+) : (error|fatal error) (LNK\d+): (.+)$").unwrap();
+ // Linker tool (no file prefix): "LINK : fatal error LNK1104: ..."
+ static ref MSVC_LINK_TOOL_RE: Regex =
+ Regex::new(r"^(?i:LINK)\s*: (warning|error|fatal error) (LNK\d+): (.+)$").unwrap();
+ // Resource compiler: file.rc(line): error|fatal error RC1234: message [project.vcxproj]
+ static ref RC_DIAG_RE: Regex =
+ Regex::new(r"^(.+)\((\d+)\): (warning|error|fatal error) (RC\d+): (.+?)(?:\s+\[.+\])?$")
+ .unwrap();
+ // MSBuild-style diagnostics: path.vcxproj(123,5): error MSB3073: ...
+ static ref MSBUILD_DIAG_RE: Regex = Regex::new(
+ r"^(.+?)\((\d+)(?:,(\d+))?\): (warning|error|fatal error) ((?:MSB|PRJ|CVT|LNK|RC|C)\d+): (.+)$"
+ )
+ .unwrap();
+ static ref MSB3073_RE: Regex = Regex::new(r"(?i)\b(MSB3073|MSB3721)\b").unwrap();
+ static ref EXIT_CODE_RE: Regex = Regex::new(r"(?i)\bexited with code\s+(\d+)\b").unwrap();
+ static ref PROJECT_ON_NODE_RE: Regex =
+ Regex::new(r#"^Project \"(.+?)\" on node \d+ \((.+?) target\(s\)\)\."#).unwrap();
+ static ref DONE_BUILDING_RE: Regex =
+ Regex::new(r#"^Done Building Project \"(.+?)\" \(.+\) -- (FAILED|SUCCESSFUL)\."#)
+ .unwrap();
+ // Build FAILED. or Build succeeded.
+ static ref BUILD_RESULT_RE: Regex = Regex::new(r"^Build (FAILED|succeeded)\.").unwrap();
+ // " N Error(s)" or " N Warning(s)"
+ static ref ERR_WARN_COUNT_RE: Regex = Regex::new(r"^\s+\d+\s+(Error|Warning)\(s\)").unwrap();
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum Severity {
+ Error,
+ Warning,
+}
+
+#[derive(Debug, Clone)]
+struct MsbuildDiag {
+ idx: usize,
+ severity: Severity,
+ code: String,
+ file: Option,
+ line: Option,
+ message: String,
+ project: Option,
+ raw: String,
+}
+
+pub fn run(args: &[String], verbose: u8) -> Result {
+ let mut adjusted: Vec = Vec::with_capacity(args.len() + 1);
+ let mut found_link_only = false;
+ for a in args {
+ // Always rewrite /t:Link-only to /t:Build to capture both compile + link output
+ let lower = a.to_ascii_lowercase();
+ if lower == "/t:link" || lower == "-t:link" {
+ adjusted.push("/t:Build".to_string());
+ found_link_only = true;
+ } else {
+ adjusted.push(a.clone());
+ }
+ }
+ if verbose > 0 && found_link_only {
+ eprintln!("rtk msbuild: rewrote /t:Link → /t:Build to capture linker output");
+ }
+
+ let mut cmd = resolved_command("msbuild");
+ for a in &adjusted {
+ cmd.arg(a);
+ }
+ if verbose > 0 {
+ eprintln!("Running: msbuild {}", adjusted.join(" "));
+ }
+
+ let args_owned = adjusted.clone();
+ runner::run_filtered(
+ cmd,
+ "msbuild",
+ &adjusted.join(" "),
+ move |raw| filter_output(raw, &args_owned),
+ RunOptions::with_tee("msbuild"),
+ )
+}
+
+pub(crate) fn filter_output(raw: &str, args: &[String]) -> String {
+ let mut diags: Vec = Vec::new();
+ let mut summary: Vec = Vec::new();
+ let mut build_result: Option = None;
+ let mut succeeded = false;
+ let mut current_project: Option = None;
+ let mut failed_projects: Vec = Vec::new();
+
+ let lines: Vec<&str> = raw.lines().collect();
+ for (idx, line) in lines.iter().enumerate() {
+ let trimmed = line.trim_end();
+ if trimmed.is_empty() {
+ continue;
+ }
+
+ if let Some(caps) = PROJECT_ON_NODE_RE.captures(trimmed) {
+ current_project = Some(caps[1].to_string());
+ continue;
+ }
+ if let Some(caps) = DONE_BUILDING_RE.captures(trimmed) {
+ let proj = caps[1].to_string();
+ let status = &caps[2];
+ if status.eq_ignore_ascii_case("FAILED") && !failed_projects.contains(&proj) {
+ failed_projects.push(proj.clone());
+ }
+ current_project = Some(proj);
+ continue;
+ }
+
+ if let Some(diag) = parse_diag_line(trimmed, idx, current_project.as_deref()) {
+ diags.push(diag);
+ continue;
+ }
+ if let Some(caps) = BUILD_RESULT_RE.captures(trimmed) {
+ build_result = Some(trimmed.to_string());
+ succeeded = &caps[1] == "succeeded";
+ continue;
+ }
+ if ERR_WARN_COUNT_RE.is_match(trimmed) {
+ summary.push(trimmed.to_string());
+ continue;
+ }
+ }
+
+ let has_errors = diags.iter().any(|d| d.severity == Severity::Error);
+
+ if !has_errors && build_result.is_none() && diags.is_empty() {
+ // Empty / redirected output
+ let target = configuration_summary(args);
+ return format!(
+ "msbuild: no output captured \u{2014} rerun with /t:Build to capture linker output\n\
+ [target: {}]",
+ target
+ );
+ }
+
+ if succeeded && !has_errors {
+ let target = configuration_summary(args);
+ return format!("msbuild: ok {}", target);
+ }
+
+ let mut out = String::new();
+
+ if let Some(first_error) = first_real_error(&diags) {
+ let target = configuration_summary(args);
+ out.push_str("FIRST_ERROR\n");
+ if !target.is_empty() {
+ out.push_str(&format!(" target: {}\n", target));
+ }
+ if let Some(p) = first_error.project.as_deref() {
+ out.push_str(&format!(" project: {}\n", p));
+ }
+ if let Some(f) = first_error.file.as_deref() {
+ out.push_str(&format!(" file: {}\n", f));
+ }
+ if let Some(ln) = first_error.line {
+ out.push_str(&format!(" line: {}\n", ln));
+ }
+ out.push_str(&format!(" code: {}\n", first_error.code));
+ out.push_str(&format!(" message: {}\n", first_error.message));
+
+ let ctx = extract_context(&lines, first_error.idx, 3, 5);
+ if !ctx.prev.is_empty() || !ctx.next.is_empty() {
+ out.push_str(" context:\n");
+ for l in ctx.prev {
+ out.push_str(&format!(" - {}\n", l));
+ }
+ for l in ctx.next {
+ out.push_str(&format!(" + {}\n", l));
+ }
+ }
+ out.push('\n');
+ }
+
+ if !failed_projects.is_empty() {
+ out.push_str("FAILED_PROJECTS\n");
+ for p in &failed_projects {
+ out.push_str(&format!(" - {}\n", p));
+ }
+ out.push('\n');
+ }
+
+ out.push_str("DIAGNOSTICS\n");
+ for d in dedup_diags(&diags)
+ .into_iter()
+ .filter(|d| d.severity == Severity::Error)
+ {
+ out.push_str(&d.raw);
+ out.push('\n');
+ }
+ if !succeeded {
+ for d in dedup_diags(&diags)
+ .into_iter()
+ .filter(|d| d.severity == Severity::Warning)
+ {
+ out.push_str(&d.raw);
+ out.push('\n');
+ }
+ }
+ if let Some(br) = build_result {
+ out.push_str(&br);
+ out.push('\n');
+ }
+ for s in &summary {
+ out.push_str(s);
+ out.push('\n');
+ }
+ out.trim_end().to_string()
+}
+
+fn parse_diag_line(line: &str, idx: usize, current_project: Option<&str>) -> Option {
+ // Extract the project path from trailing "[...vcxproj]" when present.
+ let project_from_suffix = line
+ .rfind('[')
+ .and_then(|i| line[i..].strip_prefix('['))
+ .and_then(|rest| rest.strip_suffix(']'))
+ .map(|s| s.trim().to_string());
+ let project = project_from_suffix.or_else(|| current_project.map(str::to_string));
+
+ if let Some(caps) = MSVC_COMPILER_RE.captures(line) {
+ let file = caps.get(1)?.as_str().to_string();
+ let lnum: usize = caps.get(2)?.as_str().parse().ok()?;
+ let kind = caps.get(3)?.as_str();
+ let code = caps.get(4)?.as_str().to_string();
+ let msg = caps.get(5)?.as_str().to_string();
+ let severity = if kind.eq_ignore_ascii_case("warning") {
+ Severity::Warning
+ } else {
+ Severity::Error
+ };
+ let raw = format!("{}({}): {} {}: {}", file, lnum, kind, code, msg);
+ return Some(MsbuildDiag {
+ idx,
+ severity,
+ code,
+ file: Some(file),
+ line: Some(lnum),
+ message: msg,
+ project,
+ raw,
+ });
+ }
+
+ if let Some(caps) = RC_DIAG_RE.captures(line) {
+ let file = caps.get(1)?.as_str().to_string();
+ let lnum: usize = caps.get(2)?.as_str().parse().ok()?;
+ let kind = caps.get(3)?.as_str();
+ let code = caps.get(4)?.as_str().to_string();
+ let msg = caps.get(5)?.as_str().to_string();
+ let severity = if kind.eq_ignore_ascii_case("warning") {
+ Severity::Warning
+ } else {
+ Severity::Error
+ };
+ return Some(MsbuildDiag {
+ idx,
+ severity,
+ code,
+ file: Some(file),
+ line: Some(lnum),
+ message: msg,
+ project,
+ raw: line.to_string(),
+ });
+ }
+
+ if let Some(caps) = MSBUILD_DIAG_RE.captures(line) {
+ let file = caps.get(1)?.as_str().to_string();
+ let lnum: usize = caps.get(2)?.as_str().parse().ok()?;
+ let kind = caps.get(4)?.as_str();
+ let code = caps.get(5)?.as_str().to_string();
+ let mut msg = caps.get(6)?.as_str().to_string();
+ if MSB3073_RE.is_match(&code) {
+ let exit_code = EXIT_CODE_RE
+ .captures(&msg)
+ .and_then(|c| c.get(1))
+ .map(|m| m.as_str().to_string());
+ if let Some(cmd) = extract_msb3073_command(&msg) {
+ msg = cmd;
+ }
+ if let Some(n) = exit_code {
+ msg = format!("{} (exit code {})", msg, n);
+ }
+ }
+ let severity = if kind.eq_ignore_ascii_case("warning") {
+ Severity::Warning
+ } else {
+ Severity::Error
+ };
+ return Some(MsbuildDiag {
+ idx,
+ severity,
+ code,
+ file: Some(file),
+ line: Some(lnum),
+ message: msg,
+ project,
+ raw: line.to_string(),
+ });
+ }
+
+ if let Some(caps) = MSVC_LINKER_RE.captures(line) {
+ let kind = caps.get(2)?.as_str();
+ let code = caps.get(3)?.as_str().to_string();
+ let msg = caps.get(4)?.as_str().to_string();
+ let severity = if kind.eq_ignore_ascii_case("warning") {
+ Severity::Warning
+ } else {
+ Severity::Error
+ };
+ return Some(MsbuildDiag {
+ idx,
+ severity,
+ code,
+ file: None,
+ line: None,
+ message: msg,
+ project,
+ raw: line.to_string(),
+ });
+ }
+
+ if let Some(caps) = MSVC_LINK_TOOL_RE.captures(line) {
+ let kind = caps.get(1)?.as_str();
+ let code = caps.get(2)?.as_str().to_string();
+ let msg = caps.get(3)?.as_str().to_string();
+ let severity = if kind.eq_ignore_ascii_case("warning") {
+ Severity::Warning
+ } else {
+ Severity::Error
+ };
+ return Some(MsbuildDiag {
+ idx,
+ severity,
+ code,
+ file: None,
+ line: None,
+ message: msg,
+ project,
+ raw: line.to_string(),
+ });
+ }
+
+ if MSB3073_RE.is_match(line) {
+ let code = MSB3073_RE
+ .captures(line)
+ .and_then(|c| c.get(1))
+ .map(|m| m.as_str().to_string())
+ .unwrap_or_else(|| "MSB3073".to_string());
+
+ let mut msg = line.to_string();
+ let exit_code = EXIT_CODE_RE
+ .captures(&msg)
+ .and_then(|c| c.get(1))
+ .map(|m| m.as_str().to_string());
+ if let Some(cmd) = extract_msb3073_command(line) {
+ msg = cmd;
+ }
+ if let Some(n) = exit_code {
+ msg = format!("{} (exit code {})", msg, n);
+ }
+
+ return Some(MsbuildDiag {
+ idx,
+ severity: Severity::Error,
+ code,
+ file: None,
+ line: None,
+ message: msg.clone(),
+ project,
+ raw: line.to_string(),
+ });
+ }
+
+ None
+}
+
+fn extract_msb3073_command(msg: &str) -> Option {
+ // Expected shape:
+ // The command "...." exited with code N.
+ // Command body may contain escaped quotes: \"C:\path with spaces\"
+ let start = msg.find("The command \"")? + "The command \"".len();
+ let rest = &msg[start..];
+ let mut out = String::new();
+ let mut escape = false;
+ for (i, ch) in rest.char_indices() {
+ if escape {
+ out.push(ch);
+ escape = false;
+ continue;
+ }
+
+ if ch == '\\' {
+ // Only treat backslash as an escape marker when it escapes a quote or a backslash.
+ // Otherwise it's a real Windows path separator.
+ let next = rest[i + ch.len_utf8()..].chars().next();
+ if matches!(next, Some('"') | Some('\\')) {
+ escape = true;
+ } else {
+ out.push(ch);
+ }
+ continue;
+ }
+
+ if ch == '"' {
+ let after = &rest[i + ch.len_utf8()..];
+ if after.starts_with(" exited with code") {
+ break;
+ }
+ out.push(ch);
+ continue;
+ }
+
+ out.push(ch);
+ }
+ if out.is_empty() {
+ return None;
+ }
+
+ // Normalize MSBuild escaping so paths are readable.
+ // Keep this minimal: this is display output only.
+ let out = out.replace(r#"\""#, r#"""#);
+ Some(out)
+}
+
+fn first_real_error(diags: &[MsbuildDiag]) -> Option {
+ diags.iter().find(|d| d.severity == Severity::Error).cloned()
+}
+
+fn dedup_diags(diags: &[MsbuildDiag]) -> Vec {
+ let mut out: Vec = Vec::new();
+ let mut seen: HashSet = HashSet::new();
+ for d in diags {
+ let key = format!("{}|{}", d.code, d.raw);
+ if !seen.insert(key) {
+ continue;
+ }
+ out.push(d.clone());
+ }
+ out
+}
+
+struct ContextWindow {
+ prev: Vec,
+ next: Vec,
+}
+
+fn extract_context(lines: &[&str], idx: usize, prev_n: usize, next_n: usize) -> ContextWindow {
+ let mut prev = Vec::new();
+ let mut next = Vec::new();
+
+ let mut i = idx;
+ while i > 0 && prev.len() < prev_n {
+ i -= 1;
+ let t = lines[i].trim_end();
+ if t.is_empty() {
+ continue;
+ }
+ if is_msbuild_context_noise(t) {
+ continue;
+ }
+ prev.push(sanitize_context_line(t));
+ }
+ prev.reverse();
+
+ let mut j = idx + 1;
+ while j < lines.len() && next.len() < next_n {
+ let t = lines[j].trim_end();
+ j += 1;
+ if t.is_empty() {
+ continue;
+ }
+ if is_msbuild_context_noise(t) {
+ continue;
+ }
+ next.push(sanitize_context_line(t));
+ }
+
+ ContextWindow { prev, next }
+}
+
+fn is_msbuild_context_noise(line: &str) -> bool {
+ let l = line.trim_start();
+ let lower = l.to_ascii_lowercase();
+ lower.starts_with("project \"")
+ || lower.starts_with("done building project ")
+ || lower.starts_with("build started ")
+ || lower.starts_with("time elapsed ")
+ || lower == "build failed."
+ || lower == "build succeeded."
+}
+
+fn sanitize_context_line(line: &str) -> String {
+ // Common MSBuild suffix noise: " ... [C:\path\Project.vcxproj]"
+ // Keep behavior consistent with MSVC_COMPILER_RE stripping.
+ if line.ends_with(']') && line.contains(".vcxproj") {
+ if let Some(i) = line.rfind(" [") {
+ return line[..i].to_string();
+ }
+ }
+ line.to_string()
+}
+
+fn configuration_summary(args: &[String]) -> String {
+ let solution = args
+ .iter()
+ .find(|a| {
+ !a.starts_with('/')
+ && !a.starts_with('-')
+ && (a.ends_with(".sln")
+ || a.ends_with(".csproj")
+ || a.ends_with(".vcxproj")
+ || a.ends_with(".proj"))
+ })
+ .cloned()
+ .unwrap_or_default();
+
+ let mut config = String::new();
+ let mut platform = String::new();
+ for a in args {
+ if let Some(rest) = a
+ .strip_prefix("/p:Configuration=")
+ .or_else(|| a.strip_prefix("-p:Configuration="))
+ {
+ config = rest.to_string();
+ } else if let Some(rest) = a
+ .strip_prefix("/p:Platform=")
+ .or_else(|| a.strip_prefix("-p:Platform="))
+ {
+ platform = rest.to_string();
+ }
+ }
+
+ let mut parts = Vec::new();
+ if !solution.is_empty() {
+ parts.push(solution);
+ }
+ if !config.is_empty() && !platform.is_empty() {
+ parts.push(format!("{}|{}", config, platform));
+ } else if !config.is_empty() {
+ parts.push(config);
+ }
+ parts.join(" ")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_compiler_error_strips_project_suffix() {
+ let raw = "C:\\src\\main.cpp(42): error C2065: 'foo': undeclared identifier [C:\\proj\\MyProject.vcxproj]\n\
+ Build FAILED.\n\
+ 1 Error(s)\n";
+ let args = vec!["MyProject.sln".to_string()];
+ let out = filter_output(raw, &args);
+ assert!(out.contains("main.cpp(42): error C2065"));
+ assert!(!out.contains("[C:\\proj\\MyProject.vcxproj]"));
+ assert!(out.contains("Build FAILED"));
+ }
+
+ #[test]
+ fn test_linker_error_kept_verbatim() {
+ let raw = "MyProject.lib(module.obj) : error LNK2001: unresolved external symbol \"void __cdecl foo()\"\n\
+ MyOtherDLL.dll : fatal error LNK1120: 3 unresolved externals\n\
+ Build FAILED.\n";
+ let args = vec!["MyProject.sln".to_string()];
+ let out = filter_output(raw, &args);
+ assert!(out.contains("LNK2001"));
+ assert!(out.contains("LNK1120"));
+ assert!(out.contains("MyProject.lib(module.obj)"));
+ }
+
+ #[test]
+ fn test_success_compact() {
+ let raw = "Microsoft (R) Build Engine version 17.8\n\
+ Copyright (C) Microsoft Corporation.\n\
+ \n\
+ Build started 1/1/2025 12:00:00 PM.\n\
+ Project \"MyProject.sln\" on node 1 (Build target(s)).\n\
+ Copying file from x to y\n\
+ Creating directory \"obj\\Debug\"\n\
+ cl.exe /c main.cpp\n\
+ Done Building Project \"MyProject.vcxproj\" (default targets).\n\
+ \n\
+ Build succeeded.\n\
+ 0 Warning(s)\n\
+ 0 Error(s)\n";
+ let args = vec![
+ "MyProject.sln".to_string(),
+ "/p:Configuration=Debug".to_string(),
+ "/p:Platform=Win32".to_string(),
+ ];
+ let out = filter_output(raw, &args);
+ assert!(out.starts_with("msbuild: ok"));
+ assert!(out.contains("MyProject.sln"));
+ assert!(out.contains("Debug|Win32"));
+ }
+
+ #[test]
+ fn test_empty_output() {
+ let args = vec!["MyProject.sln".to_string(), "/t:Build".to_string()];
+ let out = filter_output("", &args);
+ assert!(out.contains("no output captured"));
+ }
+
+ #[test]
+ fn test_fixture_success() {
+ let raw = include_str!("../../../tests/fixtures/cpp/msbuild_success.txt");
+ let args = vec![
+ "MyProject.sln".to_string(),
+ "/p:Configuration=Debug".to_string(),
+ "/p:Platform=Win32".to_string(),
+ ];
+ let out = filter_output(raw, &args);
+ assert!(out.starts_with("msbuild: ok"));
+ assert!(out.contains("Debug|Win32"));
+ }
+
+ #[test]
+ fn test_fixture_compiler_failure() {
+ let raw = include_str!("../../../tests/fixtures/cpp/msbuild_failure_compiler.txt");
+ let args = vec!["MyProject.sln".to_string()];
+ let out = filter_output(raw, &args);
+ assert!(out.contains("C2065"));
+ assert!(out.contains("C2143"));
+ assert!(out.contains("C1004"));
+ assert!(!out.contains("[C:\\src\\MyProject\\MyProject.vcxproj]"));
+ }
+
+ #[test]
+ fn test_fixture_linker_failure() {
+ let raw = include_str!("../../../tests/fixtures/cpp/msbuild_failure_linker.txt");
+ let args = vec!["MyProject.sln".to_string()];
+ let out = filter_output(raw, &args);
+ assert!(out.contains("LNK2001"));
+ assert!(out.contains("LNK1120"));
+ assert!(out.contains("MyProject.lib(util.obj)"));
+ }
+
+ #[test]
+ fn test_fixture_rc_failure() {
+ let raw = include_str!("../../../tests/fixtures/cpp/msbuild_failure_rc.txt");
+ let args = vec!["MyProject.sln".to_string()];
+ let out = filter_output(raw, &args);
+ assert!(out.contains("RC1015"));
+ assert!(out.contains("FIRST_ERROR"));
+ assert!(out.contains("FAILED_PROJECTS"));
+ }
+
+ #[test]
+ fn test_fixture_msb3073_extraction() {
+ let raw = include_str!("../../../tests/fixtures/cpp/msbuild_failure_msb3073.txt");
+ let args = vec!["MyProject.sln".to_string()];
+ let out = filter_output(raw, &args);
+ assert!(out.contains("MSB3073"));
+ assert!(out.contains("exit code 1"));
+ assert!(out.contains("copy /Y"));
+ assert!(out.contains("C:\\path with spaces\\out.dll"));
+ assert!(out.contains("C:\\dest\\bin"));
+ assert!(out.contains("FIRST_ERROR"));
+ }
+
+ #[test]
+ fn test_fixture_msb8012_detection() {
+ let raw = include_str!("../../../tests/fixtures/cpp/msbuild_failure_msb8012.txt");
+ let args = vec!["MyProject.sln".to_string()];
+ let out = filter_output(raw, &args);
+ assert!(out.contains("MSB8012"));
+ assert!(out.contains("TargetPath"));
+ assert!(out.contains("FIRST_ERROR"));
+ }
+
+ #[test]
+ fn test_fixture_empty_link() {
+ let raw = include_str!("../../../tests/fixtures/cpp/msbuild_empty_link.txt");
+ let args = vec!["MyProject.sln".to_string()];
+ let out = filter_output(raw, &args);
+ assert!(out.contains("no output captured"));
+ }
+
+ #[test]
+ fn test_warnings_dropped_on_success() {
+ let raw = "C:\\src\\main.cpp(43): warning C4244: conversion from 'double' to 'int' [C:\\proj\\MyProject.vcxproj]\n\
+ Build succeeded.\n\
+ 1 Warning(s)\n\
+ 0 Error(s)\n";
+ let args = vec!["MyProject.sln".to_string()];
+ let out = filter_output(raw, &args);
+ assert!(out.starts_with("msbuild: ok"));
+ assert!(!out.contains("C4244"));
+ }
+}
diff --git a/src/cmds/cpp/remove_item.rs b/src/cmds/cpp/remove_item.rs
new file mode 100644
index 000000000..d41dc0315
--- /dev/null
+++ b/src/cmds/cpp/remove_item.rs
@@ -0,0 +1,124 @@
+//! Minimal handler for PowerShell `Remove-Item` rewrites.
+//!
+//! Executes the deletion via `pwsh -Command "Remove-Item ..."` if `pwsh` is on
+//! PATH. Falls back to `powershell` (Windows PowerShell 5.1) on Windows. Emits
+//! `ok ` on success, the error line + non-zero exit code on failure.
+
+use crate::core::tracking;
+use anyhow::{Context, Result};
+use std::path::Path;
+use std::process::Command;
+
+pub fn run(args: &[String], verbose: u8) -> Result {
+ let timer = tracking::TimedExecution::start();
+ let raw_cmd = format!("Remove-Item {}", args.join(" "));
+ if verbose > 0 {
+ eprintln!("Running: {}", raw_cmd);
+ }
+
+ let pwsh = locate_pwsh();
+ let mut cmd = Command::new(&pwsh);
+ cmd.arg("-NoProfile").arg("-Command").arg(&raw_cmd);
+
+ let output = cmd
+ .output()
+ .with_context(|| format!("Failed to execute {}", pwsh))?;
+ let exit_code = output.status.code().unwrap_or(1);
+ let raw = format!(
+ "{}{}",
+ String::from_utf8_lossy(&output.stdout),
+ String::from_utf8_lossy(&output.stderr)
+ );
+
+ let display = if exit_code == 0 {
+ let target = first_target(args);
+ let basename = target
+ .map(|t| {
+ Path::new(t)
+ .file_name()
+ .and_then(|s| s.to_str())
+ .unwrap_or(t)
+ .to_string()
+ })
+ .unwrap_or_else(|| "(target)".to_string());
+ format!("ok {}", basename)
+ } else {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ let first_line = stderr.lines().find(|l| !l.trim().is_empty()).unwrap_or("");
+ format!("Remove-Item failed (exit {}): {}", exit_code, first_line.trim())
+ };
+
+ println!("{}", display);
+ timer.track(&raw_cmd, "rtk remove-item", &raw, &display);
+
+ Ok(exit_code)
+}
+
+fn locate_pwsh() -> String {
+ if which::which("pwsh").is_ok() {
+ return "pwsh".to_string();
+ }
+ if cfg!(target_os = "windows") {
+ return "powershell".to_string();
+ }
+ // Last-resort fallback — caller will get a sensible error.
+ "pwsh".to_string()
+}
+
+/// Return the first user-supplied path argument (after stripping flag tokens).
+fn first_target(args: &[String]) -> Option<&str> {
+ let mut iter = args.iter().peekable();
+ while let Some(a) = iter.next() {
+ let lower = a.to_ascii_lowercase();
+ if lower == "-literalpath" || lower == "-path" {
+ if let Some(next) = iter.peek() {
+ return Some(next.as_str());
+ }
+ }
+ if let Some(rest) = a.strip_prefix("-LiteralPath:") {
+ return Some(rest);
+ }
+ if let Some(rest) = a.strip_prefix("-Path:") {
+ return Some(rest);
+ }
+ // Skip flags
+ if a.starts_with('-') {
+ continue;
+ }
+ return Some(a.as_str());
+ }
+ None
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_first_target_literalpath() {
+ let args = vec![
+ "-LiteralPath".to_string(),
+ "D:\\MyProject\\lib\\Debug\\MyLib.obj".to_string(),
+ "-Force".to_string(),
+ ];
+ assert_eq!(first_target(&args), Some("D:\\MyProject\\lib\\Debug\\MyLib.obj"));
+ }
+
+ #[test]
+ fn test_first_target_path() {
+ let args = vec!["-Path".to_string(), ".\\build\\".to_string(), "-Recurse".to_string()];
+ assert_eq!(first_target(&args), Some(".\\build\\"));
+ }
+
+ #[test]
+ fn test_first_target_positional() {
+ let args = vec!["-Force".to_string(), "myfile.txt".to_string()];
+ assert_eq!(first_target(&args), Some("myfile.txt"));
+ }
+
+ #[test]
+ fn test_first_target_colon_form() {
+ let args = vec!["-LiteralPath:C:\\x\\y.obj".to_string(), "-Force".to_string()];
+ assert_eq!(first_target(&args), Some("C:\\x\\y.obj"));
+ }
+}
diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs
index 8ac6a5aaf..01b4055cb 100644
--- a/src/cmds/git/git.rs
+++ b/src/cmds/git/git.rs
@@ -103,6 +103,70 @@ 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);
+ }
+ // Filename with extension (README.md) - treat as path if it exists.
+ // This is a safe middle-ground between "bare word" (main) and a ref.
+ if 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,
@@ -112,7 +176,7 @@ fn run_diff(
let timer = tracking::TimedExecution::start();
// Re-insert `--` when clap's trailing_var_arg consumed it (issue #1215)
- let args = &args_utils::restore_double_dash(args);
+ let args = normalize_diff_args(&args_utils::restore_double_dash(args));
// Check if user wants stat output
let wants_stat = args
@@ -126,7 +190,7 @@ fn run_diff(
// User wants stat or explicitly no compacting - pass through directly
let mut cmd = git_cmd(global_args);
cmd.arg("diff");
- for arg in args {
+ for arg in &args {
if arg == "--no-compact" {
continue; // RTK flag, not a git flag
}
@@ -156,7 +220,7 @@ fn run_diff(
let mut cmd = git_cmd(global_args);
cmd.arg("diff").arg("--stat");
- for arg in args {
+ for arg in &args {
cmd.arg(arg);
}
@@ -185,7 +249,7 @@ fn run_diff(
// Now get actual diff but compact it
let mut diff_cmd = git_cmd(global_args);
diff_cmd.arg("diff");
- for arg in args {
+ for arg in &args {
diff_cmd.arg(arg);
}
@@ -1961,6 +2025,196 @@ 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);
+ }
+
+ /// Baseline: `--` already present with multiple pathspecs → no-op, args unchanged.
+ #[test]
+ fn test_normalize_diff_args_noop_when_separator_present_multiple_paths() {
+ let args = vec![
+ "--".to_string(),
+ "README.md".to_string(),
+ "src/cmds/system/README.md".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()
+ ]
+ );
+ }
+
+ /// Ref with explicit separator before a filename-with-extension → no-op, args unchanged.
+ #[test]
+ fn test_normalize_diff_args_noop_ref_then_separator_then_filename() {
+ let args = vec!["HEAD".to_string(), "--".to_string(), "README.md".to_string()];
+ assert_eq!(normalize_diff_args_impl(&args, exists_mock(&[])), args);
+ }
+
+ /// 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()
+ ]
+ );
+ }
+
+ /// Flag then filename-with-extension pathspec → inject separator after flag.
+ #[test]
+ fn test_normalize_diff_args_inject_after_flag_for_filename_with_extension() {
+ let args = vec!["--name-only".to_string(), "README.md".to_string()];
+ assert_eq!(
+ normalize_diff_args_impl(&args, exists_mock(&["README.md"])),
+ vec![
+ "--name-only".to_string(),
+ "--".to_string(),
+ "README.md".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"
+ );
+ }
+
+ /// Filename with extension that exists on disk → inject `--`.
+ #[test]
+ fn test_normalize_diff_args_inject_for_filename_with_extension() {
+ let args = vec!["README.md".to_string()];
+ assert_eq!(
+ normalize_diff_args_impl(&args, exists_mock(&["README.md"])),
+ vec!["--".to_string(), "README.md".to_string()]
+ );
+ }
+
+ /// Multiple existing paths (including a filename-with-extension) → inject once before first path.
+ #[test]
+ fn test_normalize_diff_args_inject_for_multiple_existing_paths() {
+ let args = vec![
+ "README.md".to_string(),
+ "src/cmds/system/README.md".to_string(),
+ ];
+ assert_eq!(
+ normalize_diff_args_impl(
+ &args,
+ exists_mock(&["README.md", "src/cmds/system/README.md"]),
+ ),
+ vec![
+ "--".to_string(),
+ "README.md".to_string(),
+ "src/cmds/system/README.md".to_string()
+ ]
+ );
+ }
+
#[test]
fn test_is_blob_show_arg() {
assert!(is_blob_show_arg("develop:modules/pairs_backtest.py"));
diff --git a/src/cmds/mod.rs b/src/cmds/mod.rs
index d064182b3..9f7e95ae8 100644
--- a/src/cmds/mod.rs
+++ b/src/cmds/mod.rs
@@ -1,6 +1,7 @@
//! Command filter modules organized by language ecosystem.
pub mod cloud;
+pub mod cpp;
pub mod dotnet;
pub mod git;
pub mod go;
diff --git a/src/cmds/system/README.md b/src/cmds/system/README.md
index 55de28912..8898e1429 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.
+- `grep_cmd.rs` reads `core/config` for `limits.grep_max_results` and `limits.grep_max_per_file`. Flags: `--files-only`, `--count-by-file`, `--top-files `, `--max-matches`, `--max-per-file`, `--max-line-chars`, `--full-lines`, `--all`, `--agent-safe`, `--json`. Env: `RTK_AGENT_SAFE=1` behaves like `--agent-safe` (grep only). 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/gci_cmd.rs b/src/cmds/system/gci_cmd.rs
new file mode 100644
index 000000000..1feeaa16d
--- /dev/null
+++ b/src/cmds/system/gci_cmd.rs
@@ -0,0 +1,209 @@
+//! PowerShell Get-ChildItem / gci / dir compatible (subset) file listing with compact output.
+
+use crate::core::tracking;
+use anyhow::Result;
+use ignore::WalkBuilder;
+use std::fs;
+use std::path::{Path, PathBuf};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum GciKind {
+ File,
+ Directory,
+ Any,
+}
+
+#[derive(Debug)]
+pub struct GciArgs {
+ pub path: PathBuf,
+ pub recurse: bool,
+ pub force: bool,
+ pub kind: GciKind,
+ pub filter: Option,
+ pub include: Vec,
+ pub max: usize,
+ pub select_full_name: bool,
+ pub select_last_write_time: bool,
+ pub select_length: bool,
+}
+
+impl Default for GciArgs {
+ fn default() -> Self {
+ Self {
+ path: PathBuf::from("."),
+ recurse: false,
+ force: false,
+ kind: GciKind::Any,
+ filter: None,
+ include: Vec::new(),
+ max: 50,
+ select_full_name: true,
+ select_last_write_time: false,
+ select_length: false,
+ }
+ }
+}
+
+pub fn run(args: &GciArgs, verbose: u8) -> Result<()> {
+ let timer = tracking::TimedExecution::start();
+ if verbose > 0 {
+ eprintln!("gci: {} (recurse={})", args.path.display(), args.recurse);
+ }
+
+ let mut builder = WalkBuilder::new(&args.path);
+ builder.git_ignore(true).git_exclude(true).hidden(!args.force);
+ if !args.recurse {
+ builder.max_depth(Some(1));
+ }
+
+ let filter = args.filter.as_deref();
+ let include = &args.include;
+
+ let mut matches: Vec = Vec::new();
+ for dent in builder.build() {
+ let dent = match dent {
+ Ok(d) => d,
+ Err(_) => continue,
+ };
+ let p = dent.path();
+ if p == Path::new("") {
+ continue;
+ }
+
+ let ft = match dent.file_type() {
+ Some(t) => t,
+ None => continue,
+ };
+ match args.kind {
+ GciKind::File if !ft.is_file() => continue,
+ GciKind::Directory if !ft.is_dir() => continue,
+ _ => {}
+ }
+
+ let name = match p.file_name().and_then(|s| s.to_str()) {
+ Some(n) => n,
+ None => continue,
+ };
+
+ if let Some(f) = filter {
+ if !glob_match(f, name) {
+ continue;
+ }
+ }
+
+ if !include.is_empty() && !include.iter().any(|pat| glob_match(pat, name)) {
+ continue;
+ }
+
+ matches.push(p.to_path_buf());
+ }
+
+ matches.sort();
+ let total = matches.len();
+
+ let mut out = String::new();
+ out.push_str(&format!("{} matches\n\n", total));
+
+ let shown = std::cmp::min(total, args.max);
+ for p in matches.iter().take(shown) {
+ if args.select_last_write_time || args.select_length {
+ let meta = fs::metadata(p).ok();
+ let len = meta.as_ref().map(|m| m.len());
+ let mtime = meta.as_ref().and_then(|m| m.modified().ok());
+
+ let mut parts = Vec::new();
+ if args.select_full_name {
+ parts.push(p.display().to_string());
+ }
+ if args.select_length {
+ parts.push(format!(
+ "len={}",
+ len.map(|v| v.to_string()).unwrap_or_else(|| "?".into())
+ ));
+ }
+ if args.select_last_write_time {
+ parts.push(format!(
+ "mtime={}",
+ mtime.map(format_system_time).unwrap_or_else(|| "?".into())
+ ));
+ }
+ out.push_str(&parts.join(" "));
+ out.push('\n');
+ } else {
+ out.push_str(&p.display().to_string());
+ out.push('\n');
+ }
+ }
+
+ if total > shown {
+ out.push_str(&format!("[+{} more]\n", total - shown));
+ }
+
+ print!("{}", out);
+ timer.track(
+ &format!("gci {}", args.path.display()),
+ "rtk gci",
+ "",
+ &out,
+ );
+ Ok(())
+}
+
+pub fn parse_select_list(spec: &str, out: &mut GciArgs) {
+ // Accept: "FullName,LastWriteTime,Length" (powershell-ish).
+ for raw in spec.split(',') {
+ let s = raw.trim().to_ascii_lowercase();
+ match s.as_str() {
+ "fullname" => out.select_full_name = true,
+ "lastwritetime" => out.select_last_write_time = true,
+ "length" => out.select_length = true,
+ _ => {}
+ }
+ }
+}
+
+fn format_system_time(t: SystemTime) -> String {
+ // Avoid chrono dependency; emit seconds since epoch for compactness.
+ match t.duration_since(UNIX_EPOCH) {
+ Ok(d) => format!("{}s", d.as_secs()),
+ Err(_) => "?".into(),
+ }
+}
+
+fn glob_match(pattern: &str, name: &str) -> bool {
+ let pat = pattern.to_ascii_lowercase();
+ let nm = name.to_ascii_lowercase();
+ glob_match_inner(pat.as_bytes(), nm.as_bytes())
+}
+
+fn glob_match_inner(pat: &[u8], name: &[u8]) -> bool {
+ match (pat.first(), name.first()) {
+ (None, None) => true,
+ (Some(b'*'), _) => {
+ glob_match_inner(&pat[1..], name)
+ || (!name.is_empty() && glob_match_inner(pat, &name[1..]))
+ }
+ (Some(b'?'), Some(_)) => glob_match_inner(&pat[1..], &name[1..]),
+ (Some(&p), Some(&n)) if p == n => glob_match_inner(&pat[1..], &name[1..]),
+ _ => false,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_glob_match_case_insensitive() {
+ assert!(glob_match("*.CPP", "api_win.cpp"));
+ assert!(glob_match("API_WIN.OBJ", "api_win.obj"));
+ }
+
+ #[test]
+ fn test_glob_match_wildcards() {
+ assert!(glob_match("a?c.txt", "abc.txt"));
+ assert!(glob_match("a*c.txt", "abbbbbc.txt"));
+ assert!(!glob_match("a?c.txt", "ac.txt"));
+ }
+}
diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs
index 3f7d9a327..6e2acad5d 100644
--- a/src/cmds/system/grep_cmd.rs
+++ b/src/cmds/system/grep_cmd.rs
@@ -6,16 +6,51 @@ use crate::core::tracking;
use crate::core::utils::resolved_command;
use anyhow::{Context, Result};
use regex::Regex;
+use serde::Serialize;
use std::collections::HashMap;
+#[derive(Clone, Debug)]
+struct GrepRenderOptions {
+ max_line_chars: Option,
+ max_matches: Option,
+ max_per_file: Option,
+ uncapped: bool,
+ files_only: bool,
+ count_by_file: bool,
+ agent_safe: bool,
+ summary_enabled: bool,
+ context_only: bool,
+}
+
+#[derive(Clone, Debug, Default)]
+#[allow(dead_code)]
+struct GrepRenderStats {
+ total_matches: usize,
+ files_matched: usize,
+ shown: usize,
+ omitted_total: usize,
+ omitted_per_file: usize,
+ clipped_lines: usize,
+ printed_summary: bool,
+}
+
#[allow(clippy::too_many_arguments)]
pub fn run(
pattern: &str,
path: &str,
- max_line_len: usize,
- max_results: usize,
+ max_line_chars: Option,
+ max_matches: Option,
+ max_per_file: Option,
+ uncapped: bool,
+ files_only: bool,
+ count_by_file: bool,
+ agent_safe: bool,
+ summary_enabled: bool,
+ top_files: Option,
+ json: bool,
context_only: bool,
file_type: Option<&str>,
+ fixed: bool,
extra_args: &[String],
verbose: u8,
) -> Result {
@@ -25,42 +60,39 @@ pub fn run(
eprintln!("grep: '{}' in {}", pattern, path);
}
- // 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
- // 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", &rg_pattern, path]);
-
- if let Some(ft) = file_type {
- 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);
- }
+ rg_cmd.args(build_rg_args(
+ pattern,
+ path,
+ file_type,
+ fixed,
+ extra_args,
+ ));
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 -rnHZ.
- grep_cmd.args(["-rnHZ", pattern, path]).args(extra_args);
+ grep_cmd.arg("-rnHZ");
+ if fixed {
+ grep_cmd.arg("-F");
+ }
+ grep_cmd.args(extra_args);
+ grep_cmd.args([pattern, path]);
exec_capture(&mut grep_cmd)
})
.context("grep/rg failed")?;
+ if result.exit_code == 2 && !fixed && !result.stderr.trim().is_empty() {
+ let s = result.stderr.to_lowercase();
+ if s.contains("regex parse error") || s.contains("error parsing regex") {
+ eprintln!("rtk grep: regex parse error (hint: try `rtk grep --fixed ...`)");
+ }
+ }
+
// Passthrough output flags that produce output that is already small.
- if has_format_flag(extra_args) {
+ // In `--json` mode, always emit JSON (no human text), even for format flags.
+ if has_format_flag(extra_args) && !json {
print!("{}", result.stdout);
if !result.stderr.is_empty() {
eprint!("{}", result.stderr.trim());
@@ -88,76 +120,543 @@ pub fn run(
eprintln!("{}", result.stderr.trim());
}
let msg = format!("0 matches for '{}'", pattern);
- println!("{}", msg);
+ if json {
+ let out = GrepJsonOutput::no_matches(pattern, files_only, count_by_file, top_files);
+ println!("{}", serde_json::to_string(&out)?);
+ } else {
+ println!("{}", msg);
+ }
timer.track(
&format!("grep -rn '{}' {}", pattern, path),
"rtk grep",
&raw_output,
- &msg,
+ if json { "" } else { &msg },
);
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 (rtk_output, stats) = render_grep_output(
+ pattern,
+ &result.stdout,
+ &GrepRenderOptions {
+ max_line_chars,
+ max_matches,
+ max_per_file,
+ uncapped,
+ files_only,
+ count_by_file,
+ agent_safe,
+ summary_enabled,
+ context_only,
+ },
+ top_files,
+ json,
+ );
- let context_re = if context_only {
- Regex::new(&format!("(?i).{{0,20}}{}.*", regex::escape(pattern))).ok()
- } else {
- None
- };
+ print!("{}", rtk_output);
+ timer.track(
+ &format!("grep -rn '{}' {}", pattern, path),
+ "rtk grep",
+ &raw_output,
+ &rtk_output,
+ );
+
+ if json && stats.printed_summary {
+ // In JSON mode, tracking output is the JSON itself; ensure no extra text sneaks in.
+ }
+
+ Ok(exit_code)
+}
- let mut by_file: HashMap> = HashMap::new();
- for line in result.stdout.lines() {
+fn render_grep_output(
+ pattern: &str,
+ stdout: &str,
+ opts: &GrepRenderOptions,
+ top_files: Option,
+ json: bool,
+) -> (String, GrepRenderStats) {
+ // Filter: group by file, optionally cap/truncate, render in deterministic order.
+ // Output uses `file:line:content` so AI agents can parse it.
+ let mut by_file_raw: HashMap> = HashMap::new();
+ for line in stdout.lines() {
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));
+ by_file_raw.entry(file).or_default().push((line_num, content));
+ }
+ let total_matches: usize = by_file_raw.values().map(|v| v.len()).sum();
+
+ if opts.files_only {
+ if json {
+ let mut rows: Vec<(usize, String)> = by_file_raw
+ .iter()
+ .map(|(file, matches)| (matches.len(), compact_path(file)))
+ .collect();
+ rows.sort_by(|(a_cnt, a_file), (b_cnt, b_file)| {
+ b_cnt.cmp(a_cnt).then_with(|| a_file.cmp(b_file))
+ });
+ let out = GrepJsonOutput::file_counts(pattern, total_matches, by_file_raw.len(), &rows);
+ return (
+ format!("{}\n", serde_json::to_string(&out).unwrap_or_else(|_| "{}".to_string())),
+ GrepRenderStats {
+ total_matches,
+ files_matched: by_file_raw.len(),
+ shown: rows.len(),
+ printed_summary: true,
+ ..Default::default()
+ },
+ );
+ } else {
+ let mut files: Vec<&String> = by_file_raw.keys().collect();
+ files.sort();
+ let mut out = String::new();
+ for f in files {
+ out.push_str(f);
+ out.push('\n');
+ }
+ return (
+ out,
+ GrepRenderStats {
+ total_matches,
+ files_matched: by_file_raw.len(),
+ shown: by_file_raw.len(),
+ ..Default::default()
+ },
+ );
+ }
}
+ if opts.count_by_file {
+ let mut rows: Vec<(usize, &String)> = by_file_raw
+ .iter()
+ .map(|(file, matches)| (matches.len(), file))
+ .collect();
+ rows.sort_by(|(a_cnt, a_file), (b_cnt, b_file)| {
+ b_cnt.cmp(a_cnt).then_with(|| a_file.cmp(b_file))
+ });
+
+ if json {
+ let out_rows: Vec<(usize, String)> = rows
+ .into_iter()
+ .map(|(cnt, file)| (cnt, compact_path(file)))
+ .collect();
+ let out =
+ GrepJsonOutput::file_counts(pattern, total_matches, by_file_raw.len(), &out_rows);
+ return (
+ format!("{}\n", serde_json::to_string(&out).unwrap_or_else(|_| "{}".to_string())),
+ GrepRenderStats {
+ total_matches,
+ files_matched: by_file_raw.len(),
+ shown: out_rows.len(),
+ printed_summary: true,
+ ..Default::default()
+ },
+ );
+ } else {
+ let mut out = String::new();
+ for (cnt, file) in rows {
+ out.push_str(&format!("{} {}\n", cnt, file));
+ }
+ return (
+ out,
+ GrepRenderStats {
+ total_matches,
+ files_matched: by_file_raw.len(),
+ shown: by_file_raw.len(),
+ ..Default::default()
+ },
+ );
+ }
+ }
+
+ if let Some(n) = top_files {
+ let mut rows: Vec<(usize, &String)> = by_file_raw
+ .iter()
+ .map(|(file, matches)| (matches.len(), file))
+ .collect();
+ rows.sort_by(|(a_cnt, a_file), (b_cnt, b_file)| {
+ b_cnt.cmp(a_cnt).then_with(|| a_file.cmp(b_file))
+ });
+
+ let mut out_files: Vec<(usize, String)> = Vec::new();
+ for (cnt, file) in rows.into_iter().take(n) {
+ out_files.push((cnt, compact_path(file)));
+ }
+
+ if json {
+ let out = GrepJsonOutput::top_files(
+ pattern,
+ total_matches,
+ by_file_raw.len(),
+ n,
+ &out_files,
+ );
+ return (
+ format!("{}\n", serde_json::to_string(&out).unwrap_or_else(|_| "{}".to_string())),
+ GrepRenderStats {
+ total_matches,
+ files_matched: by_file_raw.len(),
+ shown: out_files.len(),
+ printed_summary: true,
+ ..Default::default()
+ },
+ );
+ }
+
+ let mut out = String::new();
+ out.push_str(&format!("{} matches in {} files\n\n", total_matches, by_file_raw.len()));
+ for (cnt, file) in &out_files {
+ out.push_str(&format!("{} {}\n", cnt, file));
+ }
+ return (
+ out,
+ GrepRenderStats {
+ total_matches,
+ files_matched: by_file_raw.len(),
+ shown: out_files.len(),
+ ..Default::default()
+ },
+ );
+ }
+
+ let context_re = if opts.context_only {
+ Regex::new(&format!("(?i).{{0,20}}{}.*", regex::escape(pattern))).ok()
+ } else {
+ None
+ };
+
+ let effective_per_file = if opts.uncapped {
+ None
+ } else {
+ Some(opts.max_per_file.unwrap_or(config::limits().grep_max_per_file))
+ };
+ let effective_total = if opts.uncapped { None } else { opts.max_matches };
+ let effective_line_chars = opts.max_line_chars;
+
let mut rtk_output = String::new();
rtk_output.push_str(&format!(
"{} matches in {} files:\n\n",
total_matches,
- by_file.len()
+ by_file_raw.len()
));
- let mut shown = 0;
- let mut files: Vec<_> = by_file.iter().collect();
+ let mut shown = 0usize;
+ let mut omitted_total = 0usize;
+ let mut omitted_per_file = 0usize;
+ let mut clipped_lines = 0usize;
+ let mut first_displayed: Option<(String, usize)> = None;
+
+ let mut files: Vec<_> = by_file_raw.iter().collect();
files.sort_by_key(|(f, _)| *f);
- let per_file = config::limits().grep_max_per_file;
for (file, matches) in files {
- if shown >= max_results {
- break;
+ if let Some(total_cap) = effective_total {
+ if shown >= total_cap {
+ omitted_total += matches.len();
+ continue;
+ }
}
let file_display = compact_path(file);
- for (line_num, content) in matches.iter().take(per_file) {
- if shown >= max_results {
- break;
+ let mut used_in_file = 0usize;
+ for (line_num, content) in matches.iter() {
+ if let Some(total_cap) = effective_total {
+ if shown >= total_cap {
+ omitted_total += 1;
+ continue;
+ }
+ }
+
+ if let Some(per_file_cap) = effective_per_file {
+ if used_in_file >= per_file_cap {
+ omitted_per_file += 1;
+ continue;
+ }
+ }
+
+ let cleaned = if let Some(max_len) = effective_line_chars {
+ let s = clean_line(content, max_len, context_re.as_ref(), pattern);
+ if s.trim().chars().count() < content.trim().chars().count() {
+ clipped_lines += 1;
+ }
+ s
+ } else {
+ content.trim().to_string()
+ };
+
+ rtk_output.push_str(&format!("{}:{}:{}\n", file_display, line_num, cleaned));
+ if first_displayed.is_none() {
+ first_displayed = Some((file_display.clone(), *line_num));
}
- rtk_output.push_str(&format!("{}:{}:{}\n", file_display, line_num, content));
shown += 1;
+ used_in_file += 1;
}
}
- if total_matches > shown {
+ // Legacy overflow marker: keep `[+N more]` for uncapped mode.
+ if effective_total.is_none() && (total_matches > shown) {
rtk_output.push_str(&format!("[+{} more]\n", total_matches - shown));
}
- print!("{}", rtk_output);
- timer.track(
- &format!("grep -rn '{}' {}", pattern, path),
- "rtk grep",
- &raw_output,
- &rtk_output,
- );
+ // Summary only when explicitly enabled (agent-safe or explicit new flags) AND
+ // there was actual omission/clipping, or agent-safe was used.
+ let print_summary = opts.summary_enabled
+ && (opts.agent_safe || clipped_lines > 0 || omitted_total > 0 || omitted_per_file > 0);
+ let hints = build_hints(pattern, first_displayed.as_ref());
- Ok(exit_code)
+ if json {
+ let out = GrepJsonOutput::normal(
+ pattern,
+ total_matches,
+ by_file_raw.len(),
+ shown,
+ omitted_total,
+ omitted_per_file,
+ clipped_lines,
+ &by_file_raw,
+ &hints,
+ effective_total,
+ effective_per_file,
+ effective_line_chars,
+ );
+ return (
+ format!("{}\n", serde_json::to_string(&out).unwrap_or_else(|_| "{}".to_string())),
+ GrepRenderStats {
+ total_matches,
+ files_matched: by_file_raw.len(),
+ shown,
+ omitted_total,
+ omitted_per_file,
+ clipped_lines,
+ printed_summary: true,
+ },
+ );
+ }
+
+ if print_summary {
+ rtk_output.push('\n');
+ rtk_output.push_str(&format!(
+ "summary: total={} files={} shown={} omitted_total={} omitted_per_file={} clipped_lines={}\n",
+ total_matches,
+ by_file_raw.len(),
+ shown,
+ omitted_total,
+ omitted_per_file,
+ clipped_lines
+ ));
+ rtk_output.push_str("hints:\n");
+ for h in &hints {
+ rtk_output.push_str(&format!(" {}\n", h));
+ }
+ }
+
+ (
+ rtk_output,
+ GrepRenderStats {
+ total_matches,
+ files_matched: by_file_raw.len(),
+ shown,
+ omitted_total,
+ omitted_per_file,
+ clipped_lines,
+ printed_summary: print_summary,
+ },
+ )
+}
+
+fn build_hints(pattern: &str, first_displayed: Option<&(String, usize)>) -> Vec {
+ let mut hints = vec![
+ format!("rtk grep \"{}\" --files-only", pattern),
+ format!("rtk grep \"{}\" --count-by-file", pattern),
+ format!("rtk grep \"{}\" --agent-safe --max-matches 200", pattern),
+ ];
+ if let Some((path, line)) = first_displayed {
+ let start = line.saturating_sub(5).max(1);
+ let end = line + 5;
+ hints.push(format!("rtk read \"{}\" --lines {}:{}", path, start, end));
+ } else {
+ hints.push("rtk read \"\" --lines ".to_string());
+ }
+ hints
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct GrepJsonMatch {
+ line: usize,
+ text: String,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct GrepJsonFile {
+ path: String,
+ count: usize,
+ #[serde(skip_serializing_if = "Vec::is_empty")]
+ matches: Vec,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct GrepJsonOutput {
+ pattern: String,
+ total_matches: usize,
+ files_matched: usize,
+ displayed_matches: usize,
+ omitted_total: usize,
+ omitted_per_file: usize,
+ clipped_lines: usize,
+ truncated: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ top_files: Option,
+ files: Vec,
+ hints: Vec,
+}
+
+impl GrepJsonOutput {
+ fn no_matches(pattern: &str, files_only: bool, count_by_file: bool, top_files: Option) -> Self {
+ let _ = (files_only, count_by_file);
+ Self {
+ pattern: pattern.to_string(),
+ total_matches: 0,
+ files_matched: 0,
+ displayed_matches: 0,
+ omitted_total: 0,
+ omitted_per_file: 0,
+ clipped_lines: 0,
+ truncated: false,
+ top_files,
+ files: Vec::new(),
+ hints: build_hints(pattern, None),
+ }
+ }
+
+ fn file_counts(pattern: &str, total_matches: usize, files_matched: usize, rows: &[(usize, String)]) -> Self {
+ Self {
+ pattern: pattern.to_string(),
+ total_matches,
+ files_matched,
+ displayed_matches: 0,
+ omitted_total: 0,
+ omitted_per_file: 0,
+ clipped_lines: 0,
+ truncated: false,
+ top_files: None,
+ files: rows
+ .iter()
+ .map(|(cnt, path)| GrepJsonFile {
+ path: path.clone(),
+ count: *cnt,
+ matches: Vec::new(),
+ })
+ .collect(),
+ hints: build_hints(pattern, None),
+ }
+ }
+
+ fn top_files(
+ pattern: &str,
+ total_matches: usize,
+ files_matched: usize,
+ requested: usize,
+ rows: &[(usize, String)],
+ ) -> Self {
+ Self {
+ pattern: pattern.to_string(),
+ total_matches,
+ files_matched,
+ displayed_matches: 0,
+ omitted_total: 0,
+ omitted_per_file: 0,
+ clipped_lines: 0,
+ truncated: false,
+ top_files: Some(requested),
+ files: rows
+ .iter()
+ .map(|(cnt, path)| GrepJsonFile {
+ path: path.clone(),
+ count: *cnt,
+ matches: Vec::new(),
+ })
+ .collect(),
+ hints: build_hints(pattern, None),
+ }
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ fn normal(
+ pattern: &str,
+ total_matches: usize,
+ files_matched: usize,
+ _displayed_matches: usize,
+ omitted_total: usize,
+ omitted_per_file: usize,
+ clipped_lines: usize,
+ by_file_raw: &HashMap>,
+ hints: &[String],
+ effective_total: Option,
+ effective_per_file: Option,
+ effective_line_chars: Option,
+ ) -> Self {
+ let truncated = omitted_total > 0 || omitted_per_file > 0;
+ let mut files: Vec<(&String, &Vec<(usize, &str)>)> = by_file_raw.iter().collect();
+ files.sort_by_key(|(f, _)| *f);
+
+ let mut current_count = 0usize;
+ let mut out_files: Vec = Vec::new();
+ for (file, matches) in files {
+ if let Some(total_cap) = effective_total {
+ if current_count >= total_cap {
+ break;
+ }
+ }
+ let mut out_matches: Vec = Vec::new();
+ for (used_in_file, (line, content)) in matches.iter().enumerate() {
+ if let Some(total_cap) = effective_total {
+ if current_count >= total_cap {
+ break;
+ }
+ }
+ if let Some(per_file_cap) = effective_per_file {
+ if used_in_file >= per_file_cap {
+ break;
+ }
+ }
+
+ let text = if let Some(max_len) = effective_line_chars {
+ clean_line(content, max_len, None, pattern)
+ } else {
+ content.trim().to_string()
+ };
+ out_matches.push(GrepJsonMatch { line: *line, text });
+ current_count += 1;
+ }
+
+ // In normal JSON mode, avoid emitting empty file entries that can be
+ // created when we early-break due to a total cap.
+ if !out_matches.is_empty() {
+ out_files.push(GrepJsonFile {
+ path: compact_path(file),
+ count: matches.len(),
+ matches: out_matches,
+ });
+ }
+ }
+
+ Self {
+ pattern: pattern.to_string(),
+ total_matches,
+ files_matched,
+ displayed_matches: current_count,
+ omitted_total,
+ omitted_per_file,
+ clipped_lines,
+ truncated,
+ top_files: None,
+ files: out_files,
+ hints: hints.to_vec(),
+ }
+ }
}
/// Parses a single rg/grep match line of the form `file\0line_number:content`.
@@ -182,6 +681,55 @@ fn parse_match_line(line: &str) -> Option<(String, usize, &str)> {
})
}
+fn build_rg_args(
+ pattern: &str,
+ path: &str,
+ file_type: Option<&str>,
+ fixed: bool,
+ extra_args: &[String],
+) -> Vec {
+ // Regex mode: convert BRE alternation \| → | for rg (which uses PCRE-style regex)
+ let rg_pattern = if fixed {
+ pattern.to_string()
+ } else {
+ pattern.replace(r"\|", "|")
+ };
+
+ // --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.
+ let mut args = vec![
+ // -n: include line numbers.
+ // -H: always emit the filename.
+ // -0: NUL-separate filename from `line:content` for unambiguous parsing.
+ "-nH0".to_string(),
+ "--no-heading".to_string(),
+ "--no-ignore-vcs".to_string(),
+ ];
+ if fixed {
+ args.push("-F".to_string());
+ }
+ if let Some(ft) = file_type {
+ args.push("--type".to_string());
+ args.push(ft.to_string());
+ }
+
+ // Insert extra args before pattern/path so flag ordering matches rg expectations.
+ 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;
+ }
+ args.push(arg.clone());
+ }
+
+ args.push(rg_pattern);
+ args.push(path.to_string());
+
+ args
+}
+
fn has_format_flag(extra_args: &[String]) -> bool {
extra_args.iter().any(|arg| {
matches!(
@@ -202,44 +750,93 @@ fn has_format_flag(extra_args: &[String]) -> bool {
fn clean_line(line: &str, max_len: usize, context_re: Option<&Regex>, pattern: &str) -> String {
let trimmed = line.trim();
+ if max_len == 0 {
+ return String::new();
+ }
+
if let Some(re) = context_re {
if let Some(m) = re.find(trimmed) {
let matched = m.as_str();
- if matched.len() <= max_len {
+ if matched.chars().count() <= max_len {
return matched.to_string();
}
}
}
- if trimmed.len() <= max_len {
+ if trimmed.chars().count() <= max_len {
trimmed.to_string()
} else {
+ if max_len <= 3 {
+ return trimmed.chars().take(max_len).collect();
+ }
+ if max_len <= 6 {
+ let t: String = trimmed.chars().take(max_len - 3).collect();
+ return format!("{}...", t);
+ }
+
let lower = trimmed.to_lowercase();
let pattern_lower = pattern.to_lowercase();
- if let Some(pos) = lower.find(&pattern_lower) {
- let char_pos = lower[..pos].chars().count();
+ if lower.contains(&pattern_lower) {
let chars: Vec = trimmed.chars().collect();
+ let lower_chars: Vec = lower.chars().collect();
+ let pat_chars: Vec = pattern_lower.chars().collect();
+
+ // Find match start/end in char indices (not bytes) so we don't break UTF-8.
+ let mut match_start = 0usize;
+ let mut match_end = 0usize;
+ 'outer: for i in 0..=lower_chars.len().saturating_sub(pat_chars.len()) {
+ for j in 0..pat_chars.len() {
+ if lower_chars[i + j] != pat_chars[j] {
+ continue 'outer;
+ }
+ }
+ match_start = i;
+ match_end = i + pat_chars.len();
+ break;
+ }
+
let char_len = chars.len();
+ if match_end <= match_start || match_end > char_len {
+ let t: String = trimmed.chars().take(max_len.saturating_sub(3)).collect();
+ return format!("{}...", t);
+ }
- let start = char_pos.saturating_sub(max_len / 3);
- let end = (start + max_len).min(char_len);
- let start = if end == char_len {
- end.saturating_sub(max_len)
- } else {
- start
- };
+ // Reserve room for prefix/suffix + ellipses so match stays visible.
+ let ellipses = 3usize;
+ let remaining = max_len.saturating_sub(ellipses * 2);
+ let match_len = match_end - match_start;
+ if remaining <= match_len + 2 {
+ // Not enough room for context; show match-centered slice.
+ let start = match_start.saturating_sub(1);
+ let end = (start + remaining).min(char_len);
+ let slice: String = chars[start..end].iter().collect();
+ return format!("...{}...", slice);
+ }
- let slice: String = chars[start..end].iter().collect();
- if start > 0 && end < char_len {
- format!("...{}...", slice)
- } else if start > 0 {
- format!("...{}", slice)
- } else {
- format!("{}...", slice)
+ let context_budget = remaining - match_len;
+ let prefix_budget = context_budget / 2;
+ let suffix_budget = context_budget - prefix_budget;
+
+ let prefix_start = match_start.saturating_sub(prefix_budget);
+ let prefix = &chars[prefix_start..match_start];
+ let matched = &chars[match_start..match_end];
+ let suffix_end = (match_end + suffix_budget).min(char_len);
+ let suffix = &chars[match_end..suffix_end];
+
+ let mut out = String::new();
+ if prefix_start > 0 {
+ out.push_str("...");
}
+ out.push_str(&prefix.iter().collect::());
+ out.push_str(&matched.iter().collect::());
+ out.push_str(&suffix.iter().collect::());
+ if suffix_end < char_len {
+ out.push_str("...");
+ }
+ out
} else {
- let t: String = trimmed.chars().take(max_len - 3).collect();
+ let t: String = trimmed.chars().take(max_len.saturating_sub(3)).collect();
format!("{}...", t)
}
}
@@ -266,6 +863,7 @@ fn compact_path(path: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
+ use serde_json::Value;
#[test]
fn test_clean_line() {
@@ -299,6 +897,53 @@ mod tests {
assert!(!cleaned.is_empty());
}
+ #[test]
+ fn test_clean_line_utf8_croatian() {
+ let line = " Ovo je dugačka rečenica sa slovima čćđšž i uzorkom FooBar negdje u sredini. ";
+ let cleaned = clean_line(line, 24, None, "FooBar");
+ assert!(cleaned.chars().count() <= 24);
+ assert!(cleaned.contains("FooBar"));
+ }
+
+ #[test]
+ fn test_clean_line_tiny_max_len() {
+ let line = " abcdef ";
+ assert_eq!(clean_line(line, 0, None, "c"), "");
+ assert_eq!(clean_line(line, 1, None, "c").chars().count(), 1);
+ assert_eq!(clean_line(line, 2, None, "c").chars().count(), 2);
+ assert_eq!(clean_line(line, 3, None, "c").chars().count(), 3);
+ assert!(clean_line(line, 4, None, "c").chars().count() <= 4);
+ assert!(clean_line(line, 5, None, "c").chars().count() <= 5);
+ assert!(clean_line(line, 6, None, "c").chars().count() <= 6);
+ }
+
+ #[test]
+ fn test_legacy_caps_do_not_print_summary_by_default() {
+ let stdout = "b.txt\x001:foo bar baz\n\
+a.txt\x001:foo x\n\
+a.txt\x002:foo y\n";
+ let (out, stats) = render_grep_output(
+ "Foo",
+ stdout,
+ &GrepRenderOptions {
+ files_only: false,
+ count_by_file: false,
+ uncapped: false,
+ agent_safe: false,
+ summary_enabled: false,
+ context_only: false,
+ // Legacy-like defaults (from CLI flags -l/--max and config per-file).
+ max_line_chars: Some(5),
+ max_matches: Some(1),
+ max_per_file: None,
+ },
+ None,
+ false,
+ );
+ assert!(!out.contains("summary:"));
+ assert!(stats.omitted_total > 0 || stats.clipped_lines > 0);
+ }
+
#[test]
fn test_clean_line_emoji() {
let line = "🎉🎊🎈🎁🎂🎄 some text 🎃🎆🎇✨";
@@ -310,8 +955,24 @@ mod tests {
#[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");
+ let args = build_rg_args(pattern, ".", None, false, &[]);
+ assert!(args.iter().any(|a| a == "fn foo|pub.*bar"));
+ }
+
+ #[test]
+ fn test_fixed_grep_includes_dash_f_and_keeps_parens_literal() {
+ let pattern = "memcpy(szDummy";
+ let args = build_rg_args(pattern, ".", None, true, &[]);
+ assert!(args.iter().any(|a| a == "-F"));
+ assert!(args.iter().any(|a| a == pattern));
+ }
+
+ #[test]
+ fn test_fixed_grep_cpp_symbol_literal() {
+ let pattern = "AgcmUICharacter::OnAddModule";
+ let args = build_rg_args(pattern, ".", None, true, &[]);
+ assert!(args.iter().any(|a| a == "-F"));
+ assert!(args.iter().any(|a| a == pattern));
}
// Fix: -r flag (grep recursive) is stripped from extra_args (rg is recursive by default)
@@ -507,4 +1168,446 @@ mod tests {
}
// If rg is not installed, skip gracefully (test still passes)
}
+
+ fn sample_stdout() -> &'static str {
+ // Shape: `file\0line:content` (rg -0 / grep -Z)
+ "b.txt\x001:foo bar baz\n\
+a.txt\x001:foo x\n\
+a.txt\x002:foo y\n\
+a.txt\x003:foo z\n\
+c.txt\x001:foo c1\n\
+c.txt\x002:foo c2\n"
+ }
+
+ #[test]
+ fn test_files_only_unique_sorted() {
+ let (out, _stats) = render_grep_output(
+ "foo",
+ sample_stdout(),
+ &GrepRenderOptions {
+ files_only: true,
+ count_by_file: false,
+ uncapped: false,
+ agent_safe: false,
+ summary_enabled: false,
+ context_only: false,
+ max_matches: None,
+ max_per_file: None,
+ max_line_chars: None,
+ },
+ None,
+ false,
+ );
+ assert_eq!(out, "a.txt\nb.txt\nc.txt\n");
+ }
+
+ #[test]
+ fn test_count_by_file_sorted() {
+ let (out, _stats) = render_grep_output(
+ "foo",
+ sample_stdout(),
+ &GrepRenderOptions {
+ files_only: false,
+ count_by_file: true,
+ uncapped: false,
+ agent_safe: false,
+ summary_enabled: false,
+ context_only: false,
+ max_matches: None,
+ max_per_file: None,
+ max_line_chars: None,
+ },
+ None,
+ false,
+ );
+ // a.txt has 3, c.txt has 2, b.txt has 1
+ assert_eq!(out, "3 a.txt\n2 c.txt\n1 b.txt\n");
+ }
+
+ #[test]
+ fn test_total_cap_omits_and_summarizes() {
+ let (out, stats) = render_grep_output(
+ "foo",
+ sample_stdout(),
+ &GrepRenderOptions {
+ files_only: false,
+ count_by_file: false,
+ uncapped: false,
+ agent_safe: false,
+ summary_enabled: true,
+ context_only: false,
+ max_matches: Some(2),
+ max_per_file: Some(10),
+ max_line_chars: None,
+ },
+ None,
+ false,
+ );
+ assert!(out.contains("summary:"));
+ assert_eq!(stats.shown, 2);
+ assert!(stats.omitted_total > 0);
+ }
+
+ #[test]
+ fn test_per_file_cap_omits_and_summarizes() {
+ let (out, stats) = render_grep_output(
+ "foo",
+ sample_stdout(),
+ &GrepRenderOptions {
+ files_only: false,
+ count_by_file: false,
+ uncapped: false,
+ agent_safe: false,
+ summary_enabled: true,
+ context_only: false,
+ max_matches: Some(100),
+ max_per_file: Some(1),
+ max_line_chars: None,
+ },
+ None,
+ false,
+ );
+ assert!(out.contains("summary:"));
+ assert!(stats.omitted_per_file > 0);
+ }
+
+ #[test]
+ fn test_line_clipping_and_full_lines_escape_hatch() {
+ let stdout = "a.txt\x001:prefix foo suffix and extra\n";
+ let (clipped, stats_clipped) = render_grep_output(
+ "foo",
+ stdout,
+ &GrepRenderOptions {
+ files_only: false,
+ count_by_file: false,
+ uncapped: false,
+ agent_safe: false,
+ summary_enabled: true,
+ context_only: false,
+ max_matches: Some(100),
+ max_per_file: Some(10),
+ max_line_chars: Some(10),
+ },
+ None,
+ false,
+ );
+ assert!(stats_clipped.clipped_lines >= 1);
+ assert!(clipped.contains("foo"));
+
+ let (full, stats_full) = render_grep_output(
+ "foo",
+ stdout,
+ &GrepRenderOptions {
+ files_only: false,
+ count_by_file: false,
+ uncapped: false,
+ agent_safe: false,
+ summary_enabled: false,
+ context_only: false,
+ max_matches: Some(100),
+ max_per_file: Some(10),
+ max_line_chars: None,
+ },
+ None,
+ false,
+ );
+ assert_eq!(stats_full.clipped_lines, 0);
+ assert!(full.contains("prefix foo suffix and extra"));
+ }
+
+ #[test]
+ fn test_agent_safe_preset_and_override_semantics() {
+ // agent-safe: total=80, per-file=5, line=240; explicit max_per_file overrides to 30.
+ // (Dispatch logic in main.rs; we validate render behavior here.)
+ let mut many = String::new();
+ for i in 1..=40 {
+ many.push_str(&format!("a.txt\x00{}:foo {}\n", i, i));
+ }
+
+ let (_out, stats_default) = render_grep_output(
+ "foo",
+ &many,
+ &GrepRenderOptions {
+ files_only: false,
+ count_by_file: false,
+ uncapped: false,
+ agent_safe: true,
+ summary_enabled: true,
+ context_only: false,
+ max_matches: Some(80),
+ max_per_file: Some(5),
+ max_line_chars: Some(240),
+ },
+ None,
+ false,
+ );
+ assert_eq!(stats_default.shown, 5);
+
+ let (_out, stats_override) = render_grep_output(
+ "foo",
+ &many,
+ &GrepRenderOptions {
+ files_only: false,
+ count_by_file: false,
+ uncapped: false,
+ agent_safe: true,
+ summary_enabled: true,
+ context_only: false,
+ max_matches: Some(80),
+ max_per_file: Some(30),
+ max_line_chars: Some(240),
+ },
+ None,
+ false,
+ );
+ assert_eq!(stats_override.shown, 30);
+ }
+
+ #[test]
+ fn test_summary_hint_includes_concrete_file_and_line() {
+ let stdout = "src\\\\main.rs\u{0}371:Foo bar\n";
+ let (out, _stats) = render_grep_output(
+ "Foo",
+ stdout,
+ &GrepRenderOptions {
+ max_line_chars: Some(80),
+ max_matches: Some(200),
+ max_per_file: Some(25),
+ uncapped: false,
+ files_only: false,
+ count_by_file: false,
+ agent_safe: true,
+ summary_enabled: true,
+ context_only: false,
+ },
+ None,
+ false,
+ );
+ assert!(out.contains("rtk read \"src\\\\main.rs\" --lines 366:376"));
+ }
+
+ #[test]
+ fn test_top_files_sorts_and_limits() {
+ let stdout = concat!(
+ "b.rs\u{0}1:Foo\n",
+ "a.rs\u{0}1:Foo\n",
+ "a.rs\u{0}2:Foo\n"
+ );
+ let (out, _stats) = render_grep_output(
+ "Foo",
+ stdout,
+ &GrepRenderOptions {
+ max_line_chars: Some(80),
+ max_matches: Some(200),
+ max_per_file: Some(25),
+ uncapped: false,
+ files_only: false,
+ count_by_file: false,
+ agent_safe: false,
+ summary_enabled: false,
+ context_only: false,
+ },
+ Some(1),
+ false,
+ );
+ assert!(out.contains("2 a.rs"));
+ assert!(!out.contains("1 b.rs"));
+ }
+
+ #[test]
+ fn test_json_output_is_valid_json_only() {
+ let stdout = "src\\\\main.rs\u{0}371:Foo bar\n";
+ let (out, _stats) = render_grep_output(
+ "Foo",
+ stdout,
+ &GrepRenderOptions {
+ max_line_chars: Some(80),
+ max_matches: Some(1),
+ max_per_file: Some(1),
+ uncapped: false,
+ files_only: false,
+ count_by_file: false,
+ agent_safe: true,
+ summary_enabled: true,
+ context_only: false,
+ },
+ None,
+ true,
+ );
+ let v: Value = serde_json::from_str(out.trim()).expect("valid json");
+ assert_eq!(v["pattern"], "Foo");
+ }
+
+ #[test]
+ fn test_json_output_total_cap_does_not_emit_empty_files() {
+ let stdout = concat!(
+ "b.rs\u{0}1:Foo b\n",
+ "a.rs\u{0}1:Foo a\n",
+ "a.rs\u{0}2:Foo a2\n"
+ );
+ let (out, _stats) = render_grep_output(
+ "Foo",
+ stdout,
+ &GrepRenderOptions {
+ max_line_chars: Some(80),
+ max_matches: Some(1),
+ max_per_file: Some(25),
+ uncapped: false,
+ files_only: false,
+ count_by_file: false,
+ agent_safe: true,
+ summary_enabled: true,
+ context_only: false,
+ },
+ None,
+ true,
+ );
+ let v: Value = serde_json::from_str(out.trim()).expect("valid json");
+ assert_eq!(v["files"].as_array().unwrap().len(), 1);
+ assert_eq!(v["files"][0]["matches"].as_array().unwrap().len(), 1);
+ }
+
+ #[test]
+ fn test_json_output_is_valid_for_files_only_and_count_by_file() {
+ let stdout = concat!(
+ "b.rs\u{0}1:Foo\n",
+ "a.rs\u{0}1:Foo\n",
+ "a.rs\u{0}2:Foo\n"
+ );
+
+ let (out_files_only, _stats) = render_grep_output(
+ "Foo",
+ stdout,
+ &GrepRenderOptions {
+ max_line_chars: Some(80),
+ max_matches: Some(200),
+ max_per_file: Some(25),
+ uncapped: false,
+ files_only: true,
+ count_by_file: false,
+ agent_safe: true,
+ summary_enabled: true,
+ context_only: false,
+ },
+ None,
+ true,
+ );
+ let v1: Value = serde_json::from_str(out_files_only.trim()).expect("valid json");
+ assert_eq!(v1["pattern"], "Foo");
+ assert!(v1["files"].is_array());
+
+ let (out_count_by_file, _stats) = render_grep_output(
+ "Foo",
+ stdout,
+ &GrepRenderOptions {
+ max_line_chars: Some(80),
+ max_matches: Some(200),
+ max_per_file: Some(25),
+ uncapped: false,
+ files_only: false,
+ count_by_file: true,
+ agent_safe: true,
+ summary_enabled: true,
+ context_only: false,
+ },
+ None,
+ true,
+ );
+ let v2: Value = serde_json::from_str(out_count_by_file.trim()).expect("valid json");
+ assert_eq!(v2["pattern"], "Foo");
+ assert!(v2["files"].is_array());
+ }
+
+ #[test]
+ fn test_json_total_cap_one_emits_one_match_and_non_empty_files() {
+ let stdout = concat!("b.rs\u{0}1:Foo\n", "a.rs\u{0}1:Foo\n", "a.rs\u{0}2:Foo\n");
+ let (out, _stats) = render_grep_output(
+ "Foo",
+ stdout,
+ &GrepRenderOptions {
+ max_line_chars: Some(80),
+ max_matches: Some(1),
+ max_per_file: None,
+ uncapped: false,
+ files_only: false,
+ count_by_file: false,
+ agent_safe: false,
+ summary_enabled: false,
+ context_only: false,
+ },
+ None,
+ true,
+ );
+ let v: Value = serde_json::from_str(out.trim()).expect("valid json");
+ assert_eq!(v["displayedMatches"], 1);
+ assert!(!v["files"].as_array().unwrap().is_empty());
+ let mut total_json_matches = 0usize;
+ for f in v["files"].as_array().unwrap() {
+ total_json_matches += f["matches"].as_array().unwrap().len();
+ }
+ assert_eq!(total_json_matches, 1);
+ }
+
+ #[test]
+ fn test_agent_safe_json_total_cap_does_not_emit_empty_files_when_matches_exist() {
+ let stdout = concat!("b.rs\u{0}1:Foo\n", "a.rs\u{0}1:Foo\n", "a.rs\u{0}2:Foo\n");
+ let (out, _stats) = render_grep_output(
+ "Foo",
+ stdout,
+ &GrepRenderOptions {
+ max_line_chars: Some(80),
+ max_matches: Some(1),
+ max_per_file: None,
+ uncapped: false,
+ files_only: false,
+ count_by_file: false,
+ agent_safe: true,
+ summary_enabled: true,
+ context_only: false,
+ },
+ None,
+ true,
+ );
+ let v: Value = serde_json::from_str(out.trim()).expect("valid json");
+ assert_eq!(v["displayedMatches"], 1);
+ let files = v["files"].as_array().unwrap();
+ assert!(!files.is_empty());
+ assert!(!files[0]["matches"].as_array().unwrap().is_empty());
+ }
+
+ #[test]
+ fn test_json_total_and_per_file_caps_both_respected() {
+ let stdout = concat!(
+ "a.rs\u{0}1:Foo\n",
+ "a.rs\u{0}2:Foo\n",
+ "b.rs\u{0}1:Foo\n",
+ "b.rs\u{0}2:Foo\n"
+ );
+ let (out, _stats) = render_grep_output(
+ "Foo",
+ stdout,
+ &GrepRenderOptions {
+ max_line_chars: Some(80),
+ max_matches: Some(2),
+ max_per_file: Some(1),
+ uncapped: false,
+ files_only: false,
+ count_by_file: false,
+ agent_safe: false,
+ summary_enabled: false,
+ context_only: false,
+ },
+ None,
+ true,
+ );
+ let v: Value = serde_json::from_str(out.trim()).expect("valid json");
+ assert_eq!(v["displayedMatches"], 2);
+ let mut total_json_matches = 0usize;
+ for f in v["files"].as_array().unwrap() {
+ let m = f["matches"].as_array().unwrap().len();
+ assert!(m <= 1);
+ total_json_matches += m;
+ }
+ assert_eq!(total_json_matches, 2);
+ }
}
diff --git a/src/cmds/system/log_cmd.rs b/src/cmds/system/log_cmd.rs
index 7c765f5e6..627746c7c 100644
--- a/src/cmds/system/log_cmd.rs
+++ b/src/cmds/system/log_cmd.rs
@@ -22,7 +22,7 @@ lazy_static! {
}
/// Filter and deduplicate log output
-pub fn run_file(file: &Path, verbose: u8) -> Result<()> {
+pub fn run_file(file: &Path, recent_events: usize, keywords: &[String], verbose: u8) -> Result<()> {
let timer = tracking::TimedExecution::start();
if verbose > 0 {
@@ -30,7 +30,7 @@ pub fn run_file(file: &Path, verbose: u8) -> Result<()> {
}
let content = fs::read_to_string(file)?;
- let result = analyze_logs(&content);
+ let result = analyze_logs_with_options(&content, recent_events, keywords);
println!("{}", result);
timer.track(
&format!("cat {}", file.display()),
@@ -42,7 +42,7 @@ pub fn run_file(file: &Path, verbose: u8) -> Result<()> {
}
/// Filter logs from stdin
-pub fn run_stdin(_verbose: u8) -> Result<()> {
+pub fn run_stdin(recent_events: usize, keywords: &[String], _verbose: u8) -> Result<()> {
let timer = tracking::TimedExecution::start();
let mut content = String::new();
@@ -52,7 +52,7 @@ pub fn run_stdin(_verbose: u8) -> Result<()> {
content.push('\n');
}
- let result = analyze_logs(&content);
+ let result = analyze_logs_with_options(&content, recent_events, keywords);
println!("{}", result);
timer.track("log (stdin)", "rtk log (stdin)", &content, &result);
@@ -149,7 +149,7 @@ fn analyze_logs(content: &str) -> String {
.map(|s| s.as_str())
.unwrap_or(normalized);
- let truncated = if original.len() > 100 {
+ let truncated = if original.chars().count() > 100 {
let t: String = original.chars().take(97).collect();
format!("{}...", t)
} else {
@@ -191,7 +191,7 @@ fn analyze_logs(content: &str) -> String {
.map(|s| s.as_str())
.unwrap_or(normalized);
- let truncated = if original.len() > 100 {
+ let truncated = if original.chars().count() > 100 {
let t: String = original.chars().take(97).collect();
format!("{}...", t)
} else {
@@ -216,6 +216,75 @@ fn analyze_logs(content: &str) -> String {
result.join("\n")
}
+fn analyze_logs_with_options(content: &str, recent_events: usize, keywords: &[String]) -> String {
+ let mut base = analyze_logs(content);
+ if recent_events == 0 {
+ return base;
+ }
+
+ let keys: Vec = if keywords.is_empty() {
+ vec![
+ "assert",
+ "error",
+ "failed",
+ "fail",
+ "exception",
+ "crash",
+ "load",
+ "oninitialize",
+ "onpostinitialize",
+ "streamread",
+ ]
+ .into_iter()
+ .map(|s| s.to_string())
+ .collect()
+ } else {
+ keywords.iter().map(|s| s.to_ascii_lowercase()).collect()
+ };
+
+ let mut picked: Vec<(usize, String)> = Vec::new();
+ let mut seen_norm: Vec = Vec::new();
+
+ let lines: Vec<&str> = content.lines().collect();
+ for idx0 in (0..lines.len()).rev() {
+ let l = lines[idx0].trim_end();
+ if l.is_empty() {
+ continue;
+ }
+ let lower = l.to_ascii_lowercase();
+ if !keys.iter().any(|k| lower.contains(k)) {
+ continue;
+ }
+
+ let norm = normalize_log_line(l, &TIMESTAMP_RE, &UUID_RE, &HEX_RE, &NUM_RE, &PATH_RE);
+ if seen_norm.contains(&norm) {
+ continue;
+ }
+ seen_norm.push(norm);
+ picked.push((idx0 + 1, l.to_string()));
+ if picked.len() >= recent_events {
+ break;
+ }
+ }
+
+ picked.reverse();
+
+ if !picked.is_empty() {
+ base.push_str("\n\n[RECENT_EVENTS]\n");
+ for (ln, msg) in picked {
+ let truncated = if msg.chars().count() > 200 {
+ let t: String = msg.chars().take(197).collect();
+ format!("{}...", t)
+ } else {
+ msg
+ };
+ base.push_str(&format!(" {}: {}\n", ln, truncated));
+ }
+ }
+
+ base.trim_end().to_string()
+}
+
fn normalize_log_line(
line: &str,
timestamp_re: &Regex,
@@ -274,4 +343,56 @@ mod tests {
// Should not panic even with very long multi-byte messages
assert!(result.contains("ERRORS"));
}
+
+ #[test]
+ fn test_analyze_logs_does_not_truncate_when_under_char_limit_but_over_byte_limit() {
+ let msg = "界".repeat(70); // keep total line <= 100 chars, but >100 bytes
+ let line = format!("2024-01-01 10:00:00 ERROR: {msg}");
+ let logs = format!("{line}\n");
+ let result = analyze_logs(&logs);
+ assert!(result.contains(&line));
+ }
+
+ #[test]
+ fn test_analyze_logs_truncates_when_over_char_limit() {
+ let msg = "a".repeat(101);
+ let line = format!("2024-01-01 10:00:00 ERROR: {msg}");
+ let logs = format!("{line}\n");
+ let result = analyze_logs(&logs);
+ let expected_prefix: String = line.chars().take(97).collect();
+ assert!(result.contains(&format!("{expected_prefix}...")));
+ }
+
+ #[test]
+ fn test_recent_events_tail_dedup() {
+ let logs = "INFO: startup\n\
+ ERROR: Load failed\n\
+ ERROR: Load failed\n\
+ OnInitialize: begin\n\
+ ASSERT failed: x\n";
+ let out = analyze_logs_with_options(logs, 3, &[]);
+ assert!(out.contains("[RECENT_EVENTS]"));
+ assert!(out.contains("ERROR: Load failed"));
+ assert!(out.contains("ASSERT failed"));
+ // Dedup identical ERROR line in recent events
+ assert_eq!(out.matches("ERROR: Load failed").count(), 2); // one in summary, one in recent events
+ }
+
+ #[test]
+ fn test_truncation_does_not_use_byte_len() {
+ // 60 emojis: >100 bytes, but only 60 chars → should not truncate.
+ let msg = "🎉".repeat(60);
+ let logs = format!("2024-01-01 10:00:00 ERROR: {}\n", msg);
+ let out = analyze_logs(&logs);
+ assert!(out.contains(&msg));
+ assert!(!out.contains("..."));
+ }
+
+ #[test]
+ fn test_truncation_uses_char_count_and_appends_ellipsis() {
+ let msg = "é".repeat(101);
+ let logs = format!("2024-01-01 10:00:00 ERROR: {}\n", msg);
+ let out = analyze_logs(&logs);
+ assert!(out.contains("..."));
+ }
}
diff --git a/src/cmds/system/patch.rs b/src/cmds/system/patch.rs
new file mode 100644
index 000000000..447e0eba4
--- /dev/null
+++ b/src/cmds/system/patch.rs
@@ -0,0 +1,111 @@
+use crate::core::text_encoding::{self, TextEncoding, UsedEncoding};
+use anyhow::{anyhow, Context, Result};
+use std::fs;
+use std::path::{Path, PathBuf};
+
+pub struct PatchArgs<'a> {
+ pub file: &'a Path,
+ pub encoding: TextEncoding,
+ pub old: &'a str,
+ pub new: &'a str,
+ pub all: bool,
+ pub backup: bool,
+}
+
+pub fn run(args: PatchArgs<'_>, verbose: u8) -> Result {
+ if verbose > 0 {
+ eprintln!("rtk patch: {}", args.file.display());
+ }
+
+ let original = fs::read(args.file)
+ .with_context(|| format!("Failed to read file: {}", args.file.display()))?;
+
+ let decoded = text_encoding::decode_bytes(&original, args.encoding)
+ .with_context(|| format!("Failed to decode file: {}", args.file.display()))?;
+
+ if matches!(decoded.used, UsedEncoding::Utf16Le | UsedEncoding::Utf16Be) {
+ return Err(anyhow!(
+ "utf16 input is not supported by rtk patch (use a UTF-8/ANSI file)"
+ ));
+ }
+
+ if decoded.used_fallback {
+ eprintln!(
+ "rtk patch: decoded {} as {}",
+ args.file.display(),
+ decoded.used.label()
+ );
+ }
+
+ let count = decoded.text.match_indices(args.old).count();
+ if count == 0 {
+ return Err(anyhow!("no matches for --replace in {}", args.file.display()));
+ }
+ if !args.all && count != 1 {
+ return Err(anyhow!(
+ "expected exactly 1 match for --replace (found {}); pass --all to replace all",
+ count
+ ));
+ }
+
+ let replaced = if args.all {
+ decoded.text.replace(args.old, args.new)
+ } else {
+ decoded.text.replacen(args.old, args.new, 1)
+ };
+
+ let out = text_encoding::encode_text(&replaced, decoded.used)
+ .context("Failed to encode patched content")?;
+
+ if args.backup {
+ let backup_path = bak_path(args.file);
+ fs::write(&backup_path, &original).with_context(|| {
+ format!(
+ "Failed to write backup file: {}",
+ backup_path.display()
+ )
+ })?;
+ }
+
+ fs::write(args.file, out)
+ .with_context(|| format!("Failed to write file: {}", args.file.display()))?;
+
+ Ok(0)
+}
+
+fn bak_path(path: &Path) -> PathBuf {
+ let s = path.to_string_lossy();
+ PathBuf::from(format!("{}.bak", s))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::core::text_encoding::TextEncoding;
+ use tempfile::NamedTempFile;
+
+ #[test]
+ fn test_patch_preserves_non_utf8_bytes_latin1() -> Result<()> {
+ let f = NamedTempFile::new()?;
+ // Contains non-UTF8 bytes (0xFF, 0xFE) that must survive unchanged.
+ let original: Vec = b"AA OLD BB\n".iter().copied().chain([0xFF, 0xFE]).collect();
+ fs::write(f.path(), &original)?;
+
+ run(
+ PatchArgs {
+ file: f.path(),
+ encoding: TextEncoding::Latin1,
+ old: "OLD",
+ new: "NEW",
+ all: false,
+ backup: false,
+ },
+ 0,
+ )?;
+
+ let out = fs::read(f.path())?;
+ assert!(out.starts_with(b"AA NEW BB\n"));
+ assert_eq!(&out[out.len() - 2..], &[0xFF, 0xFE]);
+ Ok(())
+ }
+}
diff --git a/src/cmds/system/pipe_cmd.rs b/src/cmds/system/pipe_cmd.rs
index 6dcc4cdb6..918f3fd08 100644
--- a/src/cmds/system/pipe_cmd.rs
+++ b/src/cmds/system/pipe_cmd.rs
@@ -12,6 +12,7 @@ pub fn resolve_filter(name: &str) -> Option String> {
match name {
"cargo-test" | "cargo" => Some(crate::cmds::rust::cargo_cmd::filter_cargo_test),
"pytest" => Some(crate::cmds::python::pytest_cmd::filter_pytest_output),
+ "ctest" => Some(crate::cmds::cpp::ctest_cmd::filter_ctest_output),
"go-test" => Some(go_test_wrapper),
"go-build" => Some(crate::cmds::go::go_cmd::filter_go_build),
"tsc" => Some(crate::cmds::js::tsc_cmd::filter_tsc_output),
diff --git a/src/cmds/system/read.rs b/src/cmds/system/read.rs
index 2f56687ee..bb492d810 100644
--- a/src/cmds/system/read.rs
+++ b/src/cmds/system/read.rs
@@ -1,17 +1,31 @@
//! Reads source files with optional language-aware filtering to strip boilerplate.
+use crate::cmds::cpp::msbuild_cmd;
use crate::core::filter::{self, FilterLevel, Language};
+use crate::core::text_encoding::{self, TextEncoding};
use crate::core::tracking;
use anyhow::{Context, Result};
+use lazy_static::lazy_static;
+use regex::Regex;
use std::fs;
use std::path::Path;
+lazy_static! {
+ // Compiler / managed-code diagnostic: ": error C2065" / ": error L1234" / ": error MSB..."
+ static ref MSBUILD_DIAG_RE: Regex = Regex::new(r": error [CLM]\d+").unwrap();
+ // Linker diagnostic: ": error LNK2001" (covers "fatal error LNK..." too via the colon)
+ static ref MSBUILD_LNK_RE: Regex = Regex::new(r": error LNK\d+").unwrap();
+}
+
+#[allow(clippy::too_many_arguments)]
pub fn run(
file: &Path,
level: FilterLevel,
max_lines: Option,
tail_lines: Option,
+ line_range: Option<(usize, usize)>,
line_numbers: bool,
+ encoding: TextEncoding,
verbose: u8,
) -> Result<()> {
let timer = tracking::TimedExecution::start();
@@ -20,9 +34,32 @@ pub fn run(
eprintln!("Reading: {} (filter: {})", file.display(), level);
}
- // Read file content
- let content = fs::read_to_string(file)
- .with_context(|| format!("Failed to read file: {}", file.display()))?;
+ // Read file content (handles UTF-16 LE/BE BOM — MSBuild logs on Windows)
+ let (content, used_encoding, used_fallback) = read_file_text(file, encoding)?;
+ if used_fallback {
+ eprintln!(
+ "rtk read: decoded {} as {}",
+ file.display(),
+ used_encoding.label()
+ );
+ }
+
+ // Auto-detect MSBuild log files and route through the msbuild filter.
+ // Without this, `rtk read msbuild.log` (after `msbuild *> file.log`) would
+ // pass through verbose project/task chatter at near-zero token savings.
+ if let Some(filtered) = maybe_apply_msbuild_filter(&content) {
+ if verbose > 0 {
+ eprintln!("Detected MSBuild log — applying msbuild filter");
+ }
+ print!("{}", filtered);
+ timer.track(
+ &format!("cat {}", file.display()),
+ "rtk read",
+ &content,
+ &filtered,
+ );
+ return Ok(());
+ }
// Detect language from extension
let lang = file
@@ -63,10 +100,13 @@ pub fn run(
);
}
- filtered = apply_line_window(&filtered, max_lines, tail_lines, &lang);
+ filtered = apply_line_window(&filtered, max_lines, tail_lines, line_range, &lang);
let rtk_output = if line_numbers {
- format_with_line_numbers(&filtered)
+ match line_range {
+ Some((start, _end)) => format_with_line_numbers_offset(&filtered, start),
+ None => format_with_line_numbers(&filtered),
+ }
} else {
filtered.clone()
};
@@ -84,7 +124,9 @@ pub fn run_stdin(
level: FilterLevel,
max_lines: Option,
tail_lines: Option,
+ line_range: Option<(usize, usize)>,
line_numbers: bool,
+ _encoding: TextEncoding,
verbose: u8,
) -> Result<()> {
use std::io::{self, Read as IoRead};
@@ -127,10 +169,13 @@ pub fn run_stdin(
);
}
- filtered = apply_line_window(&filtered, max_lines, tail_lines, &lang);
+ filtered = apply_line_window(&filtered, max_lines, tail_lines, line_range, &lang);
let rtk_output = if line_numbers {
- format_with_line_numbers(&filtered)
+ match line_range {
+ Some((start, _end)) => format_with_line_numbers_offset(&filtered, start),
+ None => format_with_line_numbers(&filtered),
+ }
} else {
filtered.clone()
};
@@ -140,6 +185,183 @@ pub fn run_stdin(
Ok(())
}
+/// Heuristic: returns `true` if the first 200 lines contain ANY MSBuild marker.
+///
+/// Single marker is enough — real MSBuild logs may have hundreds of lines of
+/// progress chatter before the first error/build-result line, so requiring
+/// multiple markers in a 200-line sample misses logs where only `.vcxproj`
+/// references show up early. The detection runs on transcoded UTF-8 content
+/// (after BOM strip + UTF-16 → UTF-8 conversion) and tolerates `\r\n` line
+/// endings (`str::lines()` already strips `\r`, but explicit trim is kept
+/// for defense in depth).
+fn is_msbuild_log(content: &str) -> bool {
+ for (i, raw_line) in content.lines().take(200).enumerate() {
+ // Defense in depth: strip a stray UTF-8 BOM that survived decoding.
+ let line = if i == 0 {
+ raw_line.trim_start_matches('\u{FEFF}')
+ } else {
+ raw_line
+ };
+ let line = line.trim_end_matches('\r');
+
+ if line.contains("Build FAILED")
+ || line.contains("Build succeeded")
+ || line.contains(".vcxproj")
+ || MSBUILD_DIAG_RE.is_match(line)
+ || MSBUILD_LNK_RE.is_match(line)
+ {
+ return true;
+ }
+ }
+ false
+}
+
+/// If `content` is an MSBuild log, run it through the msbuild filter and return
+/// the compressed output. Returns `None` for non-MSBuild content.
+fn maybe_apply_msbuild_filter(content: &str) -> Option {
+ if !is_msbuild_log(content) {
+ return None;
+ }
+ let filtered = msbuild_cmd::filter_output(content, &[]);
+ if filtered.starts_with("msbuild: ok") {
+ return Some("rtk read: build ok \u{2014} no errors found in log\n".to_string());
+ }
+ if filtered.starts_with("msbuild: no output captured") {
+ // Detection passed but the filter found nothing structured — fall
+ // back to the regular read pipeline so the user still sees content.
+ return None;
+ }
+ let mut out = filtered;
+ if !out.ends_with('\n') {
+ out.push('\n');
+ }
+ Some(out)
+}
+
+fn read_file_text(
+ path: &Path,
+ encoding: TextEncoding,
+) -> Result<(String, text_encoding::UsedEncoding, bool)> {
+ let bytes = fs::read(path)
+ .with_context(|| format!("Failed to read file: {}", path.display()))?;
+
+ // Avoid dumping binary-ish content as Latin1 garbage in --encoding auto mode.
+ // This is intentionally conservative and only triggers for obvious cases.
+ if encoding == TextEncoding::Auto && looks_binary_bytes(&bytes) {
+ let preview = hex_preview(&bytes, 64);
+ let nul = bytes.iter().take(8192).filter(|b| **b == 0).count();
+ let msg = format!(
+ "rtk read: file appears binary ({} bytes, nul={} in first 8192)\n\
+binary preview (first {} bytes): {}\n\
+hint: use `rtk read --encoding latin1 ` to force raw bytes-as-text\n",
+ bytes.len(),
+ nul,
+ preview.len,
+ preview.hex
+ );
+ return Ok((msg, text_encoding::UsedEncoding::Utf8, false));
+ }
+
+ let decoded = text_encoding::decode_bytes(&bytes, encoding)
+ .with_context(|| format!("Failed to decode file: {}", path.display()))?;
+ Ok((decoded.text, decoded.used, decoded.used_fallback))
+}
+
+struct HexPreview {
+ hex: String,
+ len: usize,
+}
+
+fn hex_preview(bytes: &[u8], max: usize) -> HexPreview {
+ let n = std::cmp::min(bytes.len(), max);
+ let mut out = String::new();
+ for (i, b) in bytes.iter().take(n).enumerate() {
+ if i > 0 {
+ out.push(' ');
+ }
+ out.push_str(&format!("{:02X}", b));
+ }
+ HexPreview { hex: out, len: n }
+}
+
+fn looks_binary_bytes(bytes: &[u8]) -> bool {
+ if bytes.len() >= 2 && ((bytes[0] == 0xFF && bytes[1] == 0xFE) || (bytes[0] == 0xFE && bytes[1] == 0xFF)) {
+ return false;
+ }
+ let len = std::cmp::min(bytes.len(), 8192);
+ let sample = &bytes[..len];
+ if sample.is_empty() {
+ return false;
+ }
+
+ // UTF-16 without BOM can contain many NULs; don't treat it as binary.
+ if looks_utf16_no_bom(sample) {
+ return false;
+ }
+
+ // A single NUL byte is a strong signal for binary in this tool's context.
+ if sample.contains(&0) {
+ return true;
+ }
+
+ // If a large fraction of bytes are control chars (excluding \t,\n,\r), treat as binary-ish.
+ let mut control = 0usize;
+ for b in sample {
+ if *b < 0x09 || (*b > 0x0D && *b < 0x20) {
+ control += 1;
+ }
+ }
+ (control as f64 / sample.len() as f64) > 0.30
+}
+
+fn looks_utf16_no_bom(sample: &[u8]) -> bool {
+ if sample.len() < 4 {
+ return false;
+ }
+ let mut zeros_even = 0usize;
+ let mut zeros_odd = 0usize;
+ let mut pairs = 0usize;
+ for chunk in sample.chunks_exact(2).take(4096) {
+ pairs += 1;
+ if chunk[0] == 0 {
+ zeros_even += 1;
+ }
+ if chunk[1] == 0 {
+ zeros_odd += 1;
+ }
+ }
+ if pairs == 0 {
+ return false;
+ }
+ let even_ratio = zeros_even as f64 / pairs as f64;
+ let odd_ratio = zeros_odd as f64 / pairs as f64;
+
+ fn ascii_lane_ratio(sample: &[u8], lane: usize) -> f64 {
+ let mut total = 0usize;
+ let mut ascii = 0usize;
+ for chunk in sample.chunks_exact(2).take(4096) {
+ let b = chunk[lane];
+ if b == 0 {
+ continue;
+ }
+ total += 1;
+ let is_ascii =
+ b == b'\t' || b == b'\n' || b == b'\r' || (0x20..=0x7E).contains(&b);
+ if is_ascii {
+ ascii += 1;
+ }
+ }
+ if total == 0 {
+ 0.0
+ } else {
+ ascii as f64 / total as f64
+ }
+ }
+
+ (odd_ratio >= 0.60 && even_ratio < 0.10 && ascii_lane_ratio(sample, 0) >= 0.85)
+ || (even_ratio >= 0.60 && odd_ratio < 0.10 && ascii_lane_ratio(sample, 1) >= 0.85)
+}
+
fn format_with_line_numbers(content: &str) -> String {
let lines: Vec<&str> = content.lines().collect();
let width = lines.len().to_string().len();
@@ -150,12 +372,46 @@ fn format_with_line_numbers(content: &str) -> String {
out
}
+fn format_with_line_numbers_offset(content: &str, start_line: usize) -> String {
+ let lines: Vec<&str> = content.lines().collect();
+ let max_line_num = start_line.saturating_add(lines.len()).saturating_sub(1);
+ let width = max_line_num.to_string().len().max(1);
+ let mut out = String::new();
+ for (i, line) in lines.iter().enumerate() {
+ out.push_str(&format!(
+ "{:>width$} │ {}\n",
+ start_line + i,
+ line,
+ width = width
+ ));
+ }
+ out
+}
+
fn apply_line_window(
content: &str,
max_lines: Option,
tail_lines: Option,
+ line_range: Option<(usize, usize)>,
lang: &Language,
) -> String {
+ if let Some((start, end)) = line_range {
+ if start == 0 || end == 0 || end < start {
+ return String::new();
+ }
+ let lines: Vec<&str> = content.lines().collect();
+ let start_idx = start.saturating_sub(1).min(lines.len());
+ let end_idx = end.min(lines.len());
+ if end_idx <= start_idx {
+ return String::new();
+ }
+ let mut result = lines[start_idx..end_idx].join("\n");
+ if content.ends_with('\n') {
+ result.push('\n');
+ }
+ return result;
+ }
+
if let Some(tail) = tail_lines {
if tail == 0 {
return String::new();
@@ -194,10 +450,104 @@ fn main() {{
)?;
// Just verify it doesn't panic
- run(file.path(), FilterLevel::Minimal, None, None, false, 0)?;
+ run(
+ file.path(),
+ FilterLevel::Minimal,
+ None,
+ None,
+ None,
+ false,
+ TextEncoding::Auto,
+ 0,
+ )?;
+ Ok(())
+ }
+
+ #[test]
+ fn test_read_auto_fallback_cp949() -> Result<()> {
+ let mut file = NamedTempFile::new()?;
+ let (bytes, _, _) = encoding_rs::EUC_KR.encode("안녕\n");
+ file.write_all(&bytes)?;
+
+ let (txt, used, used_fallback) = read_file_text(file.path(), TextEncoding::Auto)?;
+ assert!(used_fallback);
+ assert_eq!(used, text_encoding::UsedEncoding::Cp949);
+ assert!(txt.contains("안녕"));
+ Ok(())
+ }
+
+ #[test]
+ fn test_read_auto_utf16_le_no_bom() -> Result<()> {
+ let mut file = NamedTempFile::new()?;
+ // "Hi\n" in UTF-16 LE without BOM
+ file.write_all(&[0x48, 0x00, 0x69, 0x00, 0x0A, 0x00])?;
+ let (txt, used, used_fallback) = read_file_text(file.path(), TextEncoding::Auto)?;
+ assert!(used_fallback);
+ assert_eq!(used, text_encoding::UsedEncoding::Utf16Le);
+ assert!(txt.contains("Hi"));
Ok(())
}
+ #[test]
+ fn test_read_auto_utf16_le_bom() -> Result<()> {
+ let mut file = NamedTempFile::new()?;
+ // BOM + "Hi\n" in UTF-16 LE
+ file.write_all(&[0xFF, 0xFE, 0x48, 0x00, 0x69, 0x00, 0x0A, 0x00])?;
+ let (txt, used, used_fallback) = read_file_text(file.path(), TextEncoding::Auto)?;
+ assert!(!used_fallback);
+ assert_eq!(used, text_encoding::UsedEncoding::Utf16Le);
+ assert!(txt.contains("Hi"));
+ Ok(())
+ }
+
+ #[test]
+ fn test_read_auto_windows_1252() -> Result<()> {
+ let mut file = NamedTempFile::new()?;
+ // "Hé" in windows-1252: 0x48 0xE9 (invalid UTF-8)
+ file.write_all(&[0x48, 0xE9])?;
+ let (txt, used, used_fallback) = read_file_text(file.path(), TextEncoding::Auto)?;
+ assert!(used_fallback);
+ assert_eq!(used, text_encoding::UsedEncoding::Windows1252);
+ assert!(txt.contains('é'));
+ Ok(())
+ }
+
+ #[test]
+ fn test_read_auto_binary_preview() -> Result<()> {
+ let mut file = NamedTempFile::new()?;
+ file.write_all(&[0x00, 0x01, 0x02, 0x03, 0x00, 0xFF])?;
+ let (txt, used, used_fallback) = read_file_text(file.path(), TextEncoding::Auto)?;
+ assert!(!used_fallback);
+ assert_eq!(used, text_encoding::UsedEncoding::Utf8);
+ assert!(txt.contains("file appears binary"));
+ assert!(txt.contains("binary preview"));
+ Ok(())
+ }
+
+ #[test]
+ fn test_apply_line_window_range() {
+ let lang = Language::Unknown;
+ let s = "a\nb\nc\nd\n";
+ let out = apply_line_window(s, None, None, Some((2, 3)), &lang);
+ assert_eq!(out, "b\nc\n");
+ }
+
+ #[test]
+ fn test_apply_line_window_invalid_range_empty() {
+ let lang = Language::Unknown;
+ let s = "a\nb\nc\n";
+ assert_eq!(apply_line_window(s, None, None, Some((0, 2)), &lang), "");
+ assert_eq!(apply_line_window(s, None, None, Some((3, 2)), &lang), "");
+ }
+
+ #[test]
+ fn test_format_with_line_numbers_offset() {
+ let s = "b\nc\n";
+ let out = format_with_line_numbers_offset(s, 2);
+ assert!(out.contains("2 │ b"));
+ assert!(out.contains("3 │ c"));
+ }
+
#[test]
fn test_stdin_support_signature() {
// Test that run_stdin has correct signature and compiles
@@ -205,24 +555,202 @@ fn main() {{
// Compile-time verification that the function exists with correct signature
}
+ #[test]
+ fn test_is_msbuild_log_failure_fixture() {
+ let raw = include_str!("../../../tests/fixtures/cpp/msbuild_failure_compiler.txt");
+ assert!(is_msbuild_log(raw));
+ }
+
+ #[test]
+ fn test_is_msbuild_log_success_fixture() {
+ let raw = include_str!("../../../tests/fixtures/cpp/msbuild_success.txt");
+ assert!(is_msbuild_log(raw));
+ }
+
+ #[test]
+ fn test_is_msbuild_log_rejects_plain_text() {
+ let plain = "This is just\nsome regular text\nno build markers here\n";
+ assert!(!is_msbuild_log(plain));
+ }
+
+ #[test]
+ fn test_is_msbuild_log_single_marker_is_enough() {
+ // Single marker is sufficient under OR semantics (real msbuild logs
+ // often start with hundreds of lines of progress chatter before the
+ // first error/build-result line).
+ assert!(is_msbuild_log("see MyProject.vcxproj for details\n"));
+ assert!(is_msbuild_log("Build FAILED.\n"));
+ assert!(is_msbuild_log("Build succeeded.\n"));
+ assert!(is_msbuild_log("foo.lib(bar.obj) : error LNK2001: x\n"));
+ assert!(is_msbuild_log("a.cpp(1): error C2065: x\n"));
+ }
+
+ #[test]
+ fn test_is_msbuild_log_skips_first_200_only() {
+ // 250 plain lines then an MSBuild marker → not detected (200-line cap).
+ let mut s = String::new();
+ for _ in 0..250 {
+ s.push_str("plain line\n");
+ }
+ s.push_str("Build FAILED.\n");
+ assert!(!is_msbuild_log(&s));
+ }
+
+ #[test]
+ fn test_is_msbuild_log_strips_utf8_bom_first_line() {
+ let txt = "\u{FEFF}.vcxproj reference\nmore content\n";
+ assert!(is_msbuild_log(txt));
+ }
+
+ #[test]
+ fn test_is_msbuild_log_handles_crlf() {
+ let txt = "header\r\nBuild FAILED.\r\n";
+ assert!(is_msbuild_log(txt));
+ }
+
+ #[test]
+ fn test_maybe_apply_msbuild_filter_success() {
+ let raw = include_str!("../../../tests/fixtures/cpp/msbuild_success.txt");
+ let out = maybe_apply_msbuild_filter(raw).expect("should detect");
+ assert!(out.starts_with("rtk read: build ok"));
+ }
+
+ #[test]
+ fn test_maybe_apply_msbuild_filter_failure_compresses() {
+ let raw = include_str!("../../../tests/fixtures/cpp/msbuild_failure_compiler.txt");
+ let out = maybe_apply_msbuild_filter(raw).expect("should detect");
+ assert!(out.contains("C2065"));
+ assert!(out.contains("Build FAILED"));
+ // Must drop the verbose header chatter
+ assert!(!out.contains("Microsoft (R) Build Engine"));
+ assert!(!out.contains("Done Building Project"));
+ // Must compress
+ assert!(
+ out.len() < raw.len(),
+ "filter should reduce size: raw={} filtered={}",
+ raw.len(),
+ out.len()
+ );
+ }
+
+ #[test]
+ fn test_maybe_apply_msbuild_filter_skips_non_msbuild() {
+ let plain = "Just some\nregular file content\n";
+ assert!(maybe_apply_msbuild_filter(plain).is_none());
+ }
+
+ #[test]
+ fn test_decode_utf16_le_bom() {
+ // "abc" in UTF-16 LE with BOM
+ let bytes: &[u8] = &[0xFF, 0xFE, b'a', 0, b'b', 0, b'c', 0];
+ assert_eq!(
+ text_encoding::decode_bytes(bytes, TextEncoding::Auto)
+ .unwrap()
+ .text,
+ "abc"
+ );
+ }
+
+ #[test]
+ fn test_decode_utf16_be_bom() {
+ // "abc" in UTF-16 BE with BOM
+ let bytes: &[u8] = &[0xFE, 0xFF, 0, b'a', 0, b'b', 0, b'c'];
+ assert_eq!(
+ text_encoding::decode_bytes(bytes, TextEncoding::Auto)
+ .unwrap()
+ .text,
+ "abc"
+ );
+ }
+
+ #[test]
+ fn test_decode_utf8_bom_stripped() {
+ let bytes: &[u8] = &[0xEF, 0xBB, 0xBF, b'h', b'i'];
+ assert_eq!(
+ text_encoding::decode_bytes(bytes, TextEncoding::Auto)
+ .unwrap()
+ .text,
+ "hi"
+ );
+ }
+
+ #[test]
+ fn test_decode_plain_utf8() {
+ assert_eq!(
+ text_encoding::decode_bytes(b"plain text", TextEncoding::Auto)
+ .unwrap()
+ .text,
+ "plain text"
+ );
+ }
+
+ #[test]
+ fn test_decode_utf16_le_msbuild_style() {
+ // Simulate a tiny MSBuild log line in UTF-16 LE
+ let line = "Build succeeded.\r\n";
+ let mut bytes = vec![0xFF, 0xFE];
+ for c in line.encode_utf16() {
+ bytes.extend_from_slice(&c.to_le_bytes());
+ }
+ assert_eq!(
+ text_encoding::decode_bytes(&bytes, TextEncoding::Auto)
+ .unwrap()
+ .text,
+ line
+ );
+ }
+
+ #[test]
+ fn test_looks_utf16_no_bom_allows_odd_length_sample() {
+ // UTF-16LE-like ASCII: H i ! with a trailing odd byte.
+ let sample = vec![0x48, 0x00, 0x69, 0x00, 0x21, 0x00, 0xFF];
+ assert!(looks_utf16_no_bom(&sample));
+ }
+
+ #[test]
+ fn test_looks_utf16_no_bom_short_sample_false() {
+ assert!(!looks_utf16_no_bom(&[0x00, 0x41, 0x00]));
+ }
+
+ #[test]
+ fn test_read_utf16_le_file() -> Result<()> {
+ // End-to-end: rtk read on a UTF-16 LE file should not crash
+ let mut file = NamedTempFile::with_suffix(".log")?;
+ file.write_all(&[0xFF, 0xFE])?;
+ for c in "Build succeeded.\n".encode_utf16() {
+ file.write_all(&c.to_le_bytes())?;
+ }
+ run(
+ file.path(),
+ FilterLevel::Minimal,
+ None,
+ None,
+ None,
+ false,
+ TextEncoding::Auto,
+ 0,
+ )?;
+ Ok(())
+ }
+
#[test]
fn test_apply_line_window_tail_lines() {
let input = "a\nb\nc\nd\n";
- let output = apply_line_window(input, None, Some(2), &Language::Unknown);
+ let output = apply_line_window(input, None, Some(2), None, &Language::Unknown);
assert_eq!(output, "c\nd\n");
}
#[test]
fn test_apply_line_window_tail_lines_no_trailing_newline() {
let input = "a\nb\nc\nd";
- let output = apply_line_window(input, None, Some(2), &Language::Unknown);
+ let output = apply_line_window(input, None, Some(2), None, &Language::Unknown);
assert_eq!(output, "c\nd");
}
#[test]
fn test_apply_line_window_max_lines_still_works() {
let input = "a\nb\nc\nd\n";
- let output = apply_line_window(input, Some(2), None, &Language::Unknown);
+ let output = apply_line_window(input, Some(2), None, None, &Language::Unknown);
assert!(output.starts_with("a\n"));
assert!(output.contains("more lines"));
}
diff --git a/src/core/config.rs b/src/core/config.rs
index ed0f00c6c..edc3be83a 100644
--- a/src/core/config.rs
+++ b/src/core/config.rs
@@ -21,6 +21,14 @@ pub struct Config {
pub hooks: HooksConfig,
#[serde(default)]
pub limits: LimitsConfig,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub agent: Option,
+}
+
+#[derive(Debug, Serialize, Deserialize, Default)]
+pub struct AgentConfig {
+ #[serde(default)]
+ pub safe_mode: bool,
}
#[derive(Debug, Serialize, Deserialize, Default)]
diff --git a/src/core/mod.rs b/src/core/mod.rs
index d5182bd34..eefae2488 100644
--- a/src/core/mod.rs
+++ b/src/core/mod.rs
@@ -10,6 +10,7 @@ pub mod stream;
pub mod tee;
pub mod telemetry;
pub mod telemetry_cmd;
+pub mod text_encoding;
pub mod toml_filter;
pub mod tracking;
pub mod truncate;
diff --git a/src/core/text_encoding.rs b/src/core/text_encoding.rs
new file mode 100644
index 000000000..707f8b847
--- /dev/null
+++ b/src/core/text_encoding.rs
@@ -0,0 +1,273 @@
+use anyhow::{anyhow, Context, Result};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
+pub enum TextEncoding {
+ Auto,
+ Utf8,
+ Cp949,
+ Latin1,
+ #[value(name = "windows-1252")]
+ Windows1252,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum UsedEncoding {
+ Utf8,
+ Utf16Le,
+ Utf16Be,
+ Cp949,
+ Latin1,
+ Windows1252,
+}
+
+impl UsedEncoding {
+ pub fn label(self) -> &'static str {
+ match self {
+ UsedEncoding::Utf8 => "utf8",
+ UsedEncoding::Utf16Le => "utf16-le",
+ UsedEncoding::Utf16Be => "utf16-be",
+ UsedEncoding::Cp949 => "cp949",
+ UsedEncoding::Latin1 => "latin1",
+ UsedEncoding::Windows1252 => "windows-1252",
+ }
+ }
+}
+
+pub struct DecodedText {
+ pub text: String,
+ pub used: UsedEncoding,
+ /// True when `--encoding auto` selected a non-UTF8 fallback.
+ pub used_fallback: bool,
+}
+
+pub fn decode_bytes(bytes: &[u8], requested: TextEncoding) -> Result {
+ // Always honor UTF-16 BOMs first (MSBuild logs on Windows).
+ if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE {
+ return Ok(DecodedText {
+ text: decode_utf16(&bytes[2..], true),
+ used: UsedEncoding::Utf16Le,
+ used_fallback: false,
+ });
+ }
+ if bytes.len() >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF {
+ return Ok(DecodedText {
+ text: decode_utf16(&bytes[2..], false),
+ used: UsedEncoding::Utf16Be,
+ used_fallback: false,
+ });
+ }
+
+ let (payload, had_utf8_bom) = if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
+ (&bytes[3..], true)
+ } else {
+ (bytes, false)
+ };
+
+ match requested {
+ TextEncoding::Auto => {
+ // Heuristic: UTF-16 without BOM (common for some Windows logs / legacy tools).
+ // Check this BEFORE accepting UTF-8 when NUL bytes are present, because UTF-16
+ // payloads like "H\0i\0" are valid UTF-8 but produce unreadable output.
+ if payload.contains(&0) {
+ if let Some(utf16) = detect_utf16_no_bom(payload) {
+ return Ok(DecodedText {
+ text: utf16.text,
+ used: utf16.used,
+ used_fallback: true,
+ });
+ }
+ }
+
+ if let Ok(s) = std::str::from_utf8(payload) {
+ return Ok(DecodedText {
+ text: s.to_string(),
+ used: UsedEncoding::Utf8,
+ used_fallback: false,
+ });
+ }
+
+ // Windows-ish fallbacks first (common for legacy C++ source / logs).
+ //
+ // Note: WINDOWS-1252 decoding is permissive for all bytes, so try a
+ // stricter multibyte encoding first when explicitly supported.
+ for enc in [TextEncoding::Cp949, TextEncoding::Windows1252] {
+ if let Ok(dt) = decode_bytes(payload, enc) {
+ return Ok(DecodedText {
+ text: if had_utf8_bom {
+ // Should not happen (UTF-8 BOM implies UTF-8), but keep behavior explicit.
+ dt.text
+ } else {
+ dt.text
+ },
+ used: dt.used,
+ used_fallback: true,
+ });
+ }
+ }
+
+ // Last resort: byte-safe 1:1 mapping.
+ let dt = decode_bytes(payload, TextEncoding::Latin1)?;
+ Ok(DecodedText {
+ text: dt.text,
+ used: dt.used,
+ used_fallback: true,
+ })
+ }
+ TextEncoding::Utf8 => Ok(DecodedText {
+ text: String::from_utf8(payload.to_vec())
+ .context("stream did not contain valid UTF-8")?,
+ used: UsedEncoding::Utf8,
+ used_fallback: false,
+ }),
+ TextEncoding::Windows1252 => {
+ let (cow, _, had_errors) = encoding_rs::WINDOWS_1252.decode(payload);
+ if had_errors {
+ return Err(anyhow!("invalid bytes for windows-1252"));
+ }
+ Ok(DecodedText {
+ text: cow.into_owned(),
+ used: UsedEncoding::Windows1252,
+ used_fallback: false,
+ })
+ }
+ TextEncoding::Cp949 => {
+ let (cow, _, had_errors) = encoding_rs::EUC_KR.decode(payload);
+ if had_errors {
+ return Err(anyhow!("invalid bytes for cp949"));
+ }
+ Ok(DecodedText {
+ text: cow.into_owned(),
+ used: UsedEncoding::Cp949,
+ used_fallback: false,
+ })
+ }
+ TextEncoding::Latin1 => {
+ let text: String = payload.iter().map(|b| *b as char).collect();
+ Ok(DecodedText {
+ text,
+ used: UsedEncoding::Latin1,
+ used_fallback: false,
+ })
+ }
+ }
+}
+
+struct Utf16Guess {
+ text: String,
+ used: UsedEncoding,
+}
+
+fn detect_utf16_no_bom(payload: &[u8]) -> Option {
+ #[allow(clippy::manual_is_multiple_of)]
+ if payload.len() < 4 || payload.len() % 2 != 0 {
+ return None;
+ }
+
+ let mut zeros_even = 0usize;
+ let mut zeros_odd = 0usize;
+ let mut pairs = 0usize;
+ for chunk in payload.chunks_exact(2).take(4096) {
+ pairs += 1;
+ if chunk[0] == 0 {
+ zeros_even += 1;
+ }
+ if chunk[1] == 0 {
+ zeros_odd += 1;
+ }
+ }
+ if pairs == 0 {
+ return None;
+ }
+
+ let even_ratio = zeros_even as f64 / pairs as f64;
+ let odd_ratio = zeros_odd as f64 / pairs as f64;
+
+ // For ASCII-ish UTF-16, every other byte is often 0x00 AND the other lane is
+ // mostly printable ASCII.
+ const THRESH: f64 = 0.60;
+ if odd_ratio >= THRESH && even_ratio < 0.10 && ascii_lane_ratio(payload, 0) >= 0.85 {
+ return Some(Utf16Guess {
+ text: decode_utf16(payload, true),
+ used: UsedEncoding::Utf16Le,
+ });
+ }
+ if even_ratio >= THRESH && odd_ratio < 0.10 && ascii_lane_ratio(payload, 1) >= 0.85 {
+ return Some(Utf16Guess {
+ text: decode_utf16(payload, false),
+ used: UsedEncoding::Utf16Be,
+ });
+ }
+
+ None
+}
+
+fn ascii_lane_ratio(payload: &[u8], lane: usize) -> f64 {
+ let mut total = 0usize;
+ let mut ascii = 0usize;
+ for chunk in payload.chunks_exact(2).take(4096) {
+ let b = chunk[lane];
+ if b == 0 {
+ continue;
+ }
+ total += 1;
+ let is_ascii = b == b'\t' || b == b'\n' || b == b'\r' || (0x20..=0x7E).contains(&b);
+ if is_ascii {
+ ascii += 1;
+ }
+ }
+ if total == 0 {
+ 0.0
+ } else {
+ ascii as f64 / total as f64
+ }
+}
+
+pub fn encode_text(text: &str, encoding: UsedEncoding) -> Result> {
+ match encoding {
+ UsedEncoding::Utf8 => Ok(text.as_bytes().to_vec()),
+ UsedEncoding::Utf16Le => {
+ // Keep it simple: patch currently does not target UTF-16 paths.
+ Err(anyhow!("encoding utf16-le output is not supported"))
+ }
+ UsedEncoding::Utf16Be => Err(anyhow!("encoding utf16-be output is not supported")),
+ UsedEncoding::Windows1252 => {
+ let (cow, _, had_errors) = encoding_rs::WINDOWS_1252.encode(text);
+ if had_errors {
+ return Err(anyhow!("text not representable in windows-1252"));
+ }
+ Ok(cow.into_owned())
+ }
+ UsedEncoding::Cp949 => {
+ let (cow, _, had_errors) = encoding_rs::EUC_KR.encode(text);
+ if had_errors {
+ return Err(anyhow!("text not representable in cp949"));
+ }
+ Ok(cow.into_owned())
+ }
+ UsedEncoding::Latin1 => {
+ let mut out = Vec::with_capacity(text.len());
+ for ch in text.chars() {
+ let u = ch as u32;
+ if u > 0xFF {
+ return Err(anyhow!("text not representable in latin1"));
+ }
+ out.push(u as u8);
+ }
+ Ok(out)
+ }
+ }
+}
+
+fn decode_utf16(bytes: &[u8], little_endian: bool) -> String {
+ let units: Vec = bytes
+ .chunks_exact(2)
+ .map(|c| {
+ if little_endian {
+ u16::from_le_bytes([c[0], c[1]])
+ } else {
+ u16::from_be_bytes([c[0], c[1]])
+ }
+ })
+ .collect();
+ String::from_utf16_lossy(&units)
+}
diff --git a/src/discover/registry.rs b/src/discover/registry.rs
index 4fd716828..51e9ba09d 100644
--- a/src/discover/registry.rs
+++ b/src/discover/registry.rs
@@ -73,6 +73,40 @@ lazy_static! {
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();
+
+ // PowerShell: Select-String → rtk grep (limited, safety-first).
+ //
+ // We only rewrite when we can prove equivalence. Most importantly:
+ // - Select-String is case-insensitive by default; rg/grep are not.
+ // - Pipelines ($_.FullName) and multi-pattern arrays are not safely rewritable here.
+ static ref SELECT_STRING_PATH_FIRST: Regex = Regex::new(
+ r#"(?i)^Select-String\s+.*?-Path\s+(\S+).*?-Pattern\s+("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\S+)(?:\s|$)"#
+ ).unwrap();
+ static ref SELECT_STRING_PATTERN_FIRST: Regex = Regex::new(
+ r#"(?i)^Select-String\s+.*?-Pattern\s+("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\S+).*?-Path\s+(\S+)(?:\s|$)"#
+ ).unwrap();
+ static ref SELECT_STRING_CASE_SENSITIVE_RE: Regex = Regex::new(r"(?i)\s-CaseSensitive(?:\s|$)").unwrap();
+ static ref SELECT_STRING_SIMPLE_MATCH_RE: Regex = Regex::new(r"(?i)\s-SimpleMatch(?:\s|$)").unwrap();
+ static ref SELECT_STRING_CONTEXT_RE: Regex = Regex::new(r"(?i)\s-Context\s+(\d+)\s*,\s*(\d+)(?:\s|$)").unwrap();
+ static ref SELECT_STRING_RECURSE_RE: Regex = Regex::new(r"(?i)\s-Recurse(?:\s|$)").unwrap();
+
+ // PowerShell: Get-Content / GC → rtk read
+ static ref GET_CONTENT_RE: Regex =
+ Regex::new(r"(?i)^(?:Get-Content|GC)\s+(\S+)").unwrap();
+
+ // PowerShell: Remove-Item → rtk remove-item (preserves all original args)
+ static ref REMOVE_ITEM_RE: Regex = Regex::new(r"(?i)^Remove-Item\b").unwrap();
+
+ // NOTE: a previous `MSBUILD_REDIRECT_RE` rule attempted to detect
+ // `msbuild ... *> file.log` and emit a hint pointing at the log. It was
+ // removed because PowerShell consumes `*>` as an all-streams redirect
+ // operator BEFORE the command reaches RTK's hook — by the time
+ // `rtk rewrite` is invoked, the command string is just `msbuild ...`
+ // (no `*>`, no log path). The rule only ever fired on quoted/escaped
+ // inputs that bypass the shell, which never occurs in real Claude Code
+ // hook traffic. Document the limitation in user-facing docs (RTK.md):
+ // when output is redirected with `*>`, use `rtk read ` after
+ // the build completes.
}
const GOLANGCI_GLOBAL_OPT_WITH_VALUE: &[&str] = &[
@@ -557,6 +591,26 @@ fn rewrite_compound(
}
TokenKind::Pipe => {
let seg = cmd[seg_start..tok.offset].trim();
+ let pipe_group_end = tokens.iter().find(|t| {
+ t.offset > tok.offset
+ && (t.kind == TokenKind::Operator
+ || (t.kind == TokenKind::Shellism && t.value == "&"))
+ });
+ let pipe_end = pipe_group_end.map(|t| t.offset).unwrap_or(cmd.len());
+ let pipe_group = cmd[tok.offset..pipe_end].trim();
+
+ if let Some(rewritten) = try_rewrite_powershell_pipe_group(seg, pipe_group) {
+ if rewritten != seg {
+ any_changed = true;
+ }
+ result.push_str(&rewritten);
+ seg_start = pipe_end;
+ if pipe_group_end.is_none() {
+ return if any_changed { Some(result) } else { None };
+ }
+ continue;
+ }
+
let is_pipe_incompatible = seg.starts_with("find ")
|| seg == "find"
|| seg.starts_with("fd ")
@@ -572,12 +626,6 @@ fn rewrite_compound(
}
result.push_str(&rewritten);
- let pipe_group_end = tokens.iter().find(|t| {
- t.offset > tok.offset
- && (t.kind == TokenKind::Operator
- || (t.kind == TokenKind::Shellism && t.value == "&"))
- });
-
match pipe_group_end {
Some(next_op) => {
result.push(' ');
@@ -714,6 +762,277 @@ fn rewrite_segment(
rewrite_segment_inner(seg, excluded, transparent_prefixes, 0)
}
+/// Rewrite PowerShell built-ins (`Select-String`, `Get-Content`/`GC`, `Remove-Item`)
+/// to their RTK equivalents. Returns `None` if no PowerShell pattern matches.
+fn try_powershell_rewrite(cmd: &str) -> Option {
+ if let Some(caps) = SELECT_STRING_PATH_FIRST
+ .captures(cmd)
+ .or_else(|| SELECT_STRING_PATTERN_FIRST.captures(cmd))
+ {
+ // Capture order differs between the two regexes: disambiguate by checking
+ // whether `-Path` appears before `-Pattern` in the original command.
+ let lower = cmd.to_ascii_lowercase();
+ let path_before_pattern = match (lower.find("-path"), lower.find("-pattern")) {
+ (Some(p), Some(q)) => p < q,
+ _ => false,
+ };
+ let (pattern, path) = if path_before_pattern {
+ (caps.get(2)?.as_str(), caps.get(1)?.as_str())
+ } else {
+ (caps.get(1)?.as_str(), caps.get(2)?.as_str())
+ };
+
+ // Safety: do not rewrite pipeline placeholders or obvious variables.
+ // Those depend on runtime values (e.g. $_.FullName) that rtk rewrite cannot evaluate.
+ if path.contains("$_") || path.starts_with('$') {
+ return None;
+ }
+ // Safety: multi-pattern arrays ("x","y") are not representable in a single rtk grep pattern arg.
+ if pattern.contains(',') {
+ return None;
+ }
+ // Safety: Select-String -Recurse with wildcard paths relies on PowerShell expansion semantics.
+ // rtk grep is recursive by default but cannot reliably reproduce PS globbing here.
+ if SELECT_STRING_RECURSE_RE.is_match(cmd) && (path.contains('*') || path.contains('?')) {
+ return None;
+ }
+
+ let mut out = String::new();
+ out.push_str("rtk grep ");
+
+ if SELECT_STRING_SIMPLE_MATCH_RE.is_match(cmd) {
+ out.push_str("--fixed ");
+ }
+
+ out.push_str(pattern);
+ out.push(' ');
+ out.push_str(path);
+
+ // Select-String is case-insensitive by default.
+ if !SELECT_STRING_CASE_SENSITIVE_RE.is_match(cmd) {
+ out.push_str(" -- -i");
+ }
+
+ // Context window: -Context before,after
+ if let Some(ctx) = SELECT_STRING_CONTEXT_RE.captures(cmd) {
+ let before = ctx.get(1)?.as_str();
+ let after = ctx.get(2)?.as_str();
+ out.push_str(&format!(" -B {} -A {}", before, after));
+ }
+
+ return Some(out);
+ }
+
+ // PowerShell: Get-ChildItem / gci / dir → rtk gci (subset)
+ {
+ let lower = cmd.trim_start().to_ascii_lowercase();
+ if lower.starts_with("get-childitem")
+ || lower.starts_with("gci")
+ || lower.starts_with("dir")
+ {
+ if let Some(rewritten) = try_rewrite_powershell_get_child_item(cmd) {
+ return Some(rewritten);
+ }
+ }
+ }
+
+ if let Some(caps) = GET_CONTENT_RE.captures(cmd) {
+ let path = caps.get(1)?.as_str();
+ return Some(format!("rtk read {}", path));
+ }
+
+ if REMOVE_ITEM_RE.is_match(cmd) {
+ let rest = cmd[cmd.find(|c: char| c.is_whitespace()).unwrap_or(cmd.len())..].trim_start();
+ if rest.is_empty() {
+ return Some("rtk remove-item".to_string());
+ }
+ return Some(format!("rtk remove-item {}", rest));
+ }
+
+ None
+}
+
+fn try_rewrite_powershell_pipe_group(left: &str, pipe_group: &str) -> Option {
+ // Only supports: Get-ChildItem ... | Select-Object FullName,LastWriteTime,Length
+ // Rewrites the whole pipe group into: rtk gci ... --select ...
+ let lower_left = left.trim_start().to_ascii_lowercase();
+ if !(lower_left.starts_with("get-childitem")
+ || lower_left.starts_with("gci")
+ || lower_left.starts_with("dir"))
+ {
+ return None;
+ }
+
+ let pg = pipe_group.trim();
+ let pg_lower = pg.to_ascii_lowercase();
+ if !pg_lower.starts_with("| select-object") {
+ return None;
+ }
+
+ // Very small, safety-first parser: do not attempt to evaluate variables.
+ if left.contains("$_") || left.contains('$') {
+ return None;
+ }
+
+ // Extract the property list after Select-Object.
+ let props = pg.split_once(char::is_whitespace)?.1.trim(); // "Select-Object ..."
+ let props = props
+ .strip_prefix("Select-Object")
+ .or_else(|| props.strip_prefix("select-object"))?
+ .trim();
+ if props.is_empty() {
+ return None;
+ }
+
+ let rewritten_left = try_rewrite_powershell_get_child_item(left)?;
+ Some(format!("{} --select {}", rewritten_left, props))
+}
+
+fn try_rewrite_powershell_get_child_item(cmd: &str) -> Option {
+ // Supports a small subset of PowerShell Get-ChildItem/gci/dir flags and rewrites
+ // to `rtk gci` with equivalent-ish behavior.
+ //
+ // Safety: no variables, no pipeline placeholders.
+ if cmd.contains("$_") || cmd.contains('$') {
+ return None;
+ }
+
+ let mut tokens: Vec = split_powershell_args_quote_aware(cmd)?;
+ if tokens.is_empty() {
+ return None;
+ }
+
+ // Drop the leading command word (Get-ChildItem/gci/dir)
+ tokens.remove(0);
+
+ let mut path: Option = None;
+ let mut recurse = false;
+ let mut force = false;
+ let mut kind_file = false;
+ let mut kind_dir = false;
+ let mut filter: Option = None;
+ let mut include: Option = None;
+
+ let mut i = 0;
+ while i < tokens.len() {
+ let t = tokens[i].as_str();
+ let lower = t.to_ascii_lowercase();
+ if !t.starts_with('-') && path.is_none() {
+ path = Some(tokens[i].clone());
+ i += 1;
+ continue;
+ }
+ match lower.as_str() {
+ "-recurse" => {
+ recurse = true;
+ i += 1;
+ }
+ "-force" => {
+ force = true;
+ i += 1;
+ }
+ "-file" => {
+ kind_file = true;
+ i += 1;
+ }
+ "-directory" => {
+ kind_dir = true;
+ i += 1;
+ }
+ "-filter" => {
+ filter = tokens.get(i + 1).cloned();
+ i += 2;
+ }
+ "-include" => {
+ include = tokens.get(i + 1).cloned();
+ i += 2;
+ }
+ _ => {
+ // Unknown flag → do not rewrite (safety).
+ return None;
+ }
+ }
+ }
+
+ let mut out = String::new();
+ out.push_str("rtk gci ");
+ out.push_str(path.as_deref().unwrap_or("."));
+ if recurse {
+ out.push_str(" --recurse");
+ }
+ if force {
+ out.push_str(" --force");
+ }
+ if kind_file {
+ out.push_str(" --file");
+ } else if kind_dir {
+ out.push_str(" --directory");
+ }
+ if let Some(f) = filter {
+ out.push_str(" --filter ");
+ out.push_str(&f);
+ }
+ if let Some(inc) = include {
+ out.push_str(" --include ");
+ out.push_str(&inc);
+ }
+ Some(out)
+}
+
+fn split_powershell_args_quote_aware(cmd: &str) -> Option> {
+ let mut out: Vec = Vec::new();
+ let mut cur = String::new();
+ let mut quote: Option = None;
+ let mut escape = false;
+
+ for ch in cmd.chars() {
+ if escape {
+ cur.push(ch);
+ escape = false;
+ continue;
+ }
+
+ if quote == Some('"') && ch == '\\' {
+ // Treat backslash-escaped chars inside double quotes as literal.
+ escape = true;
+ cur.push(ch);
+ continue;
+ }
+
+ if let Some(q) = quote {
+ cur.push(ch);
+ if ch == q {
+ quote = None;
+ }
+ continue;
+ }
+
+ if ch == '"' || ch == '\'' {
+ quote = Some(ch);
+ cur.push(ch);
+ continue;
+ }
+
+ if ch.is_whitespace() {
+ if !cur.is_empty() {
+ out.push(cur.clone());
+ cur.clear();
+ }
+ continue;
+ }
+
+ cur.push(ch);
+ }
+
+ if quote.is_some() {
+ return None;
+ }
+ if !cur.is_empty() {
+ out.push(cur);
+ }
+ Some(out)
+}
+
fn is_excluded(cmd: &str, excluded: &[ExcludePattern]) -> bool {
excluded.iter().any(|pat| match pat {
ExcludePattern::Regex(re) => re.is_match(cmd),
@@ -774,6 +1093,17 @@ fn rewrite_segment_inner(
}
}
+ // PowerShell-specific special cases. These rewrites do not pass through the
+ // standard prefix-swap path because the source command and rtk command have
+ // different argument orderings (Select-String) or fixed shapes.
+ if let Some(rewritten) = try_powershell_rewrite(trimmed) {
+ return Some(rewritten);
+ }
+
+ // (PowerShell `msbuild ... *> file.log` cannot be intercepted at rewrite
+ // time — the shell consumes `*>` before RTK sees the command string.
+ // See registry-level comment near MSBUILD_REDIRECT_RE removal.)
+
// Strip trailing stderr/stdout redirects before matching (#530)
// e.g. "git status 2>&1" → match "git status", re-append " 2>&1"
let (cmd_part, redirect_suffix) = strip_trailing_redirects(trimmed);
@@ -783,6 +1113,15 @@ fn rewrite_segment_inner(
return Some(trimmed.to_string());
}
+ // make install/clean/distclean — pass through unchanged. The regex crate
+ // does not support negative lookahead, so this is enforced procedurally.
+ if let Some(rest) = cmd_part.strip_prefix("make ") {
+ let target = rest.split_whitespace().next().unwrap_or("");
+ if matches!(target, "install" | "clean" | "distclean") {
+ return None;
+ }
+ }
+
if cmd_part.starts_with("head -") || cmd_part.starts_with("tail ") {
return rewrite_line_range(cmd_part).map(|r| format!("{}{}", r, redirect_suffix));
}
@@ -1233,6 +1572,86 @@ mod tests {
);
}
+ #[test]
+ fn test_rewrite_powershell_select_string_default_case_insensitive() {
+ assert_eq!(
+ rewrite_command_no_prefixes("Select-String -Path f -Pattern p", &[]),
+ Some("rtk grep p f -- -i".into())
+ );
+ }
+
+ #[test]
+ fn test_rewrite_powershell_select_string_case_sensitive() {
+ assert_eq!(
+ rewrite_command_no_prefixes("Select-String -Path f -Pattern p -CaseSensitive", &[]),
+ Some("rtk grep p f".into())
+ );
+ }
+
+ #[test]
+ fn test_rewrite_powershell_select_string_simple_match_and_context() {
+ assert_eq!(
+ rewrite_command_no_prefixes(
+ "Select-String -Path f -Pattern p -SimpleMatch -Context 2,3",
+ &[]
+ ),
+ Some("rtk grep --fixed p f -- -i -B 2 -A 3".into())
+ );
+ }
+
+ #[test]
+ fn test_rewrite_powershell_select_string_skips_variable_path() {
+ assert_eq!(
+ rewrite_command_no_prefixes("Select-String -Path $p -Pattern p", &[]),
+ None
+ );
+ assert_eq!(
+ rewrite_command_no_prefixes("Select-String -Path $_.FullName -Pattern p", &[]),
+ None
+ );
+ }
+
+ #[test]
+ fn test_rewrite_powershell_get_child_item_simple() {
+ assert_eq!(
+ rewrite_command_no_prefixes("Get-ChildItem . -Recurse -File -Filter API_win.obj", &[]),
+ Some("rtk gci . --recurse --file --filter API_win.obj".into())
+ );
+ }
+
+ #[test]
+ fn test_rewrite_powershell_get_child_item_quoted_path_and_filter() {
+ assert_eq!(
+ rewrite_command_no_prefixes(
+ "Get-ChildItem \"C:\\My Path\" -Recurse -File -Filter \"API win.obj\"",
+ &[]
+ ),
+ Some("rtk gci \"C:\\My Path\" --recurse --file --filter \"API win.obj\"".into())
+ );
+ }
+
+ #[test]
+ fn test_rewrite_powershell_get_child_item_quoted_glob_filter() {
+ assert_eq!(
+ rewrite_command_no_prefixes("Get-ChildItem . -Filter \"*.CPP\"", &[]),
+ Some("rtk gci . --filter \"*.CPP\"".into())
+ );
+ }
+
+ #[test]
+ fn test_rewrite_powershell_get_child_item_pipe_select_object_absorbed() {
+ assert_eq!(
+ rewrite_command_no_prefixes(
+ "Get-ChildItem . -Recurse -Force -File -Filter API_win.obj | Select-Object FullName,LastWriteTime,Length",
+ &[]
+ ),
+ Some(
+ "rtk gci . --recurse --force --file --filter API_win.obj --select FullName,LastWriteTime,Length"
+ .into()
+ )
+ );
+ }
+
// --- git -C support (#555) ---
#[test]
diff --git a/src/discover/rules.rs b/src/discover/rules.rs
index df7c72d03..5be99ec62 100644
--- a/src/discover/rules.rs
+++ b/src/discover/rules.rs
@@ -878,6 +878,66 @@ pub const RULES: &[RtkRule] = &[
subcmd_savings: &[],
subcmd_status: &[],
},
+ // C++ / CMake / MSBuild
+ RtkRule {
+ pattern: r"^cmake\s+(--build|-B)\b",
+ rtk_cmd: "rtk cmake",
+ rewrite_prefixes: &["cmake"],
+ category: "Build",
+ savings_pct: 80.0,
+ subcmd_savings: &[("--build", 85.0), ("-B", 70.0)],
+ subcmd_status: &[],
+ },
+ RtkRule {
+ pattern: r"^ctest(?:\s|$)",
+ rtk_cmd: "rtk ctest",
+ rewrite_prefixes: &["ctest"],
+ category: "Tests",
+ savings_pct: 85.0,
+ subcmd_savings: &[],
+ subcmd_status: &[],
+ },
+ // `make` rule covers any target except install/clean/distclean — the lifecycle
+ // exclusion is enforced in registry::rewrite_segment_inner since the regex
+ // crate does not support lookahead.
+ RtkRule {
+ pattern: r"^make(?:\s|$)",
+ rtk_cmd: "rtk make",
+ rewrite_prefixes: &["make"],
+ category: "Build",
+ savings_pct: 80.0,
+ subcmd_savings: &[],
+ subcmd_status: &[],
+ },
+ RtkRule {
+ pattern: r"^ninja(?:\s|$)",
+ rtk_cmd: "rtk ninja",
+ rewrite_prefixes: &["ninja"],
+ category: "Build",
+ savings_pct: 80.0,
+ subcmd_savings: &[],
+ subcmd_status: &[],
+ },
+ RtkRule {
+ pattern: r"^msbuild(?:\s|$)",
+ rtk_cmd: "rtk msbuild",
+ rewrite_prefixes: &["msbuild"],
+ category: "Build",
+ savings_pct: 80.0,
+ subcmd_savings: &[],
+ subcmd_status: &[],
+ },
+ // codegraph: only the listed CLI subcommands are rewritten. `serve`, `install`,
+ // `watch`, `visualize`, `doctor`, and `config` deliberately fall through.
+ RtkRule {
+ pattern: r"^codegraph\s+(index|update|stats|find-symbol|search|callers|callees|impact|affected-tests)\b",
+ rtk_cmd: "rtk codegraph",
+ rewrite_prefixes: &["codegraph"],
+ category: "Build",
+ savings_pct: 85.0,
+ subcmd_savings: &[("index", 90.0), ("update", 90.0)],
+ subcmd_status: &[],
+ },
];
pub const IGNORED_PREFIXES: &[&str] = &[
diff --git a/src/hooks/constants.rs b/src/hooks/constants.rs
index 23d2e1089..3094a63c5 100644
--- a/src/hooks/constants.rs
+++ b/src/hooks/constants.rs
@@ -25,7 +25,9 @@ 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";
+#[allow(dead_code)]
pub const COPILOT_USER_DIR: &str = ".copilot";
+#[allow(dead_code)]
pub const COPILOT_HOME_ENV: &str = "COPILOT_HOME";
pub const PI_DIR: &str = ".pi/agent";
diff --git a/src/hooks/init.rs b/src/hooks/init.rs
index 4c68ec49b..1b4b73164 100644
--- a/src/hooks/init.rs
+++ b/src/hooks/init.rs
@@ -3960,6 +3960,7 @@ fn run_copilot_at(base: &Path, ctx: InitContext) -> Result<()> {
}
/// Entry point for `rtk init --uninstall --copilot` (project-scoped, like install).
+#[allow(dead_code)]
pub fn uninstall_copilot(ctx: InitContext) -> Result<()> {
let InitContext { dry_run, .. } = ctx;
let removed = uninstall_copilot_at(Path::new("."), ctx)?;
@@ -3988,6 +3989,7 @@ pub fn uninstall_copilot(ctx: InitContext) -> Result<()> {
}
/// Same as [`uninstall_copilot`] but operates relative to an explicit base path.
+#[allow(dead_code)]
fn uninstall_copilot_at(base: &Path, ctx: InitContext) -> Result> {
let InitContext { dry_run, .. } = ctx;
let github_dir = base.join(GITHUB_DIR);
@@ -4036,6 +4038,7 @@ fn uninstall_copilot_at(base: &Path, ctx: InitContext) -> Result> {
Ok(removed)
}
+#[allow(dead_code)]
fn copilot_user_dir() -> Result {
if let Ok(custom) = std::env::var(COPILOT_HOME_ENV) {
return Ok(PathBuf::from(custom));
@@ -4044,11 +4047,13 @@ fn copilot_user_dir() -> Result {
Ok(home.join(COPILOT_USER_DIR))
}
+#[allow(dead_code)]
pub fn run_copilot_global(ctx: InitContext) -> Result<()> {
let copilot_dir = copilot_user_dir()?;
run_copilot_global_at(&copilot_dir, ctx)
}
+#[allow(dead_code)]
fn run_copilot_global_at(copilot_dir: &Path, ctx: InitContext) -> Result<()> {
let InitContext { dry_run, .. } = ctx;
let hooks_dir = copilot_dir.join(HOOKS_SUBDIR);
@@ -4088,6 +4093,7 @@ fn run_copilot_global_at(copilot_dir: &Path, ctx: InitContext) -> Result<()> {
Ok(())
}
+#[allow(dead_code)]
pub fn uninstall_copilot_global(ctx: InitContext) -> Result<()> {
let copilot_dir = copilot_user_dir()?;
let InitContext { dry_run, .. } = ctx;
@@ -4116,6 +4122,7 @@ pub fn uninstall_copilot_global(ctx: InitContext) -> Result<()> {
Ok(())
}
+#[allow(dead_code)]
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);
diff --git a/src/main.rs b/src/main.rs
index 992f865a2..60a85424d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -8,6 +8,7 @@ mod parser;
// Re-export command modules for routing
use cmds::cloud::{aws_cmd, container, curl_cmd, psql_cmd, wget_cmd};
+use cmds::cpp::{cmake_cmd, codegraph_cmd, ctest_cmd, make_cmd, msbuild_cmd, remove_item};
use cmds::dotnet::{binlog, dotnet_cmd, dotnet_format_report, dotnet_trx};
use cmds::git::{diff_cmd, gh_cmd, git, glab_cmd, gt_cmd};
use cmds::go::{go_cmd, golangci_cmd};
@@ -20,8 +21,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, gci_cmd, grep_cmd, json_cmd, local_llm, log_cmd, ls,
+ patch, pipe_cmd, read, summary, tree, wc_cmd,
};
use anyhow::{Context, Result};
@@ -30,6 +31,175 @@ use clap::{Parser, Subcommand, ValueEnum};
use std::ffi::OsString;
use std::path::{Path, PathBuf};
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+struct GrepEffectiveLimits {
+ max_line_chars: Option,
+ max_matches: Option,
+ max_per_file: Option,
+ summary_enabled: bool,
+}
+
+#[allow(clippy::too_many_arguments)]
+fn compute_grep_effective_limits(
+ max_len: usize,
+ max: usize,
+ all: bool,
+ full_lines: bool,
+ agent_safe: bool,
+ max_matches: Option,
+ max_per_file: Option,
+ max_line_chars: Option,
+) -> GrepEffectiveLimits {
+ // Keep legacy defaults unless user opts in. `--agent-safe` supplies caps unless
+ // explicit override flags are present. `--all` forces uncapped.
+ let mut effective_max_matches: Option = None;
+ let mut effective_max_per_file: Option = None;
+ let mut effective_max_line_chars: Option = None;
+
+ if agent_safe {
+ effective_max_matches = Some(80);
+ effective_max_per_file = Some(5);
+ effective_max_line_chars = Some(240);
+ }
+
+ if let Some(n) = max_matches {
+ effective_max_matches = Some(n);
+ }
+ if let Some(n) = max_per_file {
+ effective_max_per_file = Some(n);
+ }
+ if let Some(n) = max_line_chars {
+ effective_max_line_chars = Some(n);
+ }
+
+ // Back-compat: legacy flags set the baseline when not using explicit new overrides.
+ if effective_max_matches.is_none() {
+ effective_max_matches = Some(max);
+ }
+ if effective_max_line_chars.is_none() {
+ effective_max_line_chars = Some(max_len);
+ }
+
+ if full_lines {
+ effective_max_line_chars = None;
+ }
+ if all {
+ effective_max_matches = None;
+ effective_max_per_file = None;
+ }
+
+ let summary_enabled =
+ agent_safe || max_matches.is_some() || max_per_file.is_some() || max_line_chars.is_some();
+
+ GrepEffectiveLimits {
+ max_line_chars: effective_max_line_chars,
+ max_matches: effective_max_matches,
+ max_per_file: effective_max_per_file,
+ summary_enabled,
+ }
+}
+
+fn parse_truthy_env_var(name: &str) -> bool {
+ let Ok(v) = std::env::var(name) else {
+ return false;
+ };
+ matches!(
+ v.trim().to_ascii_lowercase().as_str(),
+ "1" | "true" | "yes" | "y" | "on"
+ )
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+struct GrepCliFixups {
+ top_files: Option,
+ json: bool,
+}
+
+struct GrepCliArgs {
+ files_only: bool,
+ count_by_file: bool,
+ all: bool,
+ max_matches: Option,
+ max_per_file: Option,
+ max_line_chars: Option,
+ full_lines: bool,
+ agent_safe: bool,
+ summary_enabled: bool,
+ fixups: GrepCliFixups,
+}
+
+fn apply_grep_rtk_flags_from_extra_args(
+ state: &mut GrepCliArgs,
+ extra_args: Vec,
+) -> Result> {
+ let mut forwarded: Vec = Vec::new();
+ let mut i = 0usize;
+ while i < extra_args.len() {
+ let a = &extra_args[i];
+
+ let take_value = |name: &str| -> Result {
+ let Some(v) = extra_args.get(i + 1) else {
+ return Err(anyhow::anyhow!("missing value for {}", name));
+ };
+ Ok(v.clone())
+ };
+
+ match a.as_str() {
+ "--files-only" => {
+ state.files_only = true;
+ i += 1;
+ }
+ "--count-by-file" => {
+ state.count_by_file = true;
+ i += 1;
+ }
+ "--all" => {
+ state.all = true;
+ i += 1;
+ }
+ "--max-matches" => {
+ let v = take_value("--max-matches")?;
+ state.max_matches = Some(v.parse().context("invalid --max-matches")?);
+ i += 2;
+ }
+ "--max-per-file" => {
+ let v = take_value("--max-per-file")?;
+ state.max_per_file = Some(v.parse().context("invalid --max-per-file")?);
+ i += 2;
+ }
+ "--max-line-chars" => {
+ let v = take_value("--max-line-chars")?;
+ state.max_line_chars = Some(v.parse().context("invalid --max-line-chars")?);
+ i += 2;
+ }
+ "--full-lines" => {
+ state.full_lines = true;
+ i += 1;
+ }
+ "--agent-safe" => {
+ state.agent_safe = true;
+ state.summary_enabled = true;
+ i += 1;
+ }
+ "--top-files" => {
+ let v = take_value("--top-files")?;
+ state.fixups.top_files = Some(v.parse().context("invalid --top-files")?);
+ i += 2;
+ }
+ "--json" => {
+ state.fixups.json = true;
+ i += 1;
+ }
+ _ => {
+ forwarded.push(a.clone());
+ i += 1;
+ }
+ }
+ }
+
+ Ok(forwarded)
+}
+
/// Target agent for hook installation.
#[derive(Debug, Clone, Copy, PartialEq, ValueEnum)]
pub enum AgentTarget {
@@ -105,9 +275,37 @@ enum Commands {
/// Keep only last N lines
#[arg(long, conflicts_with = "max_lines")]
tail_lines: Option,
+ /// Read only an inclusive line range (START:END, 1-based)
+ #[arg(long, conflicts_with_all = ["max_lines", "tail_lines"])]
+ lines: Option,
/// Show line numbers
#[arg(short = 'n', long)]
line_numbers: bool,
+ /// Input encoding for file decoding
+ #[arg(long, value_enum, default_value = "auto")]
+ encoding: core::text_encoding::TextEncoding,
+ },
+
+ /// Encoding-aware file patch helper (best-effort roundtrip)
+ Patch {
+ /// File to patch
+ #[arg(long)]
+ file: PathBuf,
+ /// Input/output encoding
+ #[arg(long, value_enum, default_value = "auto")]
+ encoding: core::text_encoding::TextEncoding,
+ /// Replace exactly one match by default
+ #[arg(long = "replace")]
+ old: String,
+ /// Replacement string
+ #[arg(long = "with")]
+ new: String,
+ /// Replace all matches
+ #[arg(long)]
+ all: bool,
+ /// Write `.bak` before patching
+ #[arg(long)]
+ backup: bool,
},
/// Generate 2-line technical summary (heuristic-based)
@@ -261,6 +459,37 @@ enum Commands {
args: Vec,
},
+ /// PowerShell-like Get-ChildItem (subset) with compact output
+ Gci {
+ /// Root path
+ #[arg(default_value = ".")]
+ path: PathBuf,
+ /// Recurse into subdirectories
+ #[arg(long)]
+ recurse: bool,
+ /// Include hidden files/directories
+ #[arg(long)]
+ force: bool,
+ /// Files only
+ #[arg(long, conflicts_with = "directory")]
+ file: bool,
+ /// Directories only
+ #[arg(long)]
+ directory: bool,
+ /// Name filter (glob, e.g. "*.cpp" or "API_win.obj")
+ #[arg(long)]
+ filter: Option,
+ /// Include patterns (comma-separated globs)
+ #[arg(long)]
+ include: Option,
+ /// Max results to show
+ #[arg(long, default_value = "50")]
+ max: usize,
+ /// Select properties (comma-separated: FullName,LastWriteTime,Length)
+ #[arg(long)]
+ select: Option,
+ },
+
/// Ultra-condensed diff (only changed lines)
Diff {
/// First file or - for stdin (unified diff)
@@ -273,6 +502,12 @@ enum Commands {
Log {
/// Log file (omit for stdin)
file: Option,
+ /// Show last N matching events (deduped)
+ #[arg(long, default_value = "0")]
+ events: usize,
+ /// Additional keywords to treat as events (can be repeated)
+ #[arg(long = "keyword", action = clap::ArgAction::Append)]
+ keyword: Vec,
},
/// .NET commands with compact output (build/test/restore/format)
@@ -301,6 +536,7 @@ enum Commands {
},
/// Compact grep - strips whitespace, truncates, groups by file
+ #[command(alias = "fgrep")]
Grep {
/// Pattern to search
pattern: String,
@@ -313,6 +549,40 @@ enum Commands {
/// Max results to show
#[arg(short, long, default_value = "200")]
max: usize,
+ /// Print only unique matching file paths (no match lines)
+ #[arg(long, conflicts_with = "count_by_file")]
+ files_only: bool,
+ /// Print one row per matching file: ` ` (no match lines)
+ #[arg(long, conflicts_with = "files_only")]
+ count_by_file: bool,
+ /// Uncapped/full normal match output (no total/per-file caps; does not affect line clipping)
+ #[arg(long)]
+ all: bool,
+ /// Optional total match cap for normal match-line output (does not apply to --files-only/--count-by-file)
+ #[arg(long, conflicts_with = "all")]
+ max_matches: Option,
+ /// Optional max matches per file for normal match-line output (does not apply to --files-only/--count-by-file)
+ #[arg(long, conflicts_with = "all")]
+ max_per_file: Option,
+ /// Optional max displayed line length for normal match-line output
+ #[arg(long, conflicts_with = "full_lines")]
+ max_line_chars: Option,
+ /// Do not clip/truncate match lines (does not affect caps; use --all for uncapped)
+ #[arg(long)]
+ full_lines: bool,
+ /// Convenience preset for token-safe agent usage (explicit flags override)
+ #[arg(long)]
+ agent_safe: bool,
+ /// Show only the top N files by match count (no match lines)
+ #[arg(
+ long,
+ value_parser = clap::value_parser!(usize),
+ conflicts_with_all = ["files_only", "count_by_file"]
+ )]
+ top_files: Option,
+ /// Output JSON only (no human output)
+ #[arg(long)]
+ json: bool,
/// Show only match context (not full line)
#[arg(long)]
context_only: bool,
@@ -322,6 +592,12 @@ enum Commands {
/// Show line numbers (always on, accepted for grep/rg compatibility)
#[arg(short = 'n', long)]
line_numbers: bool,
+ /// Treat pattern as a literal string (fixed)
+ #[arg(long, conflicts_with = "regex")]
+ fixed: bool,
+ /// Treat pattern as a regular expression
+ #[arg(long, conflicts_with = "fixed")]
+ regex: bool,
/// Extra ripgrep arguments (e.g., -i, -A 3, -w, --glob)
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
extra_args: Vec,
@@ -763,6 +1039,100 @@ enum Commands {
#[command(subcommand)]
command: HookCommands,
},
+
+ /// CMake build / configure with compact diagnostics
+ Cmake {
+ #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
+ args: Vec,
+ },
+
+ /// CTest with failure-only output
+ Ctest {
+ #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
+ args: Vec,
+ },
+
+ /// make with errors-only output
+ Make {
+ #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
+ args: Vec,
+ },
+
+ /// ninja with errors-only output
+ Ninja {
+ #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
+ args: Vec,
+ },
+
+ /// MSBuild with MSVC compile/link diagnostics only
+ Msbuild {
+ #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
+ args: Vec,
+ },
+
+ /// codegraph CLI with compact summaries
+ Codegraph {
+ #[command(subcommand)]
+ command: CodegraphCommands,
+ },
+
+ /// PowerShell Remove-Item wrapper (compact ok / error output)
+ #[command(name = "remove-item")]
+ RemoveItem {
+ #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
+ args: Vec,
+ },
+}
+
+#[derive(Debug, Subcommand)]
+enum CodegraphCommands {
+ /// Index a repository (per-file progress stripped)
+ Index {
+ #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
+ args: Vec,
+ },
+ /// Update an existing index (changed files only)
+ Update {
+ #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
+ args: Vec,
+ },
+ /// Repository statistics (decorative noise stripped)
+ Stats {
+ #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
+ args: Vec,
+ },
+ /// Find a symbol by name (truncated to 20 results)
+ #[command(name = "find-symbol")]
+ FindSymbol {
+ #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
+ args: Vec,
+ },
+ /// Semantic search (truncated to 20 results)
+ Search {
+ #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
+ args: Vec,
+ },
+ /// Find callers of a symbol (truncated to 20 results)
+ Callers {
+ #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
+ args: Vec,
+ },
+ /// Find callees of a symbol (truncated to 20 results)
+ Callees {
+ #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
+ args: Vec,
+ },
+ /// Impact analysis (truncated to 20 results)
+ Impact {
+ #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
+ args: Vec,
+ },
+ /// Affected tests — output passed through unchanged for CI consumers
+ #[command(name = "affected-tests")]
+ AffectedTests {
+ #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
+ args: Vec,
+ },
}
#[derive(Debug, Subcommand)]
@@ -1355,18 +1725,26 @@ fn validate_pnpm_filters(filters: &[String], command: &PnpmCommands) -> Option std::result::Result<(usize, usize), String> {
+ let (start_s, end_s) = spec
+ .split_once(':')
+ .ok_or_else(|| "expected START:END".to_string())?;
+ let start: usize = start_s
+ .parse()
+ .map_err(|_| "START must be a positive integer".to_string())?;
+ let end: usize = end_s
+ .parse()
+ .map_err(|_| "END must be a positive integer".to_string())?;
+ if start == 0 || end == 0 {
+ return Err("START and END must be >= 1".to_string());
}
+ if end < start {
+ return Err("END must be >= START".to_string());
+ }
+ Ok((start, end))
+}
+fn main() {
let code = match run_cli() {
Ok(code) => code,
Err(e) => {
@@ -1437,10 +1815,27 @@ fn run_cli() -> Result {
level,
max_lines,
tail_lines,
+ lines,
line_numbers,
+ encoding,
} => {
let mut had_error = false;
let mut stdin_seen = false;
+ let line_range = match lines.as_deref() {
+ None => Ok(None),
+ Some(spec) => parse_line_range(spec).map(Some),
+ };
+ let line_range = match line_range {
+ Ok(v) => v,
+ Err(e) => {
+ eprintln!(
+ "rtk read: invalid --lines '{}': {}",
+ lines.unwrap_or_default(),
+ e
+ );
+ return Ok(2);
+ }
+ };
for file in &files {
let result = if file == Path::new("-") {
if stdin_seen {
@@ -1448,14 +1843,24 @@ fn run_cli() -> Result {
continue;
}
stdin_seen = true;
- read::run_stdin(level, max_lines, tail_lines, line_numbers, cli.verbose)
+ read::run_stdin(
+ level,
+ max_lines,
+ tail_lines,
+ line_range,
+ line_numbers,
+ encoding,
+ cli.verbose,
+ )
} else {
read::run(
file,
level,
max_lines,
tail_lines,
+ line_range,
line_numbers,
+ encoding,
cli.verbose,
)
};
@@ -1471,6 +1876,25 @@ fn run_cli() -> Result {
}
}
+ Commands::Patch {
+ file,
+ encoding,
+ old,
+ new,
+ all,
+ backup,
+ } => patch::run(
+ patch::PatchArgs {
+ file: &file,
+ encoding,
+ old: &old,
+ new: &new,
+ all,
+ backup,
+ },
+ cli.verbose,
+ )?,
+
Commands::Smart {
file,
model,
@@ -1697,6 +2121,44 @@ fn run_cli() -> Result {
0
}
+ Commands::Gci {
+ path,
+ recurse,
+ force,
+ file,
+ directory,
+ filter,
+ include,
+ max,
+ select,
+ } => {
+ let mut parsed = gci_cmd::GciArgs {
+ path,
+ recurse,
+ force,
+ max,
+ filter,
+ ..Default::default()
+ };
+ if file {
+ parsed.kind = gci_cmd::GciKind::File;
+ } else if directory {
+ parsed.kind = gci_cmd::GciKind::Directory;
+ }
+ if let Some(spec) = include {
+ parsed.include = spec
+ .split(',')
+ .map(|s| s.trim().to_string())
+ .filter(|s| !s.is_empty())
+ .collect();
+ }
+ if let Some(sel) = select.as_deref() {
+ gci_cmd::parse_select_list(sel, &mut parsed);
+ }
+ gci_cmd::run(&parsed, cli.verbose)?;
+ 0
+ }
+
Commands::Diff { file1, file2 } => {
if let Some(f2) = file2 {
diff_cmd::run(&file1, &f2, cli.verbose)?;
@@ -1706,11 +2168,15 @@ fn run_cli() -> Result {
0
}
- Commands::Log { file } => {
+ Commands::Log {
+ file,
+ events,
+ keyword,
+ } => {
if let Some(f) = file {
- log_cmd::run_file(&f, cli.verbose)?;
+ log_cmd::run_file(&f, events, &keyword, cli.verbose)?;
} else {
- log_cmd::run_stdin(cli.verbose)?;
+ log_cmd::run_stdin(events, &keyword, cli.verbose)?;
}
0
}
@@ -1796,20 +2262,107 @@ fn run_cli() -> Result {
path,
max_len,
max,
+ files_only,
+ count_by_file,
+ all,
+ max_matches,
+ max_per_file,
+ max_line_chars,
+ full_lines,
+ agent_safe,
+ top_files,
+ json,
context_only,
file_type,
line_numbers: _, // no-op: line numbers always enabled in grep_cmd::run
+ fixed,
+ regex,
extra_args,
- } => grep_cmd::run(
- &pattern,
- &path,
- max_len,
- max,
- context_only,
- file_type.as_deref(),
- &extra_args,
- cli.verbose,
- )?,
+ } => {
+ // Default to fixed/literal search for agent safety; --regex opts into regex mode.
+ // --fixed is accepted as an explicit/no-op compatibility flag.
+ let fixed_mode = fixed || !regex;
+
+ let mut state = GrepCliArgs {
+ files_only,
+ count_by_file,
+ all,
+ max_matches,
+ max_per_file,
+ max_line_chars,
+ full_lines,
+ agent_safe,
+ summary_enabled: false,
+ fixups: GrepCliFixups { top_files, json },
+ };
+
+ let forwarded_extra_args = apply_grep_rtk_flags_from_extra_args(&mut state, extra_args)
+ .map_err(|e| anyhow::anyhow!("rtk grep: {}", e))?;
+
+ // Env/config: opt-in agent-safe preset for grep only.
+ // Precedence: CLI > env > config.
+ let config_agent_safe = crate::core::config::Config::load()
+ .ok()
+ .and_then(|c| c.agent.map(|a| a.safe_mode))
+ .unwrap_or(false);
+ let env_agent_safe = parse_truthy_env_var("RTK_AGENT_SAFE");
+ if !state.agent_safe && (env_agent_safe || config_agent_safe) {
+ state.agent_safe = true;
+ state.summary_enabled = true;
+ }
+
+ if state.files_only && state.count_by_file {
+ return Err(clap::Error::raw(
+ ErrorKind::ArgumentConflict,
+ "--files-only conflicts with --count-by-file",
+ )
+ .into());
+ }
+ if state.files_only && state.fixups.top_files.is_some() {
+ return Err(clap::Error::raw(
+ ErrorKind::ArgumentConflict,
+ "--files-only conflicts with --top-files",
+ )
+ .into());
+ }
+ if state.count_by_file && state.fixups.top_files.is_some() {
+ return Err(clap::Error::raw(
+ ErrorKind::ArgumentConflict,
+ "--count-by-file conflicts with --top-files",
+ )
+ .into());
+ }
+
+ let effective = compute_grep_effective_limits(
+ max_len,
+ max,
+ state.all,
+ state.full_lines,
+ state.agent_safe,
+ state.max_matches,
+ state.max_per_file,
+ state.max_line_chars,
+ );
+ grep_cmd::run(
+ &pattern,
+ &path,
+ effective.max_line_chars,
+ effective.max_matches,
+ effective.max_per_file,
+ state.all,
+ state.files_only,
+ state.count_by_file,
+ state.agent_safe,
+ effective.summary_enabled || state.summary_enabled,
+ state.fixups.top_files,
+ state.fixups.json,
+ context_only,
+ file_type.as_deref(),
+ fixed_mode,
+ &forwarded_extra_args,
+ cli.verbose,
+ )?
+ }
Commands::Init {
global,
@@ -1832,12 +2385,6 @@ fn run_cli() -> Result {
};
if show {
hooks::init::show_config(codex)?;
- } else if uninstall && copilot {
- if global {
- hooks::init::uninstall_copilot_global(ctx)?;
- } else {
- hooks::init::uninstall_copilot(ctx)?;
- }
} else if uninstall {
uninstall_init_dispatch(
agent,
@@ -1858,11 +2405,7 @@ fn run_cli() -> Result {
};
hooks::init::run_gemini(global, hook_only, patch_mode, ctx)?;
} else if copilot {
- if global {
- hooks::init::run_copilot_global(ctx)?;
- } else {
- hooks::init::run_copilot(ctx)?;
- }
+ hooks::init::run_copilot(ctx)?;
} else if agent == Some(AgentTarget::Pi) {
hooks::init::run_pi_mode(global, ctx)?
} else if agent == Some(AgentTarget::Kilocode) {
@@ -2237,6 +2780,42 @@ fn run_cli() -> Result {
0
}
+ Commands::Cmake { args } => cmake_cmd::run(&args, cli.verbose)?,
+
+ Commands::Ctest { args } => ctest_cmd::run(&args, cli.verbose)?,
+
+ Commands::Make { args } => make_cmd::run_make(&args, cli.verbose)?,
+
+ Commands::Ninja { args } => make_cmd::run_ninja(&args, cli.verbose)?,
+
+ Commands::Msbuild { args } => msbuild_cmd::run(&args, cli.verbose)?,
+
+ Commands::Codegraph { command } => match command {
+ CodegraphCommands::Index { args } => codegraph_cmd::run_index(&args, cli.verbose)?,
+ CodegraphCommands::Update { args } => codegraph_cmd::run_update(&args, cli.verbose)?,
+ CodegraphCommands::Stats { args } => codegraph_cmd::run_stats(&args, cli.verbose)?,
+ CodegraphCommands::FindSymbol { args } => {
+ codegraph_cmd::run_search_like("find-symbol", &args, cli.verbose)?
+ }
+ CodegraphCommands::Search { args } => {
+ codegraph_cmd::run_search_like("search", &args, cli.verbose)?
+ }
+ CodegraphCommands::Callers { args } => {
+ codegraph_cmd::run_search_like("callers", &args, cli.verbose)?
+ }
+ CodegraphCommands::Callees { args } => {
+ codegraph_cmd::run_search_like("callees", &args, cli.verbose)?
+ }
+ CodegraphCommands::Impact { args } => {
+ codegraph_cmd::run_search_like("impact", &args, cli.verbose)?
+ }
+ CodegraphCommands::AffectedTests { args } => {
+ codegraph_cmd::run_affected_tests(&args, cli.verbose)?
+ }
+ },
+
+ Commands::RemoveItem { args } => remove_item::run(&args, cli.verbose)?,
+
Commands::Pipe {
filter,
passthrough,
@@ -2491,6 +3070,7 @@ fn is_operational_command(cmd: &Commands) -> bool {
Commands::Ls { .. }
| Commands::Tree { .. }
| Commands::Read { .. }
+ | Commands::Patch { .. }
| Commands::Smart { .. }
| Commands::Git { .. }
| Commands::Gh { .. }
@@ -2530,6 +3110,13 @@ fn is_operational_command(cmd: &Commands) -> bool {
| Commands::Go { .. }
| Commands::GolangciLint { .. }
| Commands::Gt { .. }
+ | Commands::Cmake { .. }
+ | Commands::Ctest { .. }
+ | Commands::Make { .. }
+ | Commands::Ninja { .. }
+ | Commands::Msbuild { .. }
+ | Commands::Codegraph { .. }
+ | Commands::RemoveItem { .. }
)
}
@@ -3174,40 +3761,6 @@ mod tests {
}
}
- #[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", "--oneline", "-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"
- );
- }
-
#[test]
fn test_ultra_compact_long_form_still_works() {
let cli = Cli::try_parse_from(["rtk", "--ultra-compact", "git", "status"]).unwrap();
@@ -3272,4 +3825,189 @@ mod tests {
_ => panic!("Expected Init command"),
}
}
+
+ #[test]
+ fn test_grep_agent_safe_overrides_per_file() {
+ let cli =
+ Cli::try_parse_from(["rtk", "grep", "Foo", "--agent-safe", "--max-per-file", "30"])
+ .unwrap();
+ match cli.command {
+ Commands::Grep {
+ max_len,
+ max,
+ all,
+ full_lines,
+ agent_safe,
+ max_matches,
+ max_per_file,
+ max_line_chars,
+ ..
+ } => {
+ let effective = compute_grep_effective_limits(
+ max_len,
+ max,
+ all,
+ full_lines,
+ agent_safe,
+ max_matches,
+ max_per_file,
+ max_line_chars,
+ );
+ assert_eq!(effective.max_matches, Some(80));
+ assert_eq!(effective.max_per_file, Some(30));
+ assert_eq!(effective.max_line_chars, Some(240));
+ }
+ _ => panic!("Expected Grep command"),
+ }
+ }
+
+ #[test]
+ fn test_grep_agent_safe_overrides_total_only() {
+ let cli =
+ Cli::try_parse_from(["rtk", "grep", "Foo", "--agent-safe", "--max-matches", "200"])
+ .unwrap();
+ match cli.command {
+ Commands::Grep {
+ max_len,
+ max,
+ all,
+ full_lines,
+ agent_safe,
+ max_matches,
+ max_per_file,
+ max_line_chars,
+ ..
+ } => {
+ let effective = compute_grep_effective_limits(
+ max_len,
+ max,
+ all,
+ full_lines,
+ agent_safe,
+ max_matches,
+ max_per_file,
+ max_line_chars,
+ );
+ assert_eq!(effective.max_matches, Some(200));
+ assert_eq!(effective.max_per_file, Some(5));
+ assert_eq!(effective.max_line_chars, Some(240));
+ }
+ _ => panic!("Expected Grep command"),
+ }
+ }
+
+ #[test]
+ fn test_grep_all_disables_caps_but_not_full_lines() {
+ let cli = Cli::try_parse_from(["rtk", "grep", "Foo", "--all"]).unwrap();
+ match cli.command {
+ Commands::Grep {
+ max_len,
+ max,
+ all,
+ full_lines,
+ agent_safe,
+ max_matches,
+ max_per_file,
+ max_line_chars,
+ ..
+ } => {
+ let effective = compute_grep_effective_limits(
+ max_len,
+ max,
+ all,
+ full_lines,
+ agent_safe,
+ max_matches,
+ max_per_file,
+ max_line_chars,
+ );
+ assert_eq!(effective.max_matches, None);
+ assert_eq!(effective.max_per_file, None);
+ assert_eq!(effective.max_line_chars, Some(80));
+ }
+ _ => panic!("Expected Grep command"),
+ }
+ }
+
+ #[test]
+ fn test_grep_full_lines_disables_clipping() {
+ let cli = Cli::try_parse_from(["rtk", "grep", "Foo", "--full-lines"]).unwrap();
+ match cli.command {
+ Commands::Grep {
+ max_len,
+ max,
+ all,
+ full_lines,
+ agent_safe,
+ max_matches,
+ max_per_file,
+ max_line_chars,
+ ..
+ } => {
+ let effective = compute_grep_effective_limits(
+ max_len,
+ max,
+ all,
+ full_lines,
+ agent_safe,
+ max_matches,
+ max_per_file,
+ max_line_chars,
+ );
+ assert_eq!(effective.max_line_chars, None);
+ }
+ _ => panic!("Expected Grep command"),
+ }
+ }
+
+ #[test]
+ fn test_grep_flags_after_path_are_parsed_by_rtk() {
+ let cli =
+ Cli::try_parse_from(["rtk", "grep", "Foo", "tmp_grep_test", "--files-only"]).unwrap();
+ match cli.command {
+ Commands::Grep {
+ files_only,
+ count_by_file,
+ all,
+ max_matches,
+ max_per_file,
+ max_line_chars,
+ full_lines,
+ agent_safe,
+ top_files,
+ json,
+ extra_args,
+ ..
+ } => {
+ let mut state = GrepCliArgs {
+ files_only,
+ count_by_file,
+ all,
+ max_matches,
+ max_per_file,
+ max_line_chars,
+ full_lines,
+ agent_safe,
+ summary_enabled: false,
+ fixups: GrepCliFixups { top_files, json },
+ };
+ let forwarded =
+ apply_grep_rtk_flags_from_extra_args(&mut state, extra_args).unwrap();
+ assert!(state.files_only);
+ assert!(!state.count_by_file);
+ assert!(forwarded.is_empty(), "rtk flags must not forward to rg");
+ }
+ _ => panic!("Expected Grep command"),
+ }
+ }
+
+ #[test]
+ fn test_parse_truthy_env_var() {
+ std::env::set_var("RTK_AGENT_SAFE", "yes");
+ assert!(parse_truthy_env_var("RTK_AGENT_SAFE"));
+ std::env::set_var("RTK_AGENT_SAFE", "0");
+ assert!(!parse_truthy_env_var("RTK_AGENT_SAFE"));
+ std::env::remove_var("RTK_AGENT_SAFE");
+ assert!(!parse_truthy_env_var("RTK_AGENT_SAFE"));
+ }
}
diff --git a/tests/fixtures/cpp/cmake_build_failure.txt b/tests/fixtures/cpp/cmake_build_failure.txt
new file mode 100644
index 000000000..67c32aec5
--- /dev/null
+++ b/tests/fixtures/cpp/cmake_build_failure.txt
@@ -0,0 +1,16 @@
+[ 10%] Building CXX object CMakeFiles/myapp.dir/main.cpp.o
+[ 20%] Building CXX object CMakeFiles/myapp.dir/util.cpp.o
+[ 30%] Building CXX object CMakeFiles/myapp.dir/parser.cpp.o
+/home/user/proj/src/parser.cpp:42:14: error: 'undefined_symbol' was not declared in this scope
+ 42 | return undefined_symbol(token);
+ | ^~~~~~~~~~~~~~~~
+/home/user/proj/src/parser.cpp:55:5: warning: unused variable 'tmp' [-Wunused-variable]
+ 55 | int tmp = 0;
+ | ^~~
+[ 40%] Building CXX object CMakeFiles/myapp.dir/lexer.cpp.o
+/home/user/proj/src/lexer.cpp:120:9: error: expected ';' before 'return'
+ 120 | return tok
+ | ^~~~~~~~~~
+make[2]: *** [CMakeFiles/myapp.dir/build.make:84: CMakeFiles/myapp.dir/parser.cpp.o] Error 1
+make[1]: *** [CMakeFiles/Makefile2:99: CMakeFiles/myapp.dir/all] Error 2
+make: *** [Makefile:130: all] Error 2
diff --git a/tests/fixtures/cpp/cmake_build_success.txt b/tests/fixtures/cpp/cmake_build_success.txt
new file mode 100644
index 000000000..3366fd41b
--- /dev/null
+++ b/tests/fixtures/cpp/cmake_build_success.txt
@@ -0,0 +1,20 @@
+[ 5%] Building CXX object CMakeFiles/myapp.dir/main.cpp.o
+[ 10%] Building CXX object CMakeFiles/myapp.dir/util.cpp.o
+[ 15%] Building CXX object CMakeFiles/myapp.dir/parser.cpp.o
+[ 20%] Building CXX object CMakeFiles/myapp.dir/lexer.cpp.o
+[ 25%] Building CXX object CMakeFiles/myapp.dir/codegen.cpp.o
+[ 30%] Building CXX object CMakeFiles/myapp.dir/optimizer.cpp.o
+[ 35%] Building CXX object CMakeFiles/myapp.dir/diagnostics.cpp.o
+[ 40%] Building CXX object CMakeFiles/myapp.dir/types.cpp.o
+[ 45%] Building CXX object CMakeFiles/myapp.dir/scope.cpp.o
+[ 50%] Building CXX object CMakeFiles/myapp.dir/symbol.cpp.o
+[ 55%] Building CXX object CMakeFiles/myapp.dir/ir.cpp.o
+[ 60%] Building CXX object CMakeFiles/myapp.dir/asm.cpp.o
+[ 65%] Building CXX object CMakeFiles/myapp.dir/link.cpp.o
+[ 70%] Building CXX object CMakeFiles/myapp.dir/io.cpp.o
+[ 75%] Building CXX object CMakeFiles/myapp.dir/runtime.cpp.o
+[ 80%] Linking CXX static library libmyapp_core.a
+[ 85%] Built target myapp_core
+[ 90%] Building CXX object CMakeFiles/myapp.dir/main_app.cpp.o
+[ 95%] Linking CXX executable myapp
+[100%] Built target myapp
diff --git a/tests/fixtures/cpp/cmake_configure.txt b/tests/fixtures/cpp/cmake_configure.txt
new file mode 100644
index 000000000..2ab052bf9
--- /dev/null
+++ b/tests/fixtures/cpp/cmake_configure.txt
@@ -0,0 +1,27 @@
+-- The C compiler identification is GNU 13.2.0
+-- The CXX compiler identification is GNU 13.2.0
+-- Detecting C compiler ABI info
+-- Detecting C compiler ABI info - done
+-- Check for working C compiler: /usr/bin/cc - skipped
+-- Detecting C compile features
+-- Detecting C compile features - done
+-- Detecting CXX compiler ABI info
+-- Detecting CXX compiler ABI info - done
+-- Check for working CXX compiler: /usr/bin/c++ - skipped
+-- Detecting CXX compile features
+-- Detecting CXX compile features - done
+-- Looking for sys/types.h
+-- Looking for sys/types.h - found
+-- Looking for stdint.h
+-- Looking for stdint.h - found
+-- Looking for stddef.h
+-- Looking for stddef.h - found
+-- Found Threads: TRUE
+-- Found ZLIB: /usr/lib/x86_64-linux-gnu/libz.so (found version "1.3.1")
+-- Performing Test HAVE_CXX17 - Success
+-- Performing Test HAVE_CXX20 - Success
+-- Build type: Release
+-- Install prefix: /usr/local
+-- Configuring done (1.2s)
+-- Generating done (0.1s)
+-- Build files have been written to: /home/user/proj/build
diff --git a/tests/fixtures/cpp/codegraph_index_verbose.txt b/tests/fixtures/cpp/codegraph_index_verbose.txt
new file mode 100644
index 000000000..647677eef
--- /dev/null
+++ b/tests/fixtures/cpp/codegraph_index_verbose.txt
@@ -0,0 +1,28 @@
+codegraph 0.4.2
+Indexing repo at /home/user/proj
+Parsing src/main.rs ok (12 symbols)
+Parsing src/lib.rs ok (8 symbols)
+Parsing src/util.rs ok (15 symbols)
+Parsing src/parser.rs ok (47 symbols)
+Parsing src/lexer.rs ok (32 symbols)
+Parsing src/codegen.rs ok (58 symbols)
+Parsing src/optimizer.rs ok (24 symbols)
+Parsing src/diagnostics.rs ok (19 symbols)
+Parsing src/types.rs ok (40 symbols)
+Parsing src/scope.rs ok (12 symbols)
+Parsing src/symbol.rs ok (28 symbols)
+Parsing src/ir.rs ok (51 symbols)
+Parsing src/asm.rs ok (44 symbols)
+Parsing src/link.rs ok (36 symbols)
+Parsing src/io.rs ok (17 symbols)
+Parsing src/runtime.rs ok (22 symbols)
+Parsing src/cmds/git.rs ok (88 symbols)
+Parsing src/cmds/cargo.rs ok (66 symbols)
+Parsing src/cmds/dotnet.rs ok (95 symbols)
+Parsing src/cmds/make.rs ok (24 symbols)
+Parsing src/cmds/cmake.rs ok (33 symbols)
+Files: 21
+Symbols: 771
+Edges: 2412
+Errors: 0
+Time: 0.92s
diff --git a/tests/fixtures/cpp/codegraph_search_results.txt b/tests/fixtures/cpp/codegraph_search_results.txt
new file mode 100644
index 000000000..0604a1e11
--- /dev/null
+++ b/tests/fixtures/cpp/codegraph_search_results.txt
@@ -0,0 +1,50 @@
+parse_expr at src/parser.rs:42
+parse_expr at src/parser.rs:120
+parse_expr_test at tests/parser_test.rs:18
+parse_call at src/parser.rs:88
+parse_call at src/codegen.rs:215
+parse_token at src/lexer.rs:33
+parse_string at src/lexer.rs:78
+parse_number at src/lexer.rs:104
+parse_identifier at src/lexer.rs:130
+parse_keyword at src/lexer.rs:158
+parse_operator at src/lexer.rs:182
+parse_punct at src/lexer.rs:201
+parse_block at src/parser.rs:55
+parse_stmt at src/parser.rs:200
+parse_decl at src/parser.rs:280
+parse_function at src/parser.rs:340
+parse_struct at src/parser.rs:412
+parse_enum at src/parser.rs:478
+parse_trait at src/parser.rs:520
+parse_impl at src/parser.rs:580
+parse_use at src/parser.rs:610
+parse_mod at src/parser.rs:640
+parse_pub at src/parser.rs:680
+parse_unsafe at src/parser.rs:720
+parse_async at src/parser.rs:760
+parse_const at src/parser.rs:800
+parse_static at src/parser.rs:830
+parse_let at src/parser.rs:855
+parse_match at src/parser.rs:890
+parse_if at src/parser.rs:920
+parse_loop at src/parser.rs:945
+parse_return at src/parser.rs:975
+parse_break at src/parser.rs:1000
+parse_continue at src/parser.rs:1020
+parse_yield at src/parser.rs:1040
+parse_await at src/parser.rs:1060
+parse_macro at src/parser.rs:1080
+parse_type at src/parser.rs:1100
+parse_path at src/parser.rs:1120
+parse_attr at src/parser.rs:1140
+parse_generic at src/parser.rs:1160
+parse_lifetime at src/parser.rs:1180
+parse_where at src/parser.rs:1200
+parse_visibility at src/parser.rs:1220
+parse_extern at src/parser.rs:1240
+parse_label at src/parser.rs:1260
+parse_arg at src/parser.rs:1280
+parse_pat at src/parser.rs:1300
+parse_field at src/parser.rs:1320
+parse_variant at src/parser.rs:1340
diff --git a/tests/fixtures/cpp/ctest_failure.txt b/tests/fixtures/cpp/ctest_failure.txt
new file mode 100644
index 000000000..0b6495617
--- /dev/null
+++ b/tests/fixtures/cpp/ctest_failure.txt
@@ -0,0 +1,24 @@
+Test project /home/user/proj/build
+ Start 1: test_lexer
+1/5 Test #1: test_lexer ...................... Passed 0.02 sec
+ Start 2: test_parser_failure
+2/5 Test #2: test_parser_failure .............***Failed 0.04 sec
+unit_test_parser.cpp:42: assertion failed
+ expected: 4
+ got: 5
+ Start 3: test_codegen
+3/5 Test #3: test_codegen .................... Passed 0.03 sec
+ Start 4: test_runtime_failure
+4/5 Test #4: test_runtime_failure ............***Failed 0.10 sec
+runtime_test.cpp:88: SIGSEGV in __cxa_throw
+ Start 5: test_io
+5/5 Test #5: test_io ......................... Passed 0.02 sec
+
+60% tests passed, 2 tests failed out of 5
+
+Total Test time (real) = 0.21 sec
+
+The following tests FAILED:
+ 2 - test_parser_failure (Failed)
+ 4 - test_runtime_failure (Failed)
+Errors while running CTest
diff --git a/tests/fixtures/cpp/ctest_success.txt b/tests/fixtures/cpp/ctest_success.txt
new file mode 100644
index 000000000..bee4ea2b5
--- /dev/null
+++ b/tests/fixtures/cpp/ctest_success.txt
@@ -0,0 +1,35 @@
+Test project /home/user/proj/build
+ Start 1: test_lexer_basic
+1/15 Test #1: test_lexer_basic ............................ Passed 0.02 sec
+ Start 2: test_lexer_unicode
+2/15 Test #2: test_lexer_unicode .......................... Passed 0.03 sec
+ Start 3: test_lexer_overflow
+3/15 Test #3: test_lexer_overflow ......................... Passed 0.01 sec
+ Start 4: test_parser_simple
+4/15 Test #4: test_parser_simple .......................... Passed 0.04 sec
+ Start 5: test_parser_recursion
+5/15 Test #5: test_parser_recursion ....................... Passed 0.06 sec
+ Start 6: test_parser_errors
+6/15 Test #6: test_parser_errors .......................... Passed 0.03 sec
+ Start 7: test_codegen_basic
+7/15 Test #7: test_codegen_basic .......................... Passed 0.02 sec
+ Start 8: test_codegen_optimize
+8/15 Test #8: test_codegen_optimize ....................... Passed 0.05 sec
+ Start 9: test_runtime
+9/15 Test #9: test_runtime ................................ Passed 0.10 sec
+ Start 10: test_diagnostics
+10/15 Test #10: test_diagnostics ........................... Passed 0.01 sec
+ Start 11: test_symbols
+11/15 Test #11: test_symbols ............................... Passed 0.01 sec
+ Start 12: test_io
+12/15 Test #12: test_io .................................... Passed 0.04 sec
+ Start 13: test_ir
+13/15 Test #13: test_ir .................................... Passed 0.03 sec
+ Start 14: test_asm
+14/15 Test #14: test_asm ................................... Passed 0.04 sec
+ Start 15: test_link
+15/15 Test #15: test_link .................................. Passed 0.02 sec
+
+100% tests passed, 0 tests failed out of 15
+
+Total Test time (real) = 0.51 sec
diff --git a/tests/fixtures/cpp/make_failure.txt b/tests/fixtures/cpp/make_failure.txt
new file mode 100644
index 000000000..8a8db5781
--- /dev/null
+++ b/tests/fixtures/cpp/make_failure.txt
@@ -0,0 +1,13 @@
+make: Entering directory '/home/user/proj'
+cc -Wall -O2 -c src/main.c -o build/main.o
+cc -Wall -O2 -c src/parser.c -o build/parser.o
+src/parser.c: In function 'parse_expr':
+src/parser.c:42:5: error: implicit declaration of function 'lookup_token'
+ 42 | return lookup_token(t);
+ | ^~~~~~~~~~~~~~~~~~
+src/parser.c:55:9: warning: unused variable 'unused' [-Wunused-variable]
+ 55 | int unused = 0;
+ | ^~~~~~
+make[1]: *** [Makefile:24: build/parser.o] Error 1
+make[1]: Leaving directory '/home/user/proj'
+make: *** [Makefile:8: all] Error 2
diff --git a/tests/fixtures/cpp/msbuild_empty_link.txt b/tests/fixtures/cpp/msbuild_empty_link.txt
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/fixtures/cpp/msbuild_failure_compiler.txt b/tests/fixtures/cpp/msbuild_failure_compiler.txt
new file mode 100644
index 000000000..444fe6307
--- /dev/null
+++ b/tests/fixtures/cpp/msbuild_failure_compiler.txt
@@ -0,0 +1,26 @@
+Microsoft (R) Build Engine version 17.8.5+b5c6332e2 for .NET Framework
+Copyright (C) Microsoft Corporation. All rights reserved.
+
+Build started 1/15/2026 9:05:00 AM.
+Project "C:\src\MyProject.sln" on node 1 (Build target(s)).
+ClCompile:
+ main.cpp
+ util.cpp
+ parser.cpp
+C:\src\MyProject\src\main.cpp(42): error C2065: 'foo': undeclared identifier [C:\src\MyProject\MyProject.vcxproj]
+C:\src\MyProject\src\main.cpp(43): warning C4244: conversion from 'double' to 'int', possible loss of data [C:\src\MyProject\MyProject.vcxproj]
+C:\src\MyProject\src\parser.cpp(120): error C2143: syntax error: missing ';' before 'return' [C:\src\MyProject\MyProject.vcxproj]
+C:\src\MyProject\src\parser.cpp(121): fatal error C1004: unexpected end-of-file found [C:\src\MyProject\MyProject.vcxproj]
+Done Building Project "C:\src\MyProject\MyProject.vcxproj" (default targets) -- FAILED.
+Done Building Project "C:\src\MyProject.sln" (default targets) -- FAILED.
+
+Build FAILED.
+
+C:\src\MyProject\src\main.cpp(42): error C2065: 'foo': undeclared identifier [C:\src\MyProject\MyProject.vcxproj]
+C:\src\MyProject\src\main.cpp(43): warning C4244: conversion from 'double' to 'int', possible loss of data [C:\src\MyProject\MyProject.vcxproj]
+C:\src\MyProject\src\parser.cpp(120): error C2143: syntax error: missing ';' before 'return' [C:\src\MyProject\MyProject.vcxproj]
+C:\src\MyProject\src\parser.cpp(121): fatal error C1004: unexpected end-of-file found [C:\src\MyProject\MyProject.vcxproj]
+ 1 Warning(s)
+ 3 Error(s)
+
+Time Elapsed 00:00:03.22
diff --git a/tests/fixtures/cpp/msbuild_failure_linker.txt b/tests/fixtures/cpp/msbuild_failure_linker.txt
new file mode 100644
index 000000000..a68923e56
--- /dev/null
+++ b/tests/fixtures/cpp/msbuild_failure_linker.txt
@@ -0,0 +1,24 @@
+Microsoft (R) Build Engine version 17.8.5+b5c6332e2 for .NET Framework
+Copyright (C) Microsoft Corporation. All rights reserved.
+
+Build started 1/15/2026 9:10:00 AM.
+Project "C:\src\MyProject.sln" on node 1 (Build target(s)).
+ClCompile:
+ main.cpp
+ util.cpp
+Link:
+ C:\BuildTools\bin\link.exe /OUT:"Debug\MyProject.dll" /NOLOGO main.obj util.obj
+MyProject.lib(util.obj) : error LNK2001: unresolved external symbol "void __cdecl bar(int)" (?bar@@YAXH@Z)
+MyProject.lib(main.obj) : error LNK2001: unresolved external symbol "class Logger * __cdecl get_logger(void)" (?get_logger@@YAPAVLogger@@XZ)
+MyOtherDLL.dll : fatal error LNK1120: 2 unresolved externals
+Done Building Project "C:\src\MyProject\MyProject.vcxproj" (default targets) -- FAILED.
+
+Build FAILED.
+
+MyProject.lib(util.obj) : error LNK2001: unresolved external symbol "void __cdecl bar(int)" (?bar@@YAXH@Z)
+MyProject.lib(main.obj) : error LNK2001: unresolved external symbol "class Logger * __cdecl get_logger(void)" (?get_logger@@YAPAVLogger@@XZ)
+MyOtherDLL.dll : fatal error LNK1120: 2 unresolved externals
+ 0 Warning(s)
+ 3 Error(s)
+
+Time Elapsed 00:00:08.45
diff --git a/tests/fixtures/cpp/msbuild_failure_msb3073.txt b/tests/fixtures/cpp/msbuild_failure_msb3073.txt
new file mode 100644
index 000000000..7a16cf46c
--- /dev/null
+++ b/tests/fixtures/cpp/msbuild_failure_msb3073.txt
@@ -0,0 +1,20 @@
+Microsoft (R) Build Engine version 17.8.5+b5c6332e2 for .NET Framework
+Copyright (C) Microsoft Corporation. All rights reserved.
+
+Build started 1/15/2026 9:06:00 AM.
+Project "C:\src\MyProject.sln" on node 1 (Build target(s)).
+Project "C:\src\MyProject\MyProject.vcxproj" on node 1 (default target(s)).
+PrepareForBuild:
+ Creating directory "obj\Debug\".
+PostBuildEvent:
+ copy /Y "C:\path with spaces\out.dll" "C:\dest\bin\"
+C:\src\MyProject\MyProject.vcxproj(123,5): error MSB3073: The command "copy /Y \"C:\\path with spaces\\out.dll\" \"C:\\dest\\bin\\\"" exited with code 1. [C:\src\MyProject\MyProject.vcxproj]
+Done Building Project "C:\src\MyProject\MyProject.vcxproj" (default targets) -- FAILED.
+Done Building Project "C:\src\MyProject.sln" (default targets) -- FAILED.
+
+Build FAILED.
+
+ 0 Warning(s)
+ 1 Error(s)
+
+Time Elapsed 00:00:02.00
diff --git a/tests/fixtures/cpp/msbuild_failure_msb8012.txt b/tests/fixtures/cpp/msbuild_failure_msb8012.txt
new file mode 100644
index 000000000..996e01162
--- /dev/null
+++ b/tests/fixtures/cpp/msbuild_failure_msb8012.txt
@@ -0,0 +1,16 @@
+Microsoft (R) Build Engine version 17.8.5+b5c6332e2 for .NET Framework
+Copyright (C) Microsoft Corporation. All rights reserved.
+
+Build started 1/15/2026 9:08:00 AM.
+Project "C:\src\MyProject.sln" on node 1 (Build target(s)).
+Project "C:\src\MyProject\MyProject.vcxproj" on node 1 (default target(s)).
+C:\src\MyProject\MyProject.vcxproj(56,5): error MSB8012: TargetPath (C:\src\MyProject\bin\Debug\MyProject.dll) does not match the Linker's OutputFile property value (C:\src\MyProject\bin\Debug\MyProjectWrong.dll). This may cause your project to build incorrectly. [C:\src\MyProject\MyProject.vcxproj]
+Done Building Project "C:\src\MyProject\MyProject.vcxproj" (default targets) -- FAILED.
+Done Building Project "C:\src\MyProject.sln" (default targets) -- FAILED.
+
+Build FAILED.
+
+ 0 Warning(s)
+ 1 Error(s)
+
+Time Elapsed 00:00:01.00
diff --git a/tests/fixtures/cpp/msbuild_failure_rc.txt b/tests/fixtures/cpp/msbuild_failure_rc.txt
new file mode 100644
index 000000000..e75d0eb73
--- /dev/null
+++ b/tests/fixtures/cpp/msbuild_failure_rc.txt
@@ -0,0 +1,18 @@
+Microsoft (R) Build Engine version 17.8.5+b5c6332e2 for .NET Framework
+Copyright (C) Microsoft Corporation. All rights reserved.
+
+Build started 1/15/2026 9:07:00 AM.
+Project "C:\src\MyProject.sln" on node 1 (Build target(s)).
+Project "C:\src\MyProject\MyProject.vcxproj" on node 1 (default target(s)).
+ResourceCompile:
+ C:\src\MyProject\res\app.rc
+C:\src\MyProject\res\app.rc(10): fatal error RC1015: cannot open include file 'windows.h'. [C:\src\MyProject\MyProject.vcxproj]
+Done Building Project "C:\src\MyProject\MyProject.vcxproj" (default targets) -- FAILED.
+Done Building Project "C:\src\MyProject.sln" (default targets) -- FAILED.
+
+Build FAILED.
+
+ 0 Warning(s)
+ 1 Error(s)
+
+Time Elapsed 00:00:01.00
diff --git a/tests/fixtures/cpp/msbuild_redirected.txt b/tests/fixtures/cpp/msbuild_redirected.txt
new file mode 100644
index 000000000..20a3e9b87
--- /dev/null
+++ b/tests/fixtures/cpp/msbuild_redirected.txt
@@ -0,0 +1 @@
+msbuild MyProject.sln /t:Build /p:Configuration=Debug /p:Platform=Win32 /m:1 /nologo /v:m *> build-logs/msbuild_run1.log
diff --git a/tests/fixtures/cpp/msbuild_success.txt b/tests/fixtures/cpp/msbuild_success.txt
new file mode 100644
index 000000000..f313310c9
--- /dev/null
+++ b/tests/fixtures/cpp/msbuild_success.txt
@@ -0,0 +1,33 @@
+Microsoft (R) Build Engine version 17.8.5+b5c6332e2 for .NET Framework
+Copyright (C) Microsoft Corporation. All rights reserved.
+
+Build started 1/15/2026 9:00:00 AM.
+Project "C:\src\MyProject.sln" on node 1 (Build target(s)).
+Project "C:\src\MyProject.sln" (1) is building "C:\src\MyProject\MyProject.vcxproj" (2) on node 1 (Build target(s)).
+PrepareForBuild:
+ Creating directory "obj\Win32\Debug\".
+ Creating directory "C:\src\MyProject\Debug\".
+InitializeBuildStatus:
+ Creating "obj\Win32\Debug\MyProject.tlog\unsuccessfulbuild" because "AlwaysCreate" was specified.
+ClCompile:
+ C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Tools\MSVC\14.38.33130\bin\HostX64\x86\CL.exe /c /Z7 /nologo /W3 /WX- /diagnostics:column /sdl /Od main.cpp util.cpp parser.cpp
+ main.cpp
+ util.cpp
+ parser.cpp
+C:\src\MyProject\src\util.cpp(33): warning C4244: 'argument': conversion from 'int64_t' to 'int', possible loss of data [C:\src\MyProject\MyProject.vcxproj]
+Link:
+ C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Tools\MSVC\14.38.33130\bin\HostX64\x86\link.exe /OUT:"Debug\MyProject.dll" /NOLOGO /DLL main.obj util.obj parser.obj
+ Creating library Debug\MyProject.lib and object Debug\MyProject.exp
+ Generating code
+ Finished generating code
+ MyProject.vcxproj -> C:\src\MyProject\Debug\MyProject.dll
+FinalizeBuildStatus:
+ Deleting file "obj\Win32\Debug\MyProject.tlog\unsuccessfulbuild".
+Done Building Project "C:\src\MyProject\MyProject.vcxproj" (default targets).
+Done Building Project "C:\src\MyProject.sln" (default targets).
+
+Build succeeded.
+ 1 Warning(s)
+ 0 Error(s)
+
+Time Elapsed 00:00:14.32