Skip to content

Commit 46dd069

Browse files
committed
Bound tool outputs, fix large-file edit corruption, gate glob_files
1 parent 9bfecc0 commit 46dd069

6 files changed

Lines changed: 662 additions & 142 deletions

File tree

src/tools/codesearch.rs

Lines changed: 174 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,53 @@
11
use crate::error::{Result, SofosError};
2+
use crate::tools::utils::{MAX_TOOL_OUTPUT_TOKENS, TruncationKind, truncate_for_context};
23
use std::path::PathBuf;
34
use std::process::Command;
45

56
/// Shared so the UI display layer can strip it without duplicating the literal.
67
pub const SEARCH_RESULTS_PREFIX: &str = "Code search results:\n\n";
78

9+
/// Directories that are almost always build artefacts or vendored code and
10+
/// should never show up in a code search. ripgrep already respects
11+
/// `.gitignore`, but that protection vanishes the moment the workspace
12+
/// isn't a clean git repo (symlinked-in dir, freshly-cloned submodule with
13+
/// a committed lockfile, etc.) — so we apply these as belt-and-suspenders
14+
/// `--glob '!<dir>/**'` excludes on every call. The human-readable list
15+
/// in the tool schema is derived from this via [`default_exclude_dirs_human`],
16+
/// so adding an entry here automatically updates the description the
17+
/// model sees.
18+
pub const DEFAULT_EXCLUDE_DIRS: &[&str] =
19+
&["target", "node_modules", ".git", "dist", "build"];
20+
21+
/// Default per-file match cap when the caller doesn't specify `max_results`.
22+
/// Exposed `pub` so the schema description in `types.rs` stays in sync
23+
/// without re-hardcoding the value.
24+
pub const DEFAULT_MAX_RESULTS_PER_FILE: usize = 50;
25+
26+
/// Per-line column cap (300). Matched lines longer than this are replaced
27+
/// with a short preview (via `--max-columns-preview`) instead of being
28+
/// dumped in full. Prevents a single minified / generated line from blowing
29+
/// past the tool-output budget.
30+
const MAX_COLUMNS_FLAG: &str = "--max-columns=300";
31+
32+
/// Per-file size cap (1 MB). Files larger than this are skipped entirely —
33+
/// they're overwhelmingly lockfiles, generated bundles, or binaries
34+
/// mis-detected as text. There is no per-call override; if a caller
35+
/// legitimately needs to grep inside a huge file, `execute_bash` with a
36+
/// direct `rg` invocation is the escape hatch.
37+
const MAX_FILESIZE_FLAG: &str = "--max-filesize=1M";
38+
39+
/// Render [`DEFAULT_EXCLUDE_DIRS`] as a comma-separated human string
40+
/// (e.g. `"target/, node_modules/, .git/, dist/, build/"`) for use in
41+
/// tool-schema descriptions. Kept in `codesearch` so the const array
42+
/// remains the single source of truth.
43+
pub fn default_exclude_dirs_human() -> String {
44+
DEFAULT_EXCLUDE_DIRS
45+
.iter()
46+
.map(|d| format!("{}/", d))
47+
.collect::<Vec<_>>()
48+
.join(", ")
49+
}
50+
851
#[derive(Clone)]
952
pub struct CodeSearchTool {
1053
workspace: PathBuf,
@@ -54,57 +97,87 @@ impl CodeSearchTool {
5497
)))
5598
}
5699

