Skip to main content

pi/core/tools/
grep.rs

1//! Search file contents with native regex / literal matching.
2//!
3//! Ports `.references/pi/packages/coding-agent/src/core/tools/grep.ts` without
4//! spawning `rg`. Directory searches use `ignore::WalkBuilder` (hidden +
5//! hierarchical gitignore). Output lines are 1-indexed, match/context/unread
6//! formats match TypeScript, lines are truncated to 500 chars, and match count
7//! plus 50 KiB head truncation produce the exact notices.
8
9use 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
32/// Default maximum number of matches returned.
33const DEFAULT_LIMIT: usize = 100;
34
35/// TypeBox-compatible grep arguments (fixture `grep.json`).
36#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
37#[serde(rename_all = "camelCase")]
38pub struct GrepToolInput {
39    /// Search pattern (regex or literal string).
40    #[schemars(description = "Search pattern (regex or literal string)")]
41    pub pattern: String,
42    /// Directory or file to search (default: current directory).
43    #[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    /// Filter files by glob pattern, e.g. `*.ts` or `**/*.spec.ts`.
47    #[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    /// Case-insensitive search (default: false). Omitted and `false` are
51    /// strictly case-sensitive; only `true` enables case-insensitive search.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    #[schemars(description = "Case-insensitive search (default: false)")]
54    pub ignore_case: Option<bool>,
55    /// Treat pattern as literal string instead of regex (default: false).
56    #[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    /// Number of lines to show before and after each match (default: 0).
60    #[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    /// Maximum number of matches to return (default: 100).
64    #[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/// Optional structured details returned by the grep tool.
70#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
71#[serde(rename_all = "camelCase")]
72pub struct GrepToolDetails {
73    /// Truncation metadata when the 50 KiB head limit applied.
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub truncation: Option<TruncationResult>,
76    /// Effective match limit when that limit was hit.
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub match_limit_reached: Option<usize>,
79    /// Whether any line was truncated to [`GREP_MAX_LINE_LENGTH`] chars.
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub lines_truncated: Option<bool>,
82}
83
84/// Options for [`GrepTool`].
85#[derive(Clone, Debug)]
86pub struct GrepToolOptions {
87    /// Working directory used to resolve relative paths.
88    pub cwd: PathBuf,
89}
90
91impl GrepToolOptions {
92    /// Builds options for `cwd`.
93    #[must_use]
94    pub fn new(cwd: impl Into<PathBuf>) -> Self {
95        Self { cwd: cwd.into() }
96    }
97}
98
99/// Agent tool that searches file contents.
100#[derive(Clone, Debug)]
101pub struct GrepTool {
102    cwd: PathBuf,
103    parameters: Value,
104    description: String,
105}
106
107impl GrepTool {
108    /// Creates a grep tool rooted at `cwd`.
109    #[must_use]
110    pub fn new(cwd: impl Into<PathBuf>) -> Self {
111        Self::with_options(GrepToolOptions::new(cwd))
112    }
113
114    /// Creates a grep tool from explicit options.
115    #[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    /// Returns the JSON Schema for grep arguments (normalized `TypeBox` shape).
129    #[must_use]
130    pub fn parameters_schema() -> Value {
131        grep_parameters_schema()
132    }
133
134    /// Validates raw tool arguments into [`GrepToolInput`].
135    ///
136    /// # Errors
137    ///
138    /// Returns [`ToolError`] when required fields are missing or mistyped.
139    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(&notices.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    // Skip obvious binary files (NUL byte in first 8 KiB).
484    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        // TypeBox omits `required` when every property is optional.
645        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    // schemars represents Option<T> as ["number","null"]; TypeBox optional
658    // numbers are just "number".
659    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/// Builds an [`Arc<dyn AgentTool>`] grep tool for `cwd`.
699#[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        // Omitted ignoreCase is case-sensitive: "foo" does not match "Foo".
838        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        // Explicit true is case-insensitive.
849        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        // Default case-sensitive with glob filter.
864        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        // Literal treats regex metacharacters as text.
880        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        // Flag-like patterns are not executed.
895        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}