diff --git a/README.md b/README.md index 9092a9d57c..58916193bd 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,36 @@ git status # Automatically rewritten to rtk git status Hook-based agents rewrite Bash commands (e.g., `git status` -> `rtk git status`) before execution. Plugin-based agents, including Hermes, use their plugin API to rewrite commands before execution. The agent receives compact output without needing to call `rtk` explicitly. +## Grep (agent-friendly) + +```bash +rtk grep "Foo" . --files-only +rtk grep "Foo" . --count-by-file +rtk grep "Foo" . --top-files 10 +rtk grep "Foo" . --agent-safe +rtk grep "Foo" . --agent-safe --max-per-file 30 +rtk grep "Foo" . --json +rtk grep "Foo" . --agent-safe --json +rtk grep "Foo" . --all --full-lines + +# Opt-in preset for agents (grep only in this slice): +RTK_AGENT_SAFE=1 rtk grep "Foo" . +``` + +Notes: +- `--files-only`: locator mode (paths only) +- `--count-by-file`: counts per file +- `--top-files N`: ranked file summary (top N files) +- `--agent-safe`: caps match spam + adds summary/hints (flags override env/config) +- `--json`: machine-readable JSON only (no human text) +- `--all`: disables match caps +- `--full-lines`: disables line clipping + +PowerShell: +```powershell +$env:RTK_AGENT_SAFE="1"; rtk grep "Foo" src +``` + **Important:** the hook only runs on Bash tool calls. Claude Code built-in tools like `Read`, `Grep`, and `Glob` do not pass through the Bash hook, so they are not auto-rewritten. To get RTK's compact output for those workflows, use shell commands (`cat`/`head`/`tail`, `rg`/`grep`, `find`) or call `rtk read`, `rtk grep`, or `rtk find` directly. ## How It Works @@ -148,7 +178,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/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/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/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..6e2acad5d7 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,543 @@ pub fn run( eprintln!("{}", result.stderr.trim()); } let msg = format!("0 matches for '{}'", pattern); - println!("{}", msg); + if json { + let out = GrepJsonOutput::no_matches(pattern, files_only, count_by_file, top_files); + println!("{}", serde_json::to_string(&out)?); + } else { + println!("{}", msg); + } timer.track( &format!("grep -rn '{}' {}", pattern, path), "rtk grep", &raw_output, - &msg, + if json { "" } else { &msg }, ); return Ok(exit_code); } - // Always filter: truncate long lines, apply per-file and global caps. - // Output in standard file:line:content format that AI agents can parse. - // (A passthrough approach yields 0% savings — no reason for RTK to exist on that path.) - let total_matches = result.stdout.lines().count(); + let (rtk_output, stats) = render_grep_output( + pattern, + &result.stdout, + &GrepRenderOptions { + max_line_chars, + max_matches, + max_per_file, + uncapped, + files_only, + count_by_file, + agent_safe, + summary_enabled, + context_only, + }, + top_files, + json, + ); - let context_re = if context_only { - Regex::new(&format!("(?i).{{0,20}}{}.*", regex::escape(pattern))).ok() - } else { - None - }; + print!("{}", rtk_output); + timer.track( + &format!("grep -rn '{}' {}", pattern, path), + "rtk grep", + &raw_output, + &rtk_output, + ); - let mut by_file: HashMap> = HashMap::new(); - for line in result.stdout.lines() { + if json && stats.printed_summary { + // In JSON mode, tracking output is the JSON itself; ensure no extra text sneaks in. + } + + Ok(exit_code) +} + +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 current_count = 0usize; + let mut out_files: Vec = Vec::new(); + for (file, matches) in files { + if let Some(total_cap) = effective_total { + if current_count >= total_cap { + break; + } + } + let mut out_matches: Vec = Vec::new(); + for (used_in_file, (line, content)) in matches.iter().enumerate() { + if let Some(total_cap) = effective_total { + if current_count >= total_cap { + break; + } + } + if let Some(per_file_cap) = effective_per_file { + if used_in_file >= per_file_cap { + break; + } + } + + let text = if let Some(max_len) = effective_line_chars { + clean_line(content, max_len, None, pattern) + } else { + content.trim().to_string() + }; + out_matches.push(GrepJsonMatch { line: *line, text }); + current_count += 1; + } + + // In normal JSON mode, avoid emitting empty file entries that can be + // created when we early-break due to a total cap. + if !out_matches.is_empty() { + out_files.push(GrepJsonFile { + path: compact_path(file), + count: matches.len(), + matches: out_matches, + }); + } + } + + Self { + pattern: pattern.to_string(), + total_matches, + files_matched, + displayed_matches: current_count, + omitted_total, + omitted_per_file, + clipped_lines, + truncated, + top_files: None, + files: out_files, + hints: hints.to_vec(), + } + } } /// Parses a single rg/grep match line of the form `file\0line_number:content`. @@ -248,44 +750,93 @@ fn has_format_flag(extra_args: &[String]) -> bool { fn clean_line(line: &str, max_len: usize, context_re: Option<&Regex>, pattern: &str) -> String { let trimmed = line.trim(); + if max_len == 0 { + return String::new(); + } + if let Some(re) = context_re { if let Some(m) = re.find(trimmed) { let matched = m.as_str(); - if matched.len() <= max_len { + if matched.chars().count() <= max_len { return matched.to_string(); } } } - if trimmed.len() <= max_len { + if trimmed.chars().count() <= max_len { trimmed.to_string() } else { + if max_len <= 3 { + return trimmed.chars().take(max_len).collect(); + } + if max_len <= 6 { + let t: String = trimmed.chars().take(max_len - 3).collect(); + return format!("{}...", t); + } + let lower = trimmed.to_lowercase(); let pattern_lower = pattern.to_lowercase(); - if let Some(pos) = lower.find(&pattern_lower) { - let char_pos = lower[..pos].chars().count(); + if lower.contains(&pattern_lower) { let chars: Vec = trimmed.chars().collect(); + let lower_chars: Vec = lower.chars().collect(); + let pat_chars: Vec = pattern_lower.chars().collect(); + + // Find match start/end in char indices (not bytes) so we don't break UTF-8. + let mut match_start = 0usize; + let mut match_end = 0usize; + 'outer: for i in 0..=lower_chars.len().saturating_sub(pat_chars.len()) { + for j in 0..pat_chars.len() { + if lower_chars[i + j] != pat_chars[j] { + continue 'outer; + } + } + match_start = i; + match_end = i + pat_chars.len(); + break; + } + let char_len = chars.len(); + if match_end <= match_start || match_end > char_len { + let t: String = trimmed.chars().take(max_len.saturating_sub(3)).collect(); + return format!("{}...", t); + } - let start = char_pos.saturating_sub(max_len / 3); - let end = (start + max_len).min(char_len); - let start = if end == char_len { - end.saturating_sub(max_len) - } else { - start - }; + // Reserve room for prefix/suffix + ellipses so match stays visible. + let ellipses = 3usize; + let remaining = max_len.saturating_sub(ellipses * 2); + let match_len = match_end - match_start; + if remaining <= match_len + 2 { + // Not enough room for context; show match-centered slice. + let start = match_start.saturating_sub(1); + let end = (start + remaining).min(char_len); + let slice: String = chars[start..end].iter().collect(); + return format!("...{}...", slice); + } - let slice: String = chars[start..end].iter().collect(); - if start > 0 && end < char_len { - format!("...{}...", slice) - } else if start > 0 { - format!("...{}", slice) - } else { - format!("{}...", slice) + let context_budget = remaining - match_len; + let prefix_budget = context_budget / 2; + let suffix_budget = context_budget - prefix_budget; + + let prefix_start = match_start.saturating_sub(prefix_budget); + let prefix = &chars[prefix_start..match_start]; + let matched = &chars[match_start..match_end]; + let suffix_end = (match_end + suffix_budget).min(char_len); + let suffix = &chars[match_end..suffix_end]; + + let mut out = String::new(); + if prefix_start > 0 { + out.push_str("..."); + } + out.push_str(&prefix.iter().collect::()); + out.push_str(&matched.iter().collect::()); + out.push_str(&suffix.iter().collect::()); + if suffix_end < char_len { + out.push_str("..."); } + out } else { - let t: String = trimmed.chars().take(max_len - 3).collect(); + let t: String = trimmed.chars().take(max_len.saturating_sub(3)).collect(); format!("{}...", t) } } @@ -312,6 +863,7 @@ fn compact_path(path: &str) -> String { #[cfg(test)] mod tests { use super::*; + use serde_json::Value; #[test] fn test_clean_line() { @@ -345,6 +897,53 @@ mod tests { assert!(!cleaned.is_empty()); } + #[test] + fn test_clean_line_utf8_croatian() { + let line = " Ovo je dugačka rečenica sa slovima čćđšž i uzorkom FooBar negdje u sredini. "; + let cleaned = clean_line(line, 24, None, "FooBar"); + assert!(cleaned.chars().count() <= 24); + assert!(cleaned.contains("FooBar")); + } + + #[test] + fn test_clean_line_tiny_max_len() { + let line = " abcdef "; + assert_eq!(clean_line(line, 0, None, "c"), ""); + assert_eq!(clean_line(line, 1, None, "c").chars().count(), 1); + assert_eq!(clean_line(line, 2, None, "c").chars().count(), 2); + assert_eq!(clean_line(line, 3, None, "c").chars().count(), 3); + assert!(clean_line(line, 4, None, "c").chars().count() <= 4); + assert!(clean_line(line, 5, None, "c").chars().count() <= 5); + assert!(clean_line(line, 6, None, "c").chars().count() <= 6); + } + + #[test] + fn test_legacy_caps_do_not_print_summary_by_default() { + let stdout = "b.txt\x001:foo bar baz\n\ +a.txt\x001:foo x\n\ +a.txt\x002:foo y\n"; + let (out, stats) = render_grep_output( + "Foo", + stdout, + &GrepRenderOptions { + files_only: false, + count_by_file: false, + uncapped: false, + agent_safe: false, + summary_enabled: false, + context_only: false, + // Legacy-like defaults (from CLI flags -l/--max and config per-file). + max_line_chars: Some(5), + max_matches: Some(1), + max_per_file: None, + }, + None, + false, + ); + assert!(!out.contains("summary:")); + assert!(stats.omitted_total > 0 || stats.clipped_lines > 0); + } + #[test] fn test_clean_line_emoji() { let line = "🎉🎊🎈🎁🎂🎄 some text 🎃🎆🎇✨"; @@ -569,4 +1168,446 @@ mod tests { } // If rg is not installed, skip gracefully (test still passes) } + + fn sample_stdout() -> &'static str { + // Shape: `file\0line:content` (rg -0 / grep -Z) + "b.txt\x001:foo bar baz\n\ +a.txt\x001:foo x\n\ +a.txt\x002:foo y\n\ +a.txt\x003:foo z\n\ +c.txt\x001:foo c1\n\ +c.txt\x002:foo c2\n" + } + + #[test] + fn test_files_only_unique_sorted() { + let (out, _stats) = render_grep_output( + "foo", + sample_stdout(), + &GrepRenderOptions { + files_only: true, + count_by_file: false, + uncapped: false, + agent_safe: false, + summary_enabled: false, + context_only: false, + max_matches: None, + max_per_file: None, + max_line_chars: None, + }, + None, + false, + ); + assert_eq!(out, "a.txt\nb.txt\nc.txt\n"); + } + + #[test] + fn test_count_by_file_sorted() { + let (out, _stats) = render_grep_output( + "foo", + sample_stdout(), + &GrepRenderOptions { + files_only: false, + count_by_file: true, + uncapped: false, + agent_safe: false, + summary_enabled: false, + context_only: false, + max_matches: None, + max_per_file: None, + max_line_chars: None, + }, + None, + false, + ); + // a.txt has 3, c.txt has 2, b.txt has 1 + assert_eq!(out, "3 a.txt\n2 c.txt\n1 b.txt\n"); + } + + #[test] + fn test_total_cap_omits_and_summarizes() { + let (out, stats) = render_grep_output( + "foo", + sample_stdout(), + &GrepRenderOptions { + files_only: false, + count_by_file: false, + uncapped: false, + agent_safe: false, + summary_enabled: true, + context_only: false, + max_matches: Some(2), + max_per_file: Some(10), + max_line_chars: None, + }, + None, + false, + ); + assert!(out.contains("summary:")); + assert_eq!(stats.shown, 2); + assert!(stats.omitted_total > 0); + } + + #[test] + fn test_per_file_cap_omits_and_summarizes() { + let (out, stats) = render_grep_output( + "foo", + sample_stdout(), + &GrepRenderOptions { + files_only: false, + count_by_file: false, + uncapped: false, + agent_safe: false, + summary_enabled: true, + context_only: false, + max_matches: Some(100), + max_per_file: Some(1), + max_line_chars: None, + }, + None, + false, + ); + assert!(out.contains("summary:")); + assert!(stats.omitted_per_file > 0); + } + + #[test] + fn test_line_clipping_and_full_lines_escape_hatch() { + let stdout = "a.txt\x001:prefix foo suffix and extra\n"; + let (clipped, stats_clipped) = render_grep_output( + "foo", + stdout, + &GrepRenderOptions { + files_only: false, + count_by_file: false, + uncapped: false, + agent_safe: false, + summary_enabled: true, + context_only: false, + max_matches: Some(100), + max_per_file: Some(10), + max_line_chars: Some(10), + }, + None, + false, + ); + assert!(stats_clipped.clipped_lines >= 1); + assert!(clipped.contains("foo")); + + let (full, stats_full) = render_grep_output( + "foo", + stdout, + &GrepRenderOptions { + files_only: false, + count_by_file: false, + uncapped: false, + agent_safe: false, + summary_enabled: false, + context_only: false, + max_matches: Some(100), + max_per_file: Some(10), + max_line_chars: None, + }, + None, + false, + ); + assert_eq!(stats_full.clipped_lines, 0); + assert!(full.contains("prefix foo suffix and extra")); + } + + #[test] + fn test_agent_safe_preset_and_override_semantics() { + // agent-safe: total=80, per-file=5, line=240; explicit max_per_file overrides to 30. + // (Dispatch logic in main.rs; we validate render behavior here.) + let mut many = String::new(); + for i in 1..=40 { + many.push_str(&format!("a.txt\x00{}:foo {}\n", i, i)); + } + + let (_out, stats_default) = render_grep_output( + "foo", + &many, + &GrepRenderOptions { + files_only: false, + count_by_file: false, + uncapped: false, + agent_safe: true, + summary_enabled: true, + context_only: false, + max_matches: Some(80), + max_per_file: Some(5), + max_line_chars: Some(240), + }, + None, + false, + ); + assert_eq!(stats_default.shown, 5); + + let (_out, stats_override) = render_grep_output( + "foo", + &many, + &GrepRenderOptions { + files_only: false, + count_by_file: false, + uncapped: false, + agent_safe: true, + summary_enabled: true, + context_only: false, + max_matches: Some(80), + max_per_file: Some(30), + max_line_chars: Some(240), + }, + None, + false, + ); + assert_eq!(stats_override.shown, 30); + } + + #[test] + fn test_summary_hint_includes_concrete_file_and_line() { + let stdout = "src\\\\main.rs\u{0}371:Foo bar\n"; + let (out, _stats) = render_grep_output( + "Foo", + stdout, + &GrepRenderOptions { + max_line_chars: Some(80), + max_matches: Some(200), + max_per_file: Some(25), + uncapped: false, + files_only: false, + count_by_file: false, + agent_safe: true, + summary_enabled: true, + context_only: false, + }, + None, + false, + ); + assert!(out.contains("rtk read \"src\\\\main.rs\" --lines 366:376")); + } + + #[test] + fn test_top_files_sorts_and_limits() { + let stdout = concat!( + "b.rs\u{0}1:Foo\n", + "a.rs\u{0}1:Foo\n", + "a.rs\u{0}2:Foo\n" + ); + let (out, _stats) = render_grep_output( + "Foo", + stdout, + &GrepRenderOptions { + max_line_chars: Some(80), + max_matches: Some(200), + max_per_file: Some(25), + uncapped: false, + files_only: false, + count_by_file: false, + agent_safe: false, + summary_enabled: false, + context_only: false, + }, + Some(1), + false, + ); + assert!(out.contains("2 a.rs")); + assert!(!out.contains("1 b.rs")); + } + + #[test] + fn test_json_output_is_valid_json_only() { + let stdout = "src\\\\main.rs\u{0}371:Foo bar\n"; + let (out, _stats) = render_grep_output( + "Foo", + stdout, + &GrepRenderOptions { + max_line_chars: Some(80), + max_matches: Some(1), + max_per_file: Some(1), + uncapped: false, + files_only: false, + count_by_file: false, + agent_safe: true, + summary_enabled: true, + context_only: false, + }, + None, + true, + ); + let v: Value = serde_json::from_str(out.trim()).expect("valid json"); + assert_eq!(v["pattern"], "Foo"); + } + + #[test] + fn test_json_output_total_cap_does_not_emit_empty_files() { + let stdout = concat!( + "b.rs\u{0}1:Foo b\n", + "a.rs\u{0}1:Foo a\n", + "a.rs\u{0}2:Foo a2\n" + ); + let (out, _stats) = render_grep_output( + "Foo", + stdout, + &GrepRenderOptions { + max_line_chars: Some(80), + max_matches: Some(1), + max_per_file: Some(25), + uncapped: false, + files_only: false, + count_by_file: false, + agent_safe: true, + summary_enabled: true, + context_only: false, + }, + None, + true, + ); + let v: Value = serde_json::from_str(out.trim()).expect("valid json"); + assert_eq!(v["files"].as_array().unwrap().len(), 1); + assert_eq!(v["files"][0]["matches"].as_array().unwrap().len(), 1); + } + + #[test] + fn test_json_output_is_valid_for_files_only_and_count_by_file() { + let stdout = concat!( + "b.rs\u{0}1:Foo\n", + "a.rs\u{0}1:Foo\n", + "a.rs\u{0}2:Foo\n" + ); + + let (out_files_only, _stats) = render_grep_output( + "Foo", + stdout, + &GrepRenderOptions { + max_line_chars: Some(80), + max_matches: Some(200), + max_per_file: Some(25), + uncapped: false, + files_only: true, + count_by_file: false, + agent_safe: true, + summary_enabled: true, + context_only: false, + }, + None, + true, + ); + let v1: Value = serde_json::from_str(out_files_only.trim()).expect("valid json"); + assert_eq!(v1["pattern"], "Foo"); + assert!(v1["files"].is_array()); + + let (out_count_by_file, _stats) = render_grep_output( + "Foo", + stdout, + &GrepRenderOptions { + max_line_chars: Some(80), + max_matches: Some(200), + max_per_file: Some(25), + uncapped: false, + files_only: false, + count_by_file: true, + agent_safe: true, + summary_enabled: true, + context_only: false, + }, + None, + true, + ); + let v2: Value = serde_json::from_str(out_count_by_file.trim()).expect("valid json"); + assert_eq!(v2["pattern"], "Foo"); + assert!(v2["files"].is_array()); + } + + #[test] + fn test_json_total_cap_one_emits_one_match_and_non_empty_files() { + let stdout = concat!("b.rs\u{0}1:Foo\n", "a.rs\u{0}1:Foo\n", "a.rs\u{0}2:Foo\n"); + let (out, _stats) = render_grep_output( + "Foo", + stdout, + &GrepRenderOptions { + max_line_chars: Some(80), + max_matches: Some(1), + max_per_file: None, + uncapped: false, + files_only: false, + count_by_file: false, + agent_safe: false, + summary_enabled: false, + context_only: false, + }, + None, + true, + ); + let v: Value = serde_json::from_str(out.trim()).expect("valid json"); + assert_eq!(v["displayedMatches"], 1); + assert!(!v["files"].as_array().unwrap().is_empty()); + let mut total_json_matches = 0usize; + for f in v["files"].as_array().unwrap() { + total_json_matches += f["matches"].as_array().unwrap().len(); + } + assert_eq!(total_json_matches, 1); + } + + #[test] + fn test_agent_safe_json_total_cap_does_not_emit_empty_files_when_matches_exist() { + let stdout = concat!("b.rs\u{0}1:Foo\n", "a.rs\u{0}1:Foo\n", "a.rs\u{0}2:Foo\n"); + let (out, _stats) = render_grep_output( + "Foo", + stdout, + &GrepRenderOptions { + max_line_chars: Some(80), + max_matches: Some(1), + max_per_file: None, + uncapped: false, + files_only: false, + count_by_file: false, + agent_safe: true, + summary_enabled: true, + context_only: false, + }, + None, + true, + ); + let v: Value = serde_json::from_str(out.trim()).expect("valid json"); + assert_eq!(v["displayedMatches"], 1); + let files = v["files"].as_array().unwrap(); + assert!(!files.is_empty()); + assert!(!files[0]["matches"].as_array().unwrap().is_empty()); + } + + #[test] + fn test_json_total_and_per_file_caps_both_respected() { + let stdout = concat!( + "a.rs\u{0}1:Foo\n", + "a.rs\u{0}2:Foo\n", + "b.rs\u{0}1:Foo\n", + "b.rs\u{0}2:Foo\n" + ); + let (out, _stats) = render_grep_output( + "Foo", + stdout, + &GrepRenderOptions { + max_line_chars: Some(80), + max_matches: Some(2), + max_per_file: Some(1), + uncapped: false, + files_only: false, + count_by_file: false, + agent_safe: false, + summary_enabled: false, + context_only: false, + }, + None, + true, + ); + let v: Value = serde_json::from_str(out.trim()).expect("valid json"); + assert_eq!(v["displayedMatches"], 2); + let mut total_json_matches = 0usize; + for f in v["files"].as_array().unwrap() { + let m = f["matches"].as_array().unwrap().len(); + assert!(m <= 1); + total_json_matches += m; + } + assert_eq!(total_json_matches, 2); + } } diff --git a/src/cmds/system/log_cmd.rs b/src/cmds/system/log_cmd.rs index c17c50fe86..627746c7cd 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 { @@ -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\ @@ -358,4 +377,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 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")); + } }