1use std::path::{Component, Path, PathBuf};
10use std::sync::Arc;
11
12use futures::FutureExt as _;
13use futures::future::BoxFuture;
14use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
15use ignore::WalkBuilder;
16use memchr::memmem;
17use pi_agent::{AgentTool, AgentToolResult, ToolError, ToolUpdates};
18use pi_ai::ToolResultContent;
19use pi_ai::types::TextContent;
20use regex::RegexBuilder;
21use schemars::JsonSchema;
22use serde::{Deserialize, Serialize};
23use serde_json::{Map, Value, json};
24use tokio::task;
25use tokio_util::sync::CancellationToken;
26
27use super::{
28 DEFAULT_MAX_BYTES, GREP_MAX_LINE_LENGTH, PathResolveError, TruncationOptions, TruncationResult,
29 format_size, resolve_to_cwd, truncate_head, truncate_line,
30};
31
32const DEFAULT_LIMIT: usize = 100;
34
35#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
37#[serde(rename_all = "camelCase")]
38pub struct GrepToolInput {
39 #[schemars(description = "Search pattern (regex or literal string)")]
41 pub pattern: String,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
44 #[schemars(description = "Directory or file to search (default: current directory)")]
45 pub path: Option<String>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
48 #[schemars(description = "Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'")]
49 pub glob: Option<String>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
53 #[schemars(description = "Case-insensitive search (default: false)")]
54 pub ignore_case: Option<bool>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
57 #[schemars(description = "Treat pattern as literal string instead of regex (default: false)")]
58 pub literal: Option<bool>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 #[schemars(description = "Number of lines to show before and after each match (default: 0)")]
62 pub context: Option<f64>,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
65 #[schemars(description = "Maximum number of matches to return (default: 100)")]
66 pub limit: Option<f64>,
67}
68
69#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
71#[serde(rename_all = "camelCase")]
72pub struct GrepToolDetails {
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub truncation: Option<TruncationResult>,
76 #[serde(skip_serializing_if = "Option::is_none")]
78 pub match_limit_reached: Option<usize>,
79 #[serde(skip_serializing_if = "Option::is_none")]
81 pub lines_truncated: Option<bool>,
82}
83
84#[derive(Clone, Debug)]
86pub struct GrepToolOptions {
87 pub cwd: PathBuf,
89}
90
91impl GrepToolOptions {
92 #[must_use]
94 pub fn new(cwd: impl Into<PathBuf>) -> Self {
95 Self { cwd: cwd.into() }
96 }
97}
98
99#[derive(Clone, Debug)]
101pub struct GrepTool {
102 cwd: PathBuf,
103 parameters: Value,
104 description: String,
105}
106
107impl GrepTool {
108 #[must_use]
110 pub fn new(cwd: impl Into<PathBuf>) -> Self {
111 Self::with_options(GrepToolOptions::new(cwd))
112 }
113
114 #[must_use]
116 pub fn with_options(options: GrepToolOptions) -> Self {
117 let description = format!(
118 "Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore. Output is truncated to {DEFAULT_LIMIT} matches or {}KB (whichever is hit first). Long lines are truncated to {GREP_MAX_LINE_LENGTH} chars.",
119 DEFAULT_MAX_BYTES / 1024
120 );
121 Self {
122 cwd: options.cwd,
123 parameters: grep_parameters_schema(),
124 description,
125 }
126 }
127
128 #[must_use]
130 pub fn parameters_schema() -> Value {
131 grep_parameters_schema()
132 }
133
134 pub fn parse_input(args: &Map<String, Value>) -> Result<GrepToolInput, ToolError> {
140 serde_json::from_value(Value::Object(args.clone()))
141 .map_err(|error| ToolError::new(format!("Grep tool input is invalid. {error}")))
142 }
143}
144
145impl AgentTool for GrepTool {
146 fn name(&self) -> &'static str {
147 "grep"
148 }
149
150 fn label(&self) -> &'static str {
151 "grep"
152 }
153
154 fn description(&self) -> &str {
155 &self.description
156 }
157
158 fn parameters(&self) -> &Value {
159 &self.parameters
160 }
161
162 fn validate_arguments(
163 &self,
164 args: &Map<String, Value>,
165 ) -> Result<Map<String, Value>, ToolError> {
166 let _ = Self::parse_input(args)?;
167 Ok(args.clone())
168 }
169
170 fn execute(
171 &self,
172 _tool_call_id: &str,
173 args: Map<String, Value>,
174 cancel: CancellationToken,
175 _updates: ToolUpdates,
176 ) -> BoxFuture<'static, Result<AgentToolResult, ToolError>> {
177 let cwd = self.cwd.clone();
178 async move {
179 throw_if_cancelled(&cancel)?;
180 let input = GrepTool::parse_input(&args)?;
181 let path_arg = input.path.as_deref().unwrap_or(".");
182 let search_path = resolve_to_cwd(path_arg, cwd.to_string_lossy().as_ref())
183 .map_err(|error| path_error(&error))?;
184 let effective_limit = effective_limit_at_least_one(input.limit, DEFAULT_LIMIT);
185 let context_value = input.context.map_or(0, |value| {
186 if value.is_finite() && value > 0.0 {
187 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
188 {
189 value as usize
190 }
191 } else {
192 0
193 }
194 });
195 let literal = input.literal.unwrap_or(false);
196 let ignore_case = input.ignore_case.unwrap_or(false);
197 throw_if_cancelled(&cancel)?;
198
199 let meta = tokio::fs::metadata(&search_path).await;
200 let is_directory = match meta {
201 Ok(meta) => meta.is_dir(),
202 Err(_) => {
203 return Err(ToolError::new(format!("Path not found: {search_path}")));
204 }
205 };
206 throw_if_cancelled(&cancel)?;
207
208 let pattern = input.pattern.clone();
209 let glob = input.glob.clone();
210 let search_root = PathBuf::from(&search_path);
211 let cancel_for_search = cancel.clone();
212 let search_result = task::spawn_blocking(move || {
213 run_grep_search(
214 &pattern,
215 &search_root,
216 is_directory,
217 glob.as_deref(),
218 ignore_case,
219 literal,
220 context_value,
221 effective_limit,
222 &cancel_for_search,
223 )
224 })
225 .await
226 .map_err(|error| ToolError::new(format!("grep search failed: {error}")))??;
227 throw_if_cancelled(&cancel)?;
228
229 if search_result.output_lines.is_empty() {
230 return Ok(text_result("No matches found", None));
231 }
232
233 let raw_output = search_result.output_lines.join("\n");
234 let truncation = truncate_head(
235 &raw_output,
236 TruncationOptions {
237 max_lines: Some(usize::MAX),
238 max_bytes: Some(DEFAULT_MAX_BYTES),
239 },
240 );
241 let mut output = truncation.content.clone();
242 let mut details = GrepToolDetails::default();
243 let mut notices = Vec::new();
244 if search_result.match_limit_reached {
245 notices.push(format!(
246 "{effective_limit} matches limit reached. Use limit={} for more, or refine pattern",
247 effective_limit.saturating_mul(2)
248 ));
249 details.match_limit_reached = Some(effective_limit);
250 }
251 if truncation.truncated {
252 notices.push(format!(
253 "{} limit reached",
254 format_size(DEFAULT_MAX_BYTES as u64)
255 ));
256 details.truncation = Some(truncation);
257 }
258 if search_result.lines_truncated {
259 notices.push(format!(
260 "Some lines truncated to {GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines"
261 ));
262 details.lines_truncated = Some(true);
263 }
264 if !notices.is_empty() {
265 output.push_str("\n\n[");
266 output.push_str(¬ices.join(". "));
267 output.push(']');
268 }
269
270 let details = if details.match_limit_reached.is_some()
271 || details.truncation.is_some()
272 || details.lines_truncated.is_some()
273 {
274 Some(details)
275 } else {
276 None
277 };
278 Ok(text_result(output, details))
279 }
280 .boxed()
281 }
282}
283
284struct GrepSearchResult {
285 output_lines: Vec<String>,
286 match_limit_reached: bool,
287 lines_truncated: bool,
288}
289
290enum Matcher {
291 Regex(regex::Regex),
292 Literal { needle: Vec<u8>, ignore_case: bool },
293}
294
295impl Matcher {
296 fn is_match(&self, line: &str) -> bool {
297 match self {
298 Self::Regex(re) => re.is_match(line),
299 Self::Literal {
300 needle,
301 ignore_case,
302 } => {
303 if *ignore_case {
304 let hay = line.to_lowercase();
305 let needle = String::from_utf8_lossy(needle).to_lowercase();
306 hay.contains(&needle)
307 } else {
308 memmem::find(line.as_bytes(), needle).is_some()
309 }
310 }
311 }
312 }
313}
314
315#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
316fn run_grep_search(
317 pattern: &str,
318 search_root: &Path,
319 is_directory: bool,
320 glob: Option<&str>,
321 ignore_case: bool,
322 literal: bool,
323 context_value: usize,
324 effective_limit: usize,
325 cancel: &CancellationToken,
326) -> Result<GrepSearchResult, ToolError> {
327 throw_if_cancelled(cancel)?;
328 let matcher = compile_matcher(pattern, ignore_case, literal)?;
329 let glob_filter = match glob {
330 Some(pattern) => Some(compile_file_glob(pattern)?),
331 None => None,
332 };
333
334 let mut found_hits: Vec<(PathBuf, usize, Option<String>)> = Vec::new();
335 let mut match_limit_reached = false;
336
337 if is_directory {
338 let mut builder = WalkBuilder::new(search_root);
339 builder
340 .hidden(false)
341 .follow_links(false)
342 .git_ignore(true)
343 .git_global(true)
344 .git_exclude(true)
345 .ignore(true)
346 .parents(true)
347 .require_git(is_inside_git_repo(search_root))
348 .sort_by_file_path(std::cmp::Ord::cmp);
349
350 for entry in builder.build() {
351 throw_if_cancelled(cancel)?;
352 if match_limit_reached {
353 break;
354 }
355 let Ok(entry) = entry else {
356 continue;
357 };
358 let path = entry.path();
359 if entry.file_type().is_some_and(|ft| ft.is_dir()) {
360 continue;
361 }
362 if let Some(filter) = &glob_filter {
363 let rel = path.strip_prefix(search_root).unwrap_or(path).to_path_buf();
364 let candidate = to_posix(&rel);
365 let name = path
366 .file_name()
367 .map(|n| n.to_string_lossy().into_owned())
368 .unwrap_or_default();
369 if !filter.is_match(Path::new(&candidate)) && !filter.is_match(Path::new(&name)) {
370 continue;
371 }
372 }
373 search_file(
374 path,
375 &matcher,
376 effective_limit,
377 &mut found_hits,
378 &mut match_limit_reached,
379 cancel,
380 )?;
381 }
382 } else {
383 if let Some(filter) = &glob_filter {
384 let name = search_root
385 .file_name()
386 .map(|n| n.to_string_lossy().into_owned())
387 .unwrap_or_default();
388 if !filter.is_match(Path::new(&name)) {
389 return Ok(GrepSearchResult {
390 output_lines: Vec::new(),
391 match_limit_reached: false,
392 lines_truncated: false,
393 });
394 }
395 }
396 search_file(
397 search_root,
398 &matcher,
399 effective_limit,
400 &mut found_hits,
401 &mut match_limit_reached,
402 cancel,
403 )?;
404 }
405
406 let mut output_lines = Vec::new();
407 let mut lines_truncated = false;
408 let mut file_cache: std::collections::HashMap<PathBuf, Option<Vec<String>>> =
409 std::collections::HashMap::new();
410
411 for (file_path, line_number, line_text) in found_hits {
412 throw_if_cancelled(cancel)?;
413 let relative = format_path(&file_path, search_root, is_directory);
414 if context_value == 0
415 && let Some(text) = line_text
416 {
417 let sanitized = sanitize_match_line(&text);
418 let truncated = truncate_line(&sanitized);
419 if truncated.was_truncated {
420 lines_truncated = true;
421 }
422 output_lines.push(format!("{relative}:{line_number}: {}", truncated.text));
423 continue;
424 }
425
426 let lines = file_cache
427 .entry(file_path.clone())
428 .or_insert_with(|| read_file_lines(&file_path));
429 match lines {
430 None => {
431 output_lines.push(format!("{relative}:{line_number}: (unable to read file)"));
432 }
433 Some(lines) if lines.is_empty() => {
434 output_lines.push(format!("{relative}:{line_number}: (unable to read file)"));
435 }
436 Some(lines) => {
437 let start = if context_value > 0 {
438 line_number.saturating_sub(context_value).max(1)
439 } else {
440 line_number
441 };
442 let end = if context_value > 0 {
443 (line_number + context_value).min(lines.len())
444 } else {
445 line_number
446 };
447 for current in start..=end {
448 let line_text = lines.get(current - 1).map_or("", String::as_str);
449 let sanitized = line_text.replace('\r', "");
450 let truncated = truncate_line(&sanitized);
451 if truncated.was_truncated {
452 lines_truncated = true;
453 }
454 if current == line_number {
455 output_lines.push(format!("{relative}:{current}: {}", truncated.text));
456 } else {
457 output_lines.push(format!("{relative}-{current}- {}", truncated.text));
458 }
459 }
460 }
461 }
462 }
463
464 Ok(GrepSearchResult {
465 output_lines,
466 match_limit_reached,
467 lines_truncated,
468 })
469}
470
471fn search_file(
472 path: &Path,
473 matcher: &Matcher,
474 effective_limit: usize,
475 found_hits: &mut Vec<(PathBuf, usize, Option<String>)>,
476 match_limit_reached: &mut bool,
477 cancel: &CancellationToken,
478) -> Result<(), ToolError> {
479 throw_if_cancelled(cancel)?;
480 let Ok(bytes) = std::fs::read(path) else {
481 return Ok(());
482 };
483 let probe = &bytes[..bytes.len().min(8192)];
485 if probe.contains(&0) {
486 return Ok(());
487 }
488 let content = String::from_utf8_lossy(&bytes);
489 let normalized = content.replace("\r\n", "\n").replace('\r', "\n");
490 for (index, line) in normalized.split('\n').enumerate() {
491 throw_if_cancelled(cancel)?;
492 if found_hits.len() >= effective_limit {
493 *match_limit_reached = true;
494 break;
495 }
496 if matcher.is_match(line) {
497 found_hits.push((path.to_path_buf(), index + 1, Some(line.to_owned())));
498 if found_hits.len() >= effective_limit {
499 *match_limit_reached = true;
500 break;
501 }
502 }
503 }
504 Ok(())
505}
506
507fn read_file_lines(path: &Path) -> Option<Vec<String>> {
508 let bytes = std::fs::read(path).ok()?;
509 let content = String::from_utf8_lossy(&bytes);
510 let normalized = content.replace("\r\n", "\n").replace('\r', "\n");
511 Some(normalized.split('\n').map(str::to_owned).collect())
512}
513
514fn sanitize_match_line(text: &str) -> String {
515 let mut sanitized = text.replace("\r\n", "\n").replace('\r', "");
516 if sanitized.ends_with('\n') {
517 sanitized.pop();
518 }
519 sanitized
520}
521
522fn compile_matcher(pattern: &str, ignore_case: bool, literal: bool) -> Result<Matcher, ToolError> {
523 if literal {
524 Ok(Matcher::Literal {
525 needle: pattern.as_bytes().to_vec(),
526 ignore_case,
527 })
528 } else {
529 let re = RegexBuilder::new(pattern)
530 .case_insensitive(ignore_case)
531 .multi_line(false)
532 .build()
533 .map_err(|error| ToolError::new(format!("Invalid regular expression: {error}")))?;
534 Ok(Matcher::Regex(re))
535 }
536}
537
538fn compile_file_glob(pattern: &str) -> Result<GlobSet, ToolError> {
539 let glob = GlobBuilder::new(pattern)
540 .literal_separator(false)
541 .backslash_escape(true)
542 .build()
543 .map_err(|error| ToolError::new(format!("error parsing glob: {error}")))?;
544 let mut set = GlobSetBuilder::new();
545 set.add(glob);
546 set.build()
547 .map_err(|error| ToolError::new(format!("error parsing glob: {error}")))
548}
549
550fn format_path(file_path: &Path, search_root: &Path, is_directory: bool) -> String {
551 if is_directory && let Ok(relative) = file_path.strip_prefix(search_root) {
552 let posix = to_posix(relative);
553 if !posix.is_empty() && !posix.starts_with("..") {
554 return posix;
555 }
556 }
557 file_path.file_name().map_or_else(
558 || file_path.to_string_lossy().into_owned(),
559 |n| n.to_string_lossy().into_owned(),
560 )
561}
562
563fn is_inside_git_repo(start: &Path) -> bool {
564 let mut current = start.to_path_buf();
565 loop {
566 if current.join(".git").exists() {
567 return true;
568 }
569 if !current.pop() {
570 return false;
571 }
572 }
573}
574
575fn to_posix(path: &Path) -> String {
576 let mut out = String::new();
577 for component in path.components() {
578 match component {
579 Component::Normal(part) => {
580 if !out.is_empty() {
581 out.push('/');
582 }
583 out.push_str(&part.to_string_lossy());
584 }
585 Component::ParentDir => {
586 if !out.is_empty() {
587 out.push('/');
588 }
589 out.push_str("..");
590 }
591 Component::CurDir | Component::RootDir | Component::Prefix(_) => {}
592 }
593 }
594 if out.is_empty() {
595 path.to_string_lossy().replace('\\', "/")
596 } else {
597 out
598 }
599}
600fn effective_limit_at_least_one(limit: Option<f64>, default: usize) -> usize {
601 match limit {
602 Some(value) if value.is_finite() => {
603 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
604 let as_i = value as i64;
605 if as_i < 1 {
606 1
607 } else {
608 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
609 {
610 as_i as usize
611 }
612 }
613 }
614 _ => default.max(1),
615 }
616}
617
618fn text_result(text: impl Into<String>, details: Option<GrepToolDetails>) -> AgentToolResult {
619 AgentToolResult {
620 content: vec![ToolResultContent::Text(TextContent::new(text.into()))],
621 details: details_value(details),
622 added_tool_names: None,
623 terminate: None,
624 }
625}
626
627fn details_value(details: Option<GrepToolDetails>) -> Value {
628 details.map_or(Value::Null, |details| {
629 serde_json::to_value(details).unwrap_or_else(|_| json!({}))
630 })
631}
632
633fn grep_parameters_schema() -> Value {
634 normalize_tool_schema(schemars::schema_for!(GrepToolInput))
635}
636
637fn normalize_tool_schema(schema: schemars::Schema) -> Value {
638 let mut value = serde_json::to_value(schema).unwrap_or_else(|_| Value::Object(Map::new()));
639 if let Value::Object(map) = &mut value {
640 map.remove("$schema");
641 map.remove("title");
642 map.remove("description");
643 map.remove("additionalProperties");
644 if let Some(Value::Array(required)) = map.get("required")
646 && required.is_empty()
647 {
648 map.remove("required");
649 }
650 normalize_schema_node(map);
651 }
652 value
653}
654
655fn normalize_schema_node(map: &mut Map<String, Value>) {
656 map.remove("format");
657 if let Some(Value::Array(types)) = map.get("type").cloned() {
660 let non_null: Vec<Value> = types
661 .into_iter()
662 .filter(|t| t.as_str() != Some("null"))
663 .collect();
664 if non_null.len() == 1 {
665 map.insert("type".to_owned(), non_null[0].clone());
666 } else if !non_null.is_empty() {
667 map.insert("type".to_owned(), Value::Array(non_null));
668 }
669 }
670 let keys: Vec<String> = map.keys().cloned().collect();
671 for key in keys {
672 match map.get_mut(&key) {
673 Some(Value::Object(child)) => normalize_schema_node(child),
674 Some(Value::Array(items)) => {
675 for item in items {
676 if let Value::Object(child) = item {
677 normalize_schema_node(child);
678 }
679 }
680 }
681 _ => {}
682 }
683 }
684}
685
686fn throw_if_cancelled(cancel: &CancellationToken) -> Result<(), ToolError> {
687 if cancel.is_cancelled() {
688 Err(ToolError::new("Operation aborted"))
689 } else {
690 Ok(())
691 }
692}
693
694fn path_error(error: &PathResolveError) -> ToolError {
695 ToolError::new(error.to_string())
696}
697
698#[must_use]
700pub fn create_grep_tool(cwd: impl Into<PathBuf>) -> Arc<dyn AgentTool> {
701 Arc::new(GrepTool::new(cwd))
702}
703
704#[cfg(test)]
705mod tests {
706 use super::*;
707 use std::fs;
708
709 use serde_json::json;
710 use tempfile::tempdir;
711
712 fn fixture_schema() -> Result<Value, serde_json::Error> {
713 let text = include_str!("../../../tests/fixtures/tool-schemas/grep.json");
714 serde_json::from_str(text)
715 }
716
717 fn json_map(value: &Value) -> Result<Map<String, Value>, ToolError> {
718 value
719 .as_object()
720 .cloned()
721 .ok_or_else(|| ToolError::new("test arguments must be a JSON object"))
722 }
723
724 fn text_of(result: &AgentToolResult) -> String {
725 match result.content.first() {
726 Some(ToolResultContent::Text(text)) => text.text.to_string(),
727 _ => String::new(),
728 }
729 }
730
731 async fn run(tool: &GrepTool, args: &Value) -> Result<AgentToolResult, ToolError> {
732 tool.execute(
733 "t",
734 json_map(args)?,
735 CancellationToken::new(),
736 ToolUpdates::noop(),
737 )
738 .await
739 }
740
741 #[test]
742 fn schema_matches_typebox_fixture() -> Result<(), Box<dyn std::error::Error>> {
743 assert_eq!(GrepTool::parameters_schema(), fixture_schema()?);
744 Ok(())
745 }
746
747 #[tokio::test]
748 async fn omitted_ignore_case_is_case_sensitive() -> Result<(), Box<dyn std::error::Error>> {
749 let dir = tempdir()?;
750 fs::write(dir.path().join("case.txt"), "Foo\nfoo\n")?;
751 let tool = GrepTool::new(dir.path());
752
753 let omitted = text_of(
754 &run(
755 &tool,
756 &json!({"pattern": "foo", "path": dir.path().join("case.txt").to_string_lossy()}),
757 )
758 .await?,
759 );
760 assert!(omitted.contains("case.txt:2: foo"));
761 assert!(!omitted.contains("Foo"));
762
763 let insensitive = text_of(
764 &run(
765 &tool,
766 &json!({
767 "pattern": "foo",
768 "path": dir.path().join("case.txt").to_string_lossy(),
769 "ignoreCase": true
770 }),
771 )
772 .await?,
773 );
774 assert!(insensitive.contains("case.txt:1: Foo"));
775 assert!(insensitive.contains("case.txt:2: foo"));
776 Ok(())
777 }
778
779 #[tokio::test]
780 async fn single_file_match_format() -> Result<(), Box<dyn std::error::Error>> {
781 let dir = tempdir()?;
782 let file = dir.path().join("example.txt");
783 fs::write(&file, "first line\nmatch line\nlast line")?;
784 let tool = GrepTool::new(dir.path());
785 let text = text_of(
786 &run(
787 &tool,
788 &json!({"pattern": "match", "path": file.to_string_lossy()}),
789 )
790 .await?,
791 );
792 assert!(text.contains("example.txt:2: match line"));
793 Ok(())
794 }
795
796 #[tokio::test]
797 async fn context_limit_and_notice() -> Result<(), Box<dyn std::error::Error>> {
798 let dir = tempdir()?;
799 let file = dir.path().join("context.txt");
800 fs::write(
801 &file,
802 "before\nmatch one\nafter\nmiddle\nmatch two\nafter two",
803 )?;
804 let tool = GrepTool::new(dir.path());
805 let text = text_of(
806 &run(
807 &tool,
808 &json!({
809 "pattern": "match",
810 "path": file.to_string_lossy(),
811 "limit": 1,
812 "context": 1
813 }),
814 )
815 .await?,
816 );
817 assert!(text.contains("context.txt-1- before"));
818 assert!(text.contains("context.txt:2: match one"));
819 assert!(text.contains("context.txt-3- after"));
820 assert!(
821 text.contains("[1 matches limit reached. Use limit=2 for more, or refine pattern]")
822 );
823 assert!(!text.contains("match two"));
824 Ok(())
825 }
826
827 #[tokio::test]
828 async fn ignore_case_literal_glob_and_unread() -> Result<(), Box<dyn std::error::Error>> {
829 let dir = tempdir()?;
830 fs::write(dir.path().join("a.ts"), "Hello world\nhello again\n")?;
831 fs::write(dir.path().join("b.js"), "Hello nowhere\n")?;
832 fs::write(dir.path().join("bin.dat"), b"Hello\0binary")?;
833 fs::write(dir.path().join("case.txt"), "Foo\nfoo\n")?;
834
835 let tool = GrepTool::new(dir.path());
836
837 let omitted = text_of(
839 &run(
840 &tool,
841 &json!({"pattern": "foo", "path": dir.path().join("case.txt").to_string_lossy()}),
842 )
843 .await?,
844 );
845 assert!(omitted.contains("case.txt:2: foo"));
846 assert!(!omitted.contains("Foo"));
847
848 let insensitive = text_of(
850 &run(
851 &tool,
852 &json!({
853 "pattern": "foo",
854 "path": dir.path().join("case.txt").to_string_lossy(),
855 "ignoreCase": true
856 }),
857 )
858 .await?,
859 );
860 assert!(insensitive.contains("case.txt:1: Foo"));
861 assert!(insensitive.contains("case.txt:2: foo"));
862
863 let sens = text_of(
865 &run(
866 &tool,
867 &json!({
868 "pattern": "hello",
869 "path": dir.path().to_string_lossy(),
870 "glob": "*.ts"
871 }),
872 )
873 .await?,
874 );
875 assert!(sens.contains("hello again"));
876 assert!(!sens.contains("Hello world"));
877 assert!(!sens.contains("b.js"));
878
879 fs::write(dir.path().join("lit.txt"), "a+b\n")?;
881 let lit = text_of(
882 &run(
883 &tool,
884 &json!({
885 "pattern": "a+b",
886 "path": dir.path().join("lit.txt").to_string_lossy(),
887 "literal": true
888 }),
889 )
890 .await?,
891 );
892 assert!(lit.contains("lit.txt:1: a+b"));
893
894 let flag = text_of(
896 &run(
897 &tool,
898 &json!({"pattern": "--pre=/tmp/x", "path": dir.path().to_string_lossy()}),
899 )
900 .await?,
901 );
902 assert_eq!(flag, "No matches found");
903 Ok(())
904 }
905
906 #[tokio::test]
907 async fn gitignore_hidden_and_line_truncation() -> Result<(), Box<dyn std::error::Error>> {
908 let dir = tempdir()?;
909 fs::write(dir.path().join(".gitignore"), "skip.txt\n")?;
910 fs::write(dir.path().join("skip.txt"), "needle\n")?;
911 fs::create_dir(dir.path().join(".hidden"))?;
912 fs::write(dir.path().join(".hidden/h.txt"), "needle\n")?;
913 fs::write(
914 dir.path().join("keep.txt"),
915 format!("{}\n", "x".repeat(600)),
916 )?;
917 fs::write(
918 dir.path().join("keep.txt"),
919 format!("needle {}\n", "x".repeat(600)),
920 )?;
921
922 let tool = GrepTool::new(dir.path());
923 let text = text_of(
924 &run(
925 &tool,
926 &json!({"pattern": "needle", "path": dir.path().to_string_lossy()}),
927 )
928 .await?,
929 );
930 assert!(!text.contains("skip.txt"));
931 assert!(text.contains(".hidden/h.txt:1: needle"));
932 assert!(text.contains("... [truncated]"));
933 assert!(text.contains(&format!(
934 "Some lines truncated to {GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines"
935 )));
936 Ok(())
937 }
938
939 #[tokio::test]
940 async fn missing_path_and_cancel() -> Result<(), Box<dyn std::error::Error>> {
941 let dir = tempdir()?;
942 let tool = GrepTool::new(dir.path());
943 let result = run(&tool, &json!({"pattern": "x", "path": "nope"})).await;
944 let Err(err) = result else {
945 return Err("missing path unexpectedly succeeded".into());
946 };
947 assert!(err.message().starts_with("Path not found:"));
948
949 let cancel = CancellationToken::new();
950 cancel.cancel();
951 let result = tool
952 .execute(
953 "t",
954 json_map(&json!({"pattern": "x"}))?,
955 cancel,
956 ToolUpdates::noop(),
957 )
958 .await;
959 let Err(err) = result else {
960 return Err("cancelled grep unexpectedly succeeded".into());
961 };
962 assert_eq!(err.message(), "Operation aborted");
963 Ok(())
964 }
965}