57-
/// Search for a pattern in the codebase using ripgrep
100+
/// Search for a pattern in the codebase using ripgrep.
101+
///
102+
/// When `include_ignored` is `true`, the hard-coded [`DEFAULT_EXCLUDE_DIRS`]
103+
/// and ripgrep's automatic `.gitignore` / `.ignore` filtering are both
104+
/// disabled — the escape hatch for the rare case where the user genuinely
105+
/// needs to grep inside `target/`, `node_modules/`, or other normally-
106+
/// skipped paths. Defaults to `false` so the usual output-size protection
107+
/// stays on.
58108
pub fn search(
59109
&self,
60110
pattern: &str,
61111
file_type: Option<&str>,
62112
max_results: Option<usize>,
113+
include_ignored: bool,
63114
) -> Result<String> {
64115
let mut cmd = Command::new(&self.rg_path);
65116

66117
cmd.arg("--heading")
67118
.arg("--line-number")
68119
.arg("--color=never")
69120
.arg("--no-messages")
70-
.arg("--with-filename");
121+
.arg("--with-filename")
122+
.arg(MAX_COLUMNS_FLAG)
123+
.arg("--max-columns-preview")
124+
.arg(MAX_FILESIZE_FLAG);
71125

72-
if let Some(max) = max_results {
73-
cmd.arg("--max-count").arg(max.to_string());
126+
if include_ignored {
127+
// Bypass gitignore / .ignore / global ignores as well — callers
128+
// asking for "include ignored" almost always want everything,
129+
// not just our hard-coded extras.
130+
cmd.arg("--no-ignore");
74131
} else {
75-
cmd.arg("--max-count").arg("50");
132+
for dir in DEFAULT_EXCLUDE_DIRS {
133+
cmd.arg("--glob").arg(format!("!{}/**", dir));
134+
}
76135
}
77136

137+
let max_count = max_results.unwrap_or(DEFAULT_MAX_RESULTS_PER_FILE);
138+
cmd.arg("--max-count").arg(max_count.to_string());
139+
78140
if let Some(ft) = file_type {
79141
if !ft.trim().is_empty() {
80142
cmd.arg("--type").arg(ft);
81143
}
82144
}
83145

84-
cmd.arg(pattern);
146+
// `--` terminates flag parsing so a pattern like `-v`, `--files`,
147+
// or `--no-config` is treated as a literal search string instead
148+
// of flipping ripgrep's behaviour. Without this, a confused model
149+
// emitting `pattern="-v"` would silently invert every match.
150+
cmd.arg("--").arg(pattern);
85151
cmd.current_dir(&self.workspace);
86152

87153
let output = cmd
88154
.output()
89155
.map_err(|e| SofosError::ToolExecution(format!("Failed to execute ripgrep: {}", e)))?;
90156

91-
if output.status.success() {
92-
let stdout = String::from_utf8_lossy(&output.stdout);
93-
if stdout.trim().is_empty() {
94-
Ok(format!("No matches found for pattern: '{}'", pattern))
95-
} else {
96-
Ok(stdout.to_string())
97-
}
157+
let stdout = String::from_utf8_lossy(&output.stdout);
158+
if output.status.success() && !stdout.trim().is_empty() {
159+
return Ok(truncate_for_context(
160+
&stdout,
161+
MAX_TOOL_OUTPUT_TOKENS,
162+
TruncationKind::SearchOutput,
163+
));
164+
}
165+
166+
// ripgrep exits 1 for "no matches" (non-success but benign) and
167+
// 2+ for real errors. An empty stderr with a non-success exit
168+
// is the common "no matches" shape; we also keep the legacy
169+
// "No matches found" substring check for defence-in-depth.
170+
let stderr = String::from_utf8_lossy(&output.stderr);
171+
let is_no_match = output.status.success()
172+
|| stderr.is_empty()
173+
|| stderr.contains("No matches found");
174+
if is_no_match {
175+
Ok(format!("No matches found for pattern: '{}'", pattern))
98176
} else {
99-
let stderr = String::from_utf8_lossy(&output.stderr);
100-
if stderr.contains("No matches found") || stderr.is_empty() {
101-
Ok(format!("No matches found for pattern: '{}'", pattern))
102-
} else {
103-
Err(SofosError::ToolExecution(format!(
104-
"ripgrep error: {}",
105-
stderr
106-
)))
107-
}
177+
Err(SofosError::ToolExecution(format!(
178+
"ripgrep error: {}",
179+
stderr
180+
)))
108181
}
109182
}
110183

@@ -131,6 +204,26 @@ mod tests {
131204
use std::fs;
132205
use tempfile::TempDir;
133206

207+
#[test]
208+
fn default_exclude_dirs_human_renders_every_entry() {
209+
let rendered = default_exclude_dirs_human();
210+
for dir in DEFAULT_EXCLUDE_DIRS {
211+
let expected = format!("{}/", dir);
212+
assert!(
213+
rendered.contains(&expected),
214+
"human rendering '{}' missing entry '{}'",
215+
rendered,
216+
expected
217+
);
218+
}
219+
// Comma-joined with one separator per boundary, no trailing comma.
220+
assert_eq!(
221+
rendered.matches(", ").count(),
222+
DEFAULT_EXCLUDE_DIRS.len() - 1
223+
);
224+
assert!(!rendered.ends_with(','));
225+
}
226+
134227
#[test]
135228
fn test_code_search_creation() {
136229
let temp = TempDir::new().unwrap();
@@ -151,10 +244,68 @@ mod tests {
151244
fs::write(&test_file, "Hello World\nTest Pattern\nAnother Line").unwrap();
152245

153246
if let Ok(tool) = CodeSearchTool::new(temp.path().to_path_buf()) {
154-
let result = tool.search("Pattern", None, None);
247+
let result = tool.search("Pattern", None, None, false);
155248
if let Ok(output) = result {
156249
assert!(output.contains("Pattern") || output.contains("No matches"));
157250
}
158251
}
159252
}
253+
254+
#[test]
255+
fn search_treats_flag_like_pattern_as_literal() {
256+
// Without `--` before the pattern, ripgrep would interpret `-v` as
257+
// the "invert match" flag and `--files` as "list files instead of
258+
// search". Both would silently return the wrong output instead of
259+
// treating the token as a literal search string.
260+
let temp = TempDir::new().unwrap();
261+
fs::write(
262+
temp.path().join("notes.txt"),
263+
"release --files checklist\nsome -v output\n",
264+
)
265+
.unwrap();
266+
267+
let Ok(tool) = CodeSearchTool::new(temp.path().to_path_buf()) else {
268+
return;
269+
};
270+
271+
let out = tool.search("--files", None, None, false).unwrap();
272+
assert!(
273+
out.contains("--files checklist"),
274+
"pattern '--files' must be treated as literal; got: {}",
275+
out
276+
);
277+
278+
let out = tool.search("-v", None, None, false).unwrap();
279+
assert!(
280+
out.contains("some -v output"),
281+
"pattern '-v' must be treated as literal; got: {}",
282+
out
283+
);
284+
}
285+
286+
#[test]
287+
fn search_default_excludes_target_directory() {
288+
let temp = TempDir::new().unwrap();
289+
let target_dir = temp.path().join("target");
290+
fs::create_dir_all(&target_dir).unwrap();
291+
fs::write(target_dir.join("junk.rs"), "unique_marker_xyz\n").unwrap();
292+
293+
let Ok(tool) = CodeSearchTool::new(temp.path().to_path_buf()) else {
294+
return;
295+
};
296+
297+
let default_output = tool.search("unique_marker_xyz", None, None, false).unwrap();
298+
assert!(
299+
default_output.contains("No matches"),
300+
"target/ should be excluded by default; got: {}",
301+
default_output
302+
);
303+
304+
let override_output = tool.search("unique_marker_xyz", None, None, true).unwrap();
305+
assert!(
306+
override_output.contains("unique_marker_xyz"),
307+
"include_ignored=true must surface files under target/; got: {}",
308+
override_output
309+
);
310+
}
160311
}

0 commit comments

Comments
 (0)