From 1319f9d6d816afe39017e9885c46eb856dc9a616 Mon Sep 17 00:00:00 2001 From: isink17 <39876158+isink17@users.noreply.github.com> Date: Wed, 27 May 2026 16:24:28 +0200 Subject: [PATCH 1/6] Add Windows C++ MSBuild and PowerShell diagnostics --- README.md | 3 + src/cmds/cpp/msbuild_cmd.rs | 419 ++++++++++++++++-- src/cmds/system/gci_cmd.rs | 189 ++++++++ src/cmds/system/log_cmd.rs | 92 +++- src/cmds/system/read.rs | 176 ++++++++ src/core/text_encoding.rs | 82 ++++ src/discover/registry.rs | 282 +++++++++++- src/main.rs | 89 +++- .../fixtures/cpp/msbuild_failure_msb3073.txt | 20 + .../fixtures/cpp/msbuild_failure_msb8012.txt | 16 + tests/fixtures/cpp/msbuild_failure_rc.txt | 18 + 11 files changed, 1339 insertions(+), 47 deletions(-) create mode 100644 src/cmds/system/gci_cmd.rs create mode 100644 tests/fixtures/cpp/msbuild_failure_msb3073.txt create mode 100644 tests/fixtures/cpp/msbuild_failure_msb8012.txt create mode 100644 tests/fixtures/cpp/msbuild_failure_rc.txt diff --git a/README.md b/README.md index 7a0c646689..9092a9d57c 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,7 @@ 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 @@ -223,6 +224,7 @@ rtk codegraph index # Summary only, no per-file progress (-90%) 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 ``` @@ -255,6 +257,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 diff --git a/src/cmds/cpp/msbuild_cmd.rs b/src/cmds/cpp/msbuild_cmd.rs index a864ad6b7c..04813e64e0 100644 --- a/src/cmds/cpp/msbuild_cmd.rs +++ b/src/cmds/cpp/msbuild_cmd.rs @@ -17,12 +17,50 @@ lazy_static! { // 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 COMMAND_QUOTED_RE: Regex = Regex::new(r#"(?i)\bcommand\s+\"([^\"]+)\""#).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; @@ -59,34 +97,36 @@ pub fn run(args: &[String], verbose: u8) -> Result { } pub(crate) fn filter_output(raw: &str, args: &[String]) -> String { - let mut compiler_errors: Vec = Vec::new(); - let mut compiler_warnings: Vec = Vec::new(); - let mut linker: Vec = Vec::new(); + 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(); - for line in raw.lines() { + 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) = MSVC_COMPILER_RE.captures(trimmed) { - let file = &caps[1]; - let lnum = &caps[2]; - let kind = &caps[3]; - let code = &caps[4]; - let msg = &caps[5]; - let formatted = format!("{}({}): {} {}: {}", file, lnum, kind, code, msg); - if kind == "warning" { - compiler_warnings.push(formatted); - } else { - compiler_errors.push(formatted); + + 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 MSVC_LINKER_RE.is_match(trimmed) { - linker.push(trimmed.to_string()); + + 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) { @@ -100,9 +140,9 @@ pub(crate) fn filter_output(raw: &str, args: &[String]) -> String { } } - let has_errors = !compiler_errors.is_empty() || !linker.is_empty(); + let has_errors = diags.iter().any(|d| d.severity == Severity::Error); - if !has_errors && build_result.is_none() && compiler_warnings.is_empty() { + if !has_errors && build_result.is_none() && diags.is_empty() { // Empty / redirected output let target = configuration_summary(args); return format!( @@ -118,21 +158,63 @@ pub(crate) fn filter_output(raw: &str, args: &[String]) -> String { } let mut out = String::new(); - for c in &compiler_errors { - out.push_str(c); + + 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'); } - // Show warnings only on failure - if !succeeded { - for c in &compiler_warnings { - out.push_str(c); - 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'); } - for l in &linker { - out.push_str(l); + + 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'); @@ -144,6 +226,256 @@ pub(crate) fn filter_output(raw: &str, args: &[String]) -> String { 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_caps) = COMMAND_QUOTED_RE.captures(&msg) { + if let Some(cmd) = cmd_caps.get(1).map(|m| m.as_str()) { + msg = cmd.to_string(); + } + } + 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_caps) = COMMAND_QUOTED_RE.captures(line) { + if let Some(cmd) = cmd_caps.get(1).map(|m| m.as_str()) { + msg = cmd.to_string(); + } + } + 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 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: Vec = Vec::new(); + for d in diags { + let key = format!("{}|{}", d.code, d.raw); + if seen.contains(&key) { + continue; + } + seen.push(key); + 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() @@ -281,6 +613,37 @@ mod tests { 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("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"); diff --git a/src/cmds/system/gci_cmd.rs b/src/cmds/system/gci_cmd.rs new file mode 100644 index 0000000000..c3ba4cb089 --- /dev/null +++ b/src/cmds/system/gci_cmd.rs @@ -0,0 +1,189 @@ +//! 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 { + glob_match_inner(pattern.as_bytes(), name.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, + } +} diff --git a/src/cmds/system/log_cmd.rs b/src/cmds/system/log_cmd.rs index 7c765f5e6e..c17c50fe86 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); @@ -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.len() > 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,19 @@ mod tests { // Should not panic even with very long multi-byte messages assert!(result.contains("ERRORS")); } + + #[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 + } } diff --git a/src/cmds/system/read.rs b/src/cmds/system/read.rs index 568a6a76a6..c91940324f 100644 --- a/src/cmds/system/read.rs +++ b/src/cmds/system/read.rs @@ -244,11 +244,123 @@ fn read_file_text( ) -> 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 sample = bytes.iter().take(8192).copied().collect::>(); + 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 || !sample.len().is_multiple_of(2) { + 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(); @@ -363,6 +475,54 @@ fn main() {{ 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; @@ -371,6 +531,22 @@ fn main() {{ 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 diff --git a/src/core/text_encoding.rs b/src/core/text_encoding.rs index 00fa1c2708..8098540820 100644 --- a/src/core/text_encoding.rs +++ b/src/core/text_encoding.rs @@ -65,6 +65,19 @@ pub fn decode_bytes(bytes: &[u8], requested: TextEncoding) -> Result { + // 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(), @@ -139,6 +152,75 @@ pub fn decode_bytes(bytes: &[u8], requested: TextEncoding) -> Result Option { + if payload.len() < 4 || !payload.len().is_multiple_of(2) { + 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()), diff --git a/src/discover/registry.rs b/src/discover/registry.rs index 554fd40da2..5331a20727 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -74,13 +74,21 @@ lazy_static! { 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 (handles both -Path/-Pattern orderings). + // 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+)"# + 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+)"# + 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 = @@ -583,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 ") @@ -598,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(' '); @@ -759,7 +781,59 @@ fn try_powershell_rewrite(cmd: &str) -> Option { } else { (caps.get(1)?.as_str(), caps.get(2)?.as_str()) }; - return Some(format!("rtk grep {} {}", pattern, path)); + + // 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) { @@ -778,6 +852,133 @@ fn try_powershell_rewrite(cmd: &str) -> Option { 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<&str> = cmd.split_whitespace().collect(); + if tokens.is_empty() { + return None; + } + + // Drop the leading command word (Get-ChildItem/gci/dir) + tokens.remove(0); + + let mut path: Option<&str> = None; + let mut recurse = false; + let mut force = false; + let mut kind_file = false; + let mut kind_dir = false; + let mut filter: Option<&str> = None; + let mut include: Option<&str> = None; + + let mut i = 0; + while i < tokens.len() { + let t = tokens[i]; + let lower = t.to_ascii_lowercase(); + if !t.starts_with('-') && path.is_none() { + path = Some(t); + 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).copied(); + i += 2; + } + "-include" => { + include = tokens.get(i + 1).copied(); + i += 2; + } + _ => { + // Unknown flag → do not rewrite (safety). + return None; + } + } + } + + let mut out = String::new(); + out.push_str("rtk gci "); + out.push_str(path.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 is_excluded(cmd: &str, excluded: &[ExcludePattern]) -> bool { excluded.iter().any(|pat| match pat { ExcludePattern::Regex(re) => re.is_match(cmd), @@ -1317,6 +1518,67 @@ 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_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/main.rs b/src/main.rs index 9373936dd6..405d3c93bb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,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, patch, - 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}; @@ -290,6 +290,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) @@ -302,6 +333,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) @@ -1881,6 +1918,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)?; @@ -1890,11 +1965,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 } diff --git a/tests/fixtures/cpp/msbuild_failure_msb3073.txt b/tests/fixtures/cpp/msbuild_failure_msb3073.txt new file mode 100644 index 0000000000..7a16cf46ca --- /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 0000000000..996e01162c --- /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 0000000000..e75d0eb73a --- /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 From 3984de410833fbb3ff1bd7dfc44606340e4c6164 Mon Sep 17 00:00:00 2001 From: isink17 <39876158+isink17@users.noreply.github.com> Date: Wed, 27 May 2026 17:44:31 +0200 Subject: [PATCH 2/6] Address Windows diagnostics review feedback --- src/cmds/cpp/msbuild_cmd.rs | 68 +++++++++++++++++++++----- src/cmds/system/gci_cmd.rs | 22 ++++++++- src/cmds/system/read.rs | 10 ++-- src/core/text_encoding.rs | 3 +- src/discover/registry.rs | 95 ++++++++++++++++++++++++++++++++----- 5 files changed, 170 insertions(+), 28 deletions(-) diff --git a/src/cmds/cpp/msbuild_cmd.rs b/src/cmds/cpp/msbuild_cmd.rs index 04813e64e0..f6b981b377 100644 --- a/src/cmds/cpp/msbuild_cmd.rs +++ b/src/cmds/cpp/msbuild_cmd.rs @@ -8,6 +8,7 @@ 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] @@ -31,7 +32,6 @@ lazy_static! { .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 COMMAND_QUOTED_RE: Regex = Regex::new(r#"(?i)\bcommand\s+\"([^\"]+)\""#).unwrap(); static ref PROJECT_ON_NODE_RE: Regex = Regex::new(r#"^Project \"(.+?)\" on node \d+ \((.+?) target\(s\)\)\."#).unwrap(); static ref DONE_BUILDING_RE: Regex = @@ -293,10 +293,8 @@ fn parse_diag_line(line: &str, idx: usize, current_project: Option<&str>) -> Opt .captures(&msg) .and_then(|c| c.get(1)) .map(|m| m.as_str().to_string()); - if let Some(cmd_caps) = COMMAND_QUOTED_RE.captures(&msg) { - if let Some(cmd) = cmd_caps.get(1).map(|m| m.as_str()) { - msg = cmd.to_string(); - } + if let Some(cmd) = extract_msb3073_command(&msg) { + msg = cmd; } if let Some(n) = exit_code { msg = format!("{} (exit code {})", msg, n); @@ -373,10 +371,8 @@ fn parse_diag_line(line: &str, idx: usize, current_project: Option<&str>) -> Opt .captures(&msg) .and_then(|c| c.get(1)) .map(|m| m.as_str().to_string()); - if let Some(cmd_caps) = COMMAND_QUOTED_RE.captures(line) { - if let Some(cmd) = cmd_caps.get(1).map(|m| m.as_str()) { - msg = cmd.to_string(); - } + if let Some(cmd) = extract_msb3073_command(line) { + msg = cmd; } if let Some(n) = exit_code { msg = format!("{} (exit code {})", msg, n); @@ -397,19 +393,67 @@ fn parse_diag_line(line: &str, idx: usize, current_project: Option<&str>) -> Opt 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: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); for d in diags { let key = format!("{}|{}", d.code, d.raw); if seen.contains(&key) { continue; } - seen.push(key); + seen.insert(key); out.push(d.clone()); } out @@ -631,6 +675,8 @@ mod tests { 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")); } diff --git a/src/cmds/system/gci_cmd.rs b/src/cmds/system/gci_cmd.rs index c3ba4cb089..1feeaa16d5 100644 --- a/src/cmds/system/gci_cmd.rs +++ b/src/cmds/system/gci_cmd.rs @@ -172,7 +172,9 @@ fn format_system_time(t: SystemTime) -> String { } fn glob_match(pattern: &str, name: &str) -> bool { - glob_match_inner(pattern.as_bytes(), name.as_bytes()) + 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 { @@ -187,3 +189,21 @@ fn glob_match_inner(pat: &[u8], name: &[u8]) -> bool { _ => 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/read.rs b/src/cmds/system/read.rs index c91940324f..0fe3a319e8 100644 --- a/src/cmds/system/read.rs +++ b/src/cmds/system/read.rs @@ -288,13 +288,14 @@ 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 sample = bytes.iter().take(8192).copied().collect::>(); + 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) { + if looks_utf16_no_bom(sample) { return false; } @@ -305,7 +306,7 @@ fn looks_binary_bytes(bytes: &[u8]) -> bool { // 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 { + for b in sample { if *b < 0x09 || (*b > 0x0D && *b < 0x20) { control += 1; } @@ -314,7 +315,8 @@ fn looks_binary_bytes(bytes: &[u8]) -> bool { } fn looks_utf16_no_bom(sample: &[u8]) -> bool { - if sample.len() < 4 || !sample.len().is_multiple_of(2) { + #[allow(clippy::manual_is_multiple_of)] + if sample.len() < 4 || sample.len() % 2 != 0 { return false; } let mut zeros_even = 0usize; diff --git a/src/core/text_encoding.rs b/src/core/text_encoding.rs index 8098540820..707f8b847d 100644 --- a/src/core/text_encoding.rs +++ b/src/core/text_encoding.rs @@ -158,7 +158,8 @@ struct Utf16Guess { } fn detect_utf16_no_bom(payload: &[u8]) -> Option { - if payload.len() < 4 || !payload.len().is_multiple_of(2) { + #[allow(clippy::manual_is_multiple_of)] + if payload.len() < 4 || payload.len() % 2 != 0 { return None; } diff --git a/src/discover/registry.rs b/src/discover/registry.rs index 5331a20727..51e9ba09dc 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -897,7 +897,7 @@ fn try_rewrite_powershell_get_child_item(cmd: &str) -> Option { return None; } - let mut tokens: Vec<&str> = cmd.split_whitespace().collect(); + let mut tokens: Vec = split_powershell_args_quote_aware(cmd)?; if tokens.is_empty() { return None; } @@ -905,20 +905,20 @@ fn try_rewrite_powershell_get_child_item(cmd: &str) -> Option { // Drop the leading command word (Get-ChildItem/gci/dir) tokens.remove(0); - let mut path: Option<&str> = None; + 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<&str> = None; - let mut include: Option<&str> = None; + let mut filter: Option = None; + let mut include: Option = None; let mut i = 0; while i < tokens.len() { - let t = tokens[i]; + let t = tokens[i].as_str(); let lower = t.to_ascii_lowercase(); if !t.starts_with('-') && path.is_none() { - path = Some(t); + path = Some(tokens[i].clone()); i += 1; continue; } @@ -940,11 +940,11 @@ fn try_rewrite_powershell_get_child_item(cmd: &str) -> Option { i += 1; } "-filter" => { - filter = tokens.get(i + 1).copied(); + filter = tokens.get(i + 1).cloned(); i += 2; } "-include" => { - include = tokens.get(i + 1).copied(); + include = tokens.get(i + 1).cloned(); i += 2; } _ => { @@ -956,7 +956,7 @@ fn try_rewrite_powershell_get_child_item(cmd: &str) -> Option { let mut out = String::new(); out.push_str("rtk gci "); - out.push_str(path.unwrap_or(".")); + out.push_str(path.as_deref().unwrap_or(".")); if recurse { out.push_str(" --recurse"); } @@ -970,11 +970,65 @@ fn try_rewrite_powershell_get_child_item(cmd: &str) -> Option { } if let Some(f) = filter { out.push_str(" --filter "); - out.push_str(f); + out.push_str(&f); } if let Some(inc) = include { out.push_str(" --include "); - out.push_str(inc); + 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) } @@ -1565,6 +1619,25 @@ mod tests { ); } + #[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!( From a6ba65e502e0d556abcfc3c522031afe05b7a20a Mon Sep 17 00:00:00 2001 From: isink17 <39876158+isink17@users.noreply.github.com> Date: Thu, 28 May 2026 10:35:33 +0200 Subject: [PATCH 3/6] feat(grep): add agent-safe modes and JSON summaries Add agent-focused grep ergonomics: - parse RTK grep flags before or after the search path - add files-only, count-by-file, top-files, and JSON output modes - add agent-safe preset via CLI, RTK_AGENT_SAFE, and agent.safe_mode - support explicit caps for total matches, per-file matches, and line length - keep legacy default grep behavior unless safe mode or new modes are used - improve summaries with concrete rtk read hints - ensure JSON mode emits one stable object across grep modes Also harden UTF-8 clipping, tiny max-line handling, no-match behavior, unknown rg-arg forwarding, docs, and tests. --- README.md | 23 +- src/cmds/system/README.md | 2 +- src/cmds/system/grep_cmd.rs | 1028 +++++++++++++++++++++++++++++++++-- src/core/config.rs | 8 + src/main.rs | 473 +++++++++++++++- 5 files changed, 1473 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 9092a9d57c..84e627e383 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,22 @@ 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" . +``` + **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 @@ -148,7 +164,12 @@ 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 ``` diff --git a/src/cmds/system/README.md b/src/cmds/system/README.md index 55de289127..8898e1429c 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/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index 3bd121286f..0968917d1f 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -6,14 +6,48 @@ 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, @@ -57,7 +91,8 @@ pub fn run( } // 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()); @@ -85,76 +120,540 @@ 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()); + + 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, + }, + ); + } - Ok(exit_code) + 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 remaining_total = effective_total; + let mut out_files: Vec = Vec::new(); + for (file, matches) in files { + if let Some(total_cap) = remaining_total { + if displayed_matches >= 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) = remaining_total { + if out_matches.len() + displayed_matches >= 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 }); + } + + out_files.push(GrepJsonFile { + path: compact_path(file), + count: matches.len(), + matches: out_matches, + }); + } + + let _ = remaining_total.take(); + + Self { + pattern: pattern.to_string(), + total_matches, + files_matched, + displayed_matches, + 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`. @@ -248,44 +747,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) } } @@ -312,6 +860,7 @@ fn compact_path(path: &str) -> String { #[cfg(test)] mod tests { use super::*; + use serde_json::Value; #[test] fn test_clean_line() { @@ -345,6 +894,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 🎃🎆🎇✨"; @@ -569,4 +1165,324 @@ 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_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()); + } } diff --git a/src/core/config.rs b/src/core/config.rs index ed0f00c6c9..edc3be83ac 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/main.rs b/src/main.rs index 405d3c93bb..60a85424d1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,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 { @@ -380,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, @@ -2059,6 +2262,16 @@ 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 @@ -2069,15 +2282,84 @@ fn run_cli() -> Result { // 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, - max_len, - max, + 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, - &extra_args, + &forwarded_extra_args, cli.verbose, )? } @@ -3543,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")); + } } From 244516438e0ff619d48a8eed0bee1306ae8b92cf Mon Sep 17 00:00:00 2001 From: isink <39876158+isink17@users.noreply.github.com> Date: Thu, 28 May 2026 18:32:20 +0200 Subject: [PATCH 4/6] Fix/post grep rtk cleanups (#6) --- src/cmds/git/git.rs | 67 ++++++++++++++++++++++ src/cmds/system/grep_cmd.rs | 111 +++++++++++++++++++++++++++++++++--- src/cmds/system/log_cmd.rs | 23 +++++++- 3 files changed, 192 insertions(+), 9 deletions(-) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index eaf8d8b5f9..ff27d8479a 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -145,6 +145,11 @@ where 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. @@ -2022,6 +2027,17 @@ mod tests { 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] @@ -2056,6 +2072,13 @@ mod tests { ); } + /// 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() { @@ -2071,6 +2094,20 @@ mod tests { ); } + /// 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() { @@ -2130,6 +2167,36 @@ mod tests { ); } + /// 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/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index 0968917d1f..1f2328edbe 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -603,17 +603,18 @@ impl GrepJsonOutput { files.sort_by_key(|(f, _)| *f); let mut remaining_total = effective_total; + let mut current_count: usize = 0; let mut out_files: Vec = Vec::new(); for (file, matches) in files { if let Some(total_cap) = remaining_total { - if displayed_matches >= total_cap { + 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) = remaining_total { - if out_matches.len() + displayed_matches >= total_cap { + if current_count >= total_cap { break; } } @@ -629,13 +630,16 @@ impl GrepJsonOutput { content.trim().to_string() }; out_matches.push(GrepJsonMatch { line: *line, text }); + current_count += 1; } - out_files.push(GrepJsonFile { - path: compact_path(file), - count: matches.len(), - matches: out_matches, - }); + if !out_matches.is_empty() { + out_files.push(GrepJsonFile { + path: compact_path(file), + count: matches.len(), + matches: out_matches, + }); + } } let _ = remaining_total.take(); @@ -1485,4 +1489,97 @@ c.txt\x002:foo c2\n" 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 c17c50fe86..f3733cf465 100644 --- a/src/cmds/system/log_cmd.rs +++ b/src/cmds/system/log_cmd.rs @@ -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 { @@ -344,6 +344,25 @@ mod tests { 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\ From 3750dd9ff7d55eb082cc78784bfad76b3430fba7 Mon Sep 17 00:00:00 2001 From: isink17 <39876158+isink17@users.noreply.github.com> Date: Thu, 28 May 2026 21:18:11 +0200 Subject: [PATCH 5/6] fix: address PR review comments Resolve remaining PR review feedback after merging the C++/MSBuild handler branch: - avoid double HashSet lookup in MSBuild diagnostic deduplication - fix grep JSON capped output to use a running current_count counter - avoid empty normal JSON file entries after early total-cap breaks - use Unicode character counts for log truncation thresholds - allow UTF-16 no-BOM detection to ignore one trailing odd byte Includes tests for capped grep JSON output, Unicode-safe log truncation, and odd-length UTF-16 no-BOM samples. --- src/cmds/cpp/msbuild_cmd.rs | 3 +-- src/cmds/system/grep_cmd.rs | 45 +++++++++++++++++++++++++++++++++---- src/cmds/system/log_cmd.rs | 24 +++++++++++++++++--- src/cmds/system/read.rs | 15 +++++++++++-- 4 files changed, 76 insertions(+), 11 deletions(-) diff --git a/src/cmds/cpp/msbuild_cmd.rs b/src/cmds/cpp/msbuild_cmd.rs index f6b981b377..c8c64e1b6d 100644 --- a/src/cmds/cpp/msbuild_cmd.rs +++ b/src/cmds/cpp/msbuild_cmd.rs @@ -450,10 +450,9 @@ fn dedup_diags(diags: &[MsbuildDiag]) -> Vec { let mut seen: HashSet = HashSet::new(); for d in diags { let key = format!("{}|{}", d.code, d.raw); - if seen.contains(&key) { + if !seen.insert(key) { continue; } - seen.insert(key); out.push(d.clone()); } out diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index 0968917d1f..b1a85a2023 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -588,7 +588,7 @@ impl GrepJsonOutput { pattern: &str, total_matches: usize, files_matched: usize, - displayed_matches: usize, + _displayed_matches: usize, omitted_total: usize, omitted_per_file: usize, clipped_lines: usize, @@ -603,17 +603,18 @@ impl GrepJsonOutput { files.sort_by_key(|(f, _)| *f); let mut remaining_total = effective_total; + let mut current_count = 0usize; let mut out_files: Vec = Vec::new(); for (file, matches) in files { if let Some(total_cap) = remaining_total { - if displayed_matches >= total_cap { + 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) = remaining_total { - if out_matches.len() + displayed_matches >= total_cap { + if current_count >= total_cap { break; } } @@ -629,6 +630,13 @@ impl GrepJsonOutput { 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() { + continue; } out_files.push(GrepJsonFile { @@ -644,7 +652,7 @@ impl GrepJsonOutput { pattern: pattern.to_string(), total_matches, files_matched, - displayed_matches, + displayed_matches: current_count, omitted_total, omitted_per_file, clipped_lines, @@ -1435,6 +1443,35 @@ c.txt\x002:foo c2\n" 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!( diff --git a/src/cmds/system/log_cmd.rs b/src/cmds/system/log_cmd.rs index c17c50fe86..16528d7496 100644 --- a/src/cmds/system/log_cmd.rs +++ b/src/cmds/system/log_cmd.rs @@ -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 { @@ -272,7 +272,7 @@ fn analyze_logs_with_options(content: &str, recent_events: usize, keywords: &[St if !picked.is_empty() { base.push_str("\n\n[RECENT_EVENTS]\n"); for (ln, msg) in picked { - let truncated = if msg.len() > 200 { + let truncated = if msg.chars().count() > 200 { let t: String = msg.chars().take(197).collect(); format!("{}...", t) } else { @@ -358,4 +358,22 @@ mod tests { // 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/read.rs b/src/cmds/system/read.rs index 0fe3a319e8..bb492d8102 100644 --- a/src/cmds/system/read.rs +++ b/src/cmds/system/read.rs @@ -315,8 +315,7 @@ fn looks_binary_bytes(bytes: &[u8]) -> bool { } fn looks_utf16_no_bom(sample: &[u8]) -> bool { - #[allow(clippy::manual_is_multiple_of)] - if sample.len() < 4 || sample.len() % 2 != 0 { + if sample.len() < 4 { return false; } let mut zeros_even = 0usize; @@ -701,6 +700,18 @@ fn main() {{ ); } + #[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 From 1cef0ba000ba079a6e45fd935af937a5bb17d59b Mon Sep 17 00:00:00 2001 From: isink17 <39876158+isink17@users.noreply.github.com> Date: Thu, 28 May 2026 21:53:52 +0200 Subject: [PATCH 6/6] docs(grep): document agent-safe grep modes Document the new agent-friendly grep modes in README: - files-only, count-by-file, and top-files locator modes - agent-safe preset and RTK_AGENT_SAFE PowerShell usage - JSON output, full-lines, and all-matches controls Also keeps the grep JSON current_count cleanup by removing leftover temporary cap state. --- README.md | 14 ++++++++++++++ src/cmds/system/grep_cmd.rs | 8 ++------ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 84e627e383..58916193bd 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,20 @@ rtk grep "Foo" . --all --full-lines 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 diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index 9086d77d82..6e2acad5d7 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -602,18 +602,17 @@ impl GrepJsonOutput { let mut files: Vec<(&String, &Vec<(usize, &str)>)> = by_file_raw.iter().collect(); files.sort_by_key(|(f, _)| *f); - let mut remaining_total = effective_total; let mut current_count = 0usize; let mut out_files: Vec = Vec::new(); for (file, matches) in files { - if let Some(total_cap) = remaining_total { + 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) = remaining_total { + if let Some(total_cap) = effective_total { if current_count >= total_cap { break; } @@ -632,7 +631,6 @@ impl GrepJsonOutput { 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. @@ -645,8 +643,6 @@ impl GrepJsonOutput { } } - let _ = remaining_total.take(); - Self { pattern: pattern.to_string(), total_matches,