11use crate :: error:: { Result , SofosError } ;
2+ use crate :: tools:: utils:: { MAX_TOOL_OUTPUT_TOKENS , TruncationKind , truncate_for_context} ;
23use std:: path:: PathBuf ;
34use std:: process:: Command ;
45
56/// Shared so the UI display layer can strip it without duplicating the literal.
67pub 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 ) ]
952pub 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\n Test Pattern\n Another 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\n some -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