Skip to main content

ai_agents_tools/builtin/
fs_readonly.rs

1use async_trait::async_trait;
2use regex::{Regex, RegexBuilder};
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use std::collections::{BTreeSet, HashMap};
7use std::fs;
8use std::io::{BufRead, BufReader};
9use std::path::{Component, Path, PathBuf};
10use std::time::Instant;
11
12use ai_agents_core::{
13    PathPolicyBinding, ResultLimitBinding, ResultLimitKind, Tool, ToolExecutionContext,
14    ToolOperationKind, ToolPolicyBindings, ToolResult, ToolSafetyMetadata, ToolSideEffectLevel,
15};
16
17use crate::generate_schema;
18use crate::types::{FileVersionEvidence, FileVersionStore, file_version_evidence};
19
20const DEFAULT_MAX_RESULTS: usize = 200;
21const DEFAULT_MAX_FILE_BYTES: u64 = 1_048_576;
22const DEFAULT_MAX_OUTPUT_CHARS: usize = 20_000;
23const DEFAULT_FILE_READ_MAX_LINES: usize = 2_000;
24
25const DEFAULT_IGNORED_DIRS: &[&str] = &[
26    ".git",
27    "target",
28    "node_modules",
29    "dist",
30    "build",
31    ".next",
32    ".turbo",
33];
34
35/// Finds workspace paths that match a glob pattern.
36pub struct GlobTool;
37
38impl GlobTool {
39    /// Create a glob discovery tool.
40    pub fn new() -> Self {
41        Self
42    }
43}
44
45impl Default for GlobTool {
46    fn default() -> Self {
47        Self::new()
48    }
49}
50
51/// Searches workspace file content with regex or literal matching.
52pub struct GrepTool;
53
54impl GrepTool {
55    /// Create a bounded text search tool.
56    pub fn new() -> Self {
57        Self
58    }
59}
60
61impl Default for GrepTool {
62    fn default() -> Self {
63        Self::new()
64    }
65}
66
67/// Reads bounded text slices from local files.
68pub struct FileReadTool {
69    versions: FileVersionStore,
70}
71
72impl FileReadTool {
73    /// Create a safe text file reader.
74    pub fn new() -> Self {
75        Self::with_version_store(FileVersionStore::default())
76    }
77
78    /// Create a safe text file reader backed by shared version storage.
79    pub fn with_version_store(versions: FileVersionStore) -> Self {
80        Self { versions }
81    }
82}
83
84impl Default for FileReadTool {
85    fn default() -> Self {
86        Self::new()
87    }
88}
89
90/// Lists directory entries with pagination and hidden-file control.
91pub struct FileListTool;
92
93impl FileListTool {
94    /// Create a deterministic directory lister.
95    pub fn new() -> Self {
96        Self
97    }
98}
99
100impl Default for FileListTool {
101    fn default() -> Self {
102        Self::new()
103    }
104}
105
106/// Inspects safe file metadata without reading contents.
107pub struct FileInfoTool;
108
109impl FileInfoTool {
110    /// Create a safe file metadata inspector.
111    pub fn new() -> Self {
112        Self
113    }
114}
115
116impl Default for FileInfoTool {
117    fn default() -> Self {
118        Self::new()
119    }
120}
121
122#[derive(Debug, Deserialize, JsonSchema)]
123struct GlobInput {
124    /// Glob pattern such as **/*.rs.
125    pattern: String,
126    /// Root directory to search. Defaults to current directory.
127    #[serde(default)]
128    path: Option<String>,
129    /// Maximum returned paths. Defaults to 100.
130    #[serde(
131        default,
132        deserialize_with = "crate::deserialize_optional_positive_usize"
133    )]
134    #[schemars(range(min = 1))]
135    max_results: Option<usize>,
136    /// Result offset for pagination. Defaults to 0.
137    #[serde(default)]
138    offset: Option<usize>,
139    /// Include directory matches. Defaults to false.
140    #[serde(default)]
141    include_dirs: bool,
142    /// Sort order: path, modified, or size. Defaults to path.
143    #[serde(default)]
144    sort: Option<String>,
145}
146
147#[derive(Debug, Serialize)]
148struct GlobOutput {
149    paths: Vec<String>,
150    count: usize,
151    total_count: usize,
152    offset: usize,
153    truncated: bool,
154    duration_ms: u64,
155}
156
157#[derive(Debug, Default, Deserialize, JsonSchema)]
158#[serde(rename_all = "snake_case")]
159enum GrepMode {
160    #[default]
161    Regex,
162    Literal,
163}
164
165#[derive(Debug, Default, Deserialize, JsonSchema)]
166#[serde(rename_all = "snake_case")]
167enum GrepOutputMode {
168    Content,
169    #[default]
170    FilesWithMatches,
171    Count,
172}
173
174#[derive(Debug, Deserialize, JsonSchema)]
175struct GrepInput {
176    /// Literal or regex pattern to search for.
177    pattern: String,
178    /// Search mode. Defaults to regex.
179    #[serde(default)]
180    mode: GrepMode,
181    /// File or directory root. Defaults to current directory.
182    #[serde(default)]
183    path: Option<String>,
184    /// Optional include glob for file paths.
185    #[serde(default)]
186    include_glob: Option<String>,
187    /// Whether matching is case-sensitive. Defaults to false.
188    #[serde(default)]
189    case_sensitive: bool,
190    /// Output mode. Defaults to files_with_matches.
191    #[serde(default)]
192    output_mode: GrepOutputMode,
193    /// Context lines for content output. Defaults to 0.
194    #[serde(default)]
195    context: Option<usize>,
196    /// Maximum result rows. Defaults to 250.
197    #[serde(
198        default,
199        deserialize_with = "crate::deserialize_optional_positive_usize"
200    )]
201    #[schemars(range(min = 1))]
202    max_results: Option<usize>,
203    /// Result offset for pagination. Defaults to 0.
204    #[serde(default)]
205    offset: Option<usize>,
206    /// Maximum file bytes to inspect. Defaults to 1 MiB.
207    #[serde(default)]
208    max_file_size_bytes: Option<u64>,
209    /// Soft cap for collected text. Defaults to 20000 characters.
210    #[serde(default)]
211    max_output_chars: Option<usize>,
212}
213
214#[derive(Debug, Serialize)]
215struct GrepOutput {
216    mode: String,
217    matches: Vec<GrepMatch>,
218    files: Vec<String>,
219    count: usize,
220    total_count: usize,
221    offset: usize,
222    truncated: bool,
223    skipped_binary: usize,
224    skipped_large: usize,
225}
226
227#[derive(Debug, Serialize, Clone)]
228struct GrepMatch {
229    path: String,
230    #[serde(skip_serializing_if = "Option::is_none")]
231    line: Option<usize>,
232    #[serde(skip_serializing_if = "Option::is_none")]
233    text: Option<String>,
234    #[serde(skip_serializing_if = "Option::is_none")]
235    count: Option<usize>,
236}
237
238#[derive(Debug, Deserialize, JsonSchema)]
239struct FileReadInput {
240    /// File path to read.
241    path: String,
242    /// One-based first line. Defaults to 1.
243    #[serde(default)]
244    start_line: Option<usize>,
245    /// One-based last line.
246    #[serde(default)]
247    end_line: Option<usize>,
248    /// Maximum lines to return. Defaults to 2000.
249    #[serde(default)]
250    max_lines: Option<usize>,
251    /// Maximum bytes to return. Defaults to 1 MiB.
252    #[serde(default)]
253    max_bytes: Option<u64>,
254}
255
256#[derive(Debug, Serialize)]
257struct FileReadOutput {
258    path: String,
259    content: String,
260    start_line: usize,
261    end_line: usize,
262    total_lines: usize,
263    bytes_read: usize,
264    file_size: u64,
265    truncated: bool,
266    large_file: bool,
267    encoding: String,
268    #[serde(skip_serializing_if = "Option::is_none")]
269    version: Option<FileVersionEvidence>,
270}
271
272#[derive(Debug, Deserialize, JsonSchema)]
273struct FileListInput {
274    /// Directory path to list.
275    path: String,
276    /// Recurse into child directories. Defaults to false.
277    #[serde(default)]
278    recursive: bool,
279    /// Optional include glob for relative paths.
280    #[serde(default)]
281    include_glob: Option<String>,
282    /// Optional exclude glob for relative paths.
283    #[serde(default)]
284    exclude_glob: Option<String>,
285    /// Include hidden files. Defaults to false.
286    #[serde(default)]
287    include_hidden: bool,
288    /// Maximum returned entries. Defaults to 200.
289    #[serde(
290        default,
291        deserialize_with = "crate::deserialize_optional_positive_usize"
292    )]
293    #[schemars(range(min = 1))]
294    max_results: Option<usize>,
295    /// Result offset for pagination. Defaults to 0.
296    #[serde(default)]
297    offset: Option<usize>,
298    /// Sort order: path, modified, size, or kind. Defaults to path.
299    #[serde(default)]
300    sort: Option<String>,
301}
302
303#[derive(Debug, Serialize)]
304struct FileListOutput {
305    path: String,
306    entries: Vec<FileListEntry>,
307    count: usize,
308    total_count: usize,
309    offset: usize,
310    truncated: bool,
311    policy_notes: Vec<String>,
312}
313
314#[derive(Debug, Serialize, Clone)]
315struct FileListEntry {
316    path: String,
317    kind: String,
318    #[serde(skip_serializing_if = "Option::is_none")]
319    size: Option<u64>,
320    #[serde(skip_serializing_if = "Option::is_none")]
321    modified: Option<String>,
322    symlink: bool,
323    #[serde(skip_serializing_if = "Option::is_none")]
324    policy: Option<String>,
325}
326
327#[derive(Debug, Deserialize, JsonSchema)]
328struct FileInfoInput {
329    /// File or directory path to inspect.
330    path: String,
331    /// Follow symlink target when safe. Defaults to false.
332    #[serde(default)]
333    follow_symlinks: bool,
334}
335
336#[derive(Debug, Serialize)]
337struct FileInfoOutput {
338    path: String,
339    exists: bool,
340    kind: String,
341    #[serde(skip_serializing_if = "Option::is_none")]
342    size: Option<u64>,
343    #[serde(skip_serializing_if = "Option::is_none")]
344    modified: Option<String>,
345    #[serde(skip_serializing_if = "Option::is_none")]
346    created: Option<String>,
347    readonly: bool,
348    symlink: bool,
349    #[serde(skip_serializing_if = "Option::is_none")]
350    canonical_path: Option<String>,
351    #[serde(skip_serializing_if = "Option::is_none")]
352    mime_hint: Option<String>,
353    policy_classification: String,
354}
355
356#[async_trait]
357impl Tool for GlobTool {
358    fn id(&self) -> &str {
359        "glob"
360    }
361
362    fn name(&self) -> &str {
363        "Glob"
364    }
365
366    fn description(&self) -> &str {
367        "Find file paths by glob pattern with deterministic sorting and pagination."
368    }
369
370    fn input_schema(&self) -> Value {
371        generate_schema::<GlobInput>()
372    }
373
374    fn safety_metadata(&self) -> ToolSafetyMetadata {
375        read_tool_metadata(ToolOperationKind::Read)
376    }
377
378    fn policy_bindings(&self) -> ToolPolicyBindings {
379        ToolPolicyBindings {
380            path_fields: vec![PathPolicyBinding::read("path").with_default_path(".")],
381            result_limit_fields: vec![ResultLimitBinding::new(
382                "max_results",
383                ResultLimitKind::MaxResults,
384            )],
385            ..Default::default()
386        }
387    }
388
389    async fn execute(&self, args: Value, ctx: ToolExecutionContext) -> ToolResult {
390        let started = Instant::now();
391        let input: GlobInput = match serde_json::from_value(args) {
392            Ok(input) => input,
393            Err(error) => return ToolResult::error(format!("Invalid input: {}", error)),
394        };
395        if let Err(error) = crate::validate_positive_max_results(ctx.limits.max_results) {
396            return ToolResult::error(format!("Invalid result limit: {error}"));
397        }
398        let root = PathBuf::from(input.path.unwrap_or_else(|| ".".to_string()));
399        if let Err(reason) = ensure_safe_path(&root) {
400            return ToolResult::error(reason);
401        }
402        let matcher = match GlobMatcher::new(&input.pattern) {
403            Ok(matcher) => matcher,
404            Err(error) => return ToolResult::error(format!("Invalid glob pattern: {}", error)),
405        };
406
407        let mut entries = Vec::new();
408        let mut stack = vec![root.clone()];
409        let mut visited = 0usize;
410        while let Some(dir) = stack.pop() {
411            let read_dir = match fs::read_dir(&dir) {
412                Ok(read_dir) => read_dir,
413                Err(_) => continue,
414            };
415            for entry in read_dir.flatten() {
416                visited += 1;
417                if visited.is_multiple_of(128) {
418                    tokio::task::yield_now().await;
419                }
420                let path = entry.path();
421                let file_name = entry.file_name().to_string_lossy().to_string();
422                let metadata = match fs::symlink_metadata(&path) {
423                    Ok(metadata) => metadata,
424                    Err(_) => continue,
425                };
426                let is_dir = metadata.is_dir();
427                if is_dir && is_default_ignored_dir(&file_name) {
428                    continue;
429                }
430                if is_dir {
431                    stack.push(path.clone());
432                }
433                if is_dir && !input.include_dirs {
434                    continue;
435                }
436                let relative = relative_path(&root, &path);
437                if matcher.matches(&relative) {
438                    entries.push(SortablePath {
439                        path: normalize_slashes(relative),
440                        modified: metadata.modified().ok(),
441                        size: metadata.len(),
442                        kind: if is_dir { "dir" } else { "file" }.to_string(),
443                    });
444                }
445            }
446        }
447
448        sort_paths(&mut entries, input.sort.as_deref().unwrap_or("path"));
449        let total_count = entries.len();
450        let offset = input.offset.unwrap_or(0);
451        let max_results = input
452            .max_results
453            .unwrap_or(100)
454            .min(DEFAULT_MAX_RESULTS)
455            .min(ctx.limits.max_results.unwrap_or(DEFAULT_MAX_RESULTS));
456        let paths: Vec<String> = entries
457            .into_iter()
458            .skip(offset)
459            .take(max_results)
460            .map(|entry| entry.path)
461            .collect();
462        let output = GlobOutput {
463            count: paths.len(),
464            total_count,
465            offset,
466            truncated: offset.saturating_add(paths.len()) < total_count,
467            duration_ms: started.elapsed().as_millis() as u64,
468            paths,
469        };
470        json_result_with_caps(&output, false, None)
471    }
472}
473
474#[async_trait]
475impl Tool for GrepTool {
476    fn id(&self) -> &str {
477        "grep"
478    }
479
480    fn name(&self) -> &str {
481        "Grep"
482    }
483
484    fn description(&self) -> &str {
485        "Search text files using regex or literal matching with bounded, paginated output."
486    }
487
488    fn input_schema(&self) -> Value {
489        generate_schema::<GrepInput>()
490    }
491
492    fn safety_metadata(&self) -> ToolSafetyMetadata {
493        read_tool_metadata(ToolOperationKind::Read)
494    }
495
496    fn policy_bindings(&self) -> ToolPolicyBindings {
497        ToolPolicyBindings {
498            path_fields: vec![PathPolicyBinding::read("path").with_default_path(".")],
499            result_limit_fields: vec![
500                ResultLimitBinding::new("max_results", ResultLimitKind::MaxResults),
501                ResultLimitBinding::new("max_file_size_bytes", ResultLimitKind::MaxFileSizeBytes),
502                ResultLimitBinding::new("max_output_chars", ResultLimitKind::MaxOutputChars),
503            ],
504            ..Default::default()
505        }
506    }
507
508    async fn execute(&self, args: Value, ctx: ToolExecutionContext) -> ToolResult {
509        let input: GrepInput = match serde_json::from_value(args) {
510            Ok(input) => input,
511            Err(error) => return ToolResult::error(format!("Invalid input: {}", error)),
512        };
513        if let Err(error) = crate::validate_positive_max_results(ctx.limits.max_results) {
514            return ToolResult::error(format!("Invalid result limit: {error}"));
515        }
516        let root = PathBuf::from(input.path.clone().unwrap_or_else(|| ".".to_string()));
517        if let Err(reason) = ensure_safe_path(&root) {
518            return ToolResult::error(reason);
519        }
520        let include = match optional_matcher(input.include_glob.as_deref()) {
521            Ok(matcher) => matcher,
522            Err(error) => return ToolResult::error(format!("Invalid include_glob: {}", error)),
523        };
524        let regex = match build_search_regex(&input.pattern, &input.mode, input.case_sensitive) {
525            Ok(regex) => regex,
526            Err(error) => return ToolResult::error(format!("Invalid search pattern: {}", error)),
527        };
528        let max_results = input
529            .max_results
530            .unwrap_or(250)
531            .min(DEFAULT_MAX_RESULTS * 2)
532            .min(ctx.limits.max_results.unwrap_or(DEFAULT_MAX_RESULTS * 2));
533        let offset = input.offset.unwrap_or(0);
534        let max_file_size = input
535            .max_file_size_bytes
536            .unwrap_or(DEFAULT_MAX_FILE_BYTES)
537            .min(
538                ctx.limits
539                    .max_file_size_bytes
540                    .unwrap_or(DEFAULT_MAX_FILE_BYTES),
541            );
542        let max_output_chars = input
543            .max_output_chars
544            .unwrap_or(DEFAULT_MAX_OUTPUT_CHARS)
545            .min(
546                ctx.limits
547                    .max_output_chars
548                    .unwrap_or(DEFAULT_MAX_OUTPUT_CHARS),
549            );
550        let context = input.context.unwrap_or(0).min(20);
551
552        let files = collect_files(&root, include.as_ref()).await;
553        let mut matches = Vec::new();
554        let mut files_with_matches = BTreeSet::new();
555        let mut skipped_binary = 0usize;
556        let mut skipped_large = 0usize;
557        let mut collected_chars = 0usize;
558        let mut truncated = false;
559
560        for file in files {
561            let metadata = match fs::metadata(&file) {
562                Ok(metadata) => metadata,
563                Err(_) => continue,
564            };
565            if metadata.len() > max_file_size {
566                skipped_large += 1;
567                continue;
568            }
569            let bytes = match fs::read(&file) {
570                Ok(bytes) => bytes,
571                Err(_) => continue,
572            };
573            if looks_binary(&bytes) {
574                skipped_binary += 1;
575                continue;
576            }
577            let text = match String::from_utf8(bytes) {
578                Ok(text) => text,
579                Err(_) => {
580                    skipped_binary += 1;
581                    continue;
582                }
583            };
584            let relative = normalize_slashes(relative_path(&root, &file));
585            let line_hits = line_matches(&text, &regex, context);
586            if line_hits.is_empty() {
587                continue;
588            }
589            files_with_matches.insert(relative.clone());
590            match input.output_mode {
591                GrepOutputMode::FilesWithMatches => {
592                    matches.push(GrepMatch {
593                        path: relative,
594                        line: None,
595                        text: None,
596                        count: None,
597                    });
598                }
599                GrepOutputMode::Count => {
600                    matches.push(GrepMatch {
601                        path: relative,
602                        line: None,
603                        text: None,
604                        count: Some(line_hits.iter().filter(|hit| hit.is_match).count()),
605                    });
606                }
607                GrepOutputMode::Content => {
608                    for hit in line_hits {
609                        let mut text = hit.text;
610                        let (bounded, was_truncated) = truncate_chars(text, 1_000);
611                        text = bounded;
612                        truncated |= was_truncated;
613                        collected_chars = collected_chars.saturating_add(text.chars().count());
614                        if collected_chars > max_output_chars {
615                            truncated = true;
616                            break;
617                        }
618                        matches.push(GrepMatch {
619                            path: relative.clone(),
620                            line: Some(hit.line),
621                            text: Some(text),
622                            count: None,
623                        });
624                    }
625                }
626            }
627            if matches.len() >= offset.saturating_add(max_results) || truncated {
628                if matches.len() >= offset.saturating_add(max_results) {
629                    truncated = true;
630                }
631                break;
632            }
633        }
634
635        let total_count = matches.len();
636        let matches: Vec<GrepMatch> = matches.into_iter().skip(offset).take(max_results).collect();
637        let files: Vec<String> = files_with_matches.into_iter().collect();
638        let output = GrepOutput {
639            mode: output_mode_name(&input.output_mode).to_string(),
640            count: matches.len(),
641            total_count,
642            offset,
643            truncated: truncated || offset.saturating_add(matches.len()) < total_count,
644            skipped_binary,
645            skipped_large,
646            matches,
647            files,
648        };
649        json_result_with_caps(&output, output.truncated, Some(max_output_chars))
650    }
651}
652
653#[async_trait]
654impl Tool for FileReadTool {
655    fn id(&self) -> &str {
656        "file_read"
657    }
658
659    fn name(&self) -> &str {
660        "File Read"
661    }
662
663    fn description(&self) -> &str {
664        "Read a bounded UTF-8 text file range with line numbers and large-file protection."
665    }
666
667    fn input_schema(&self) -> Value {
668        generate_schema::<FileReadInput>()
669    }
670
671    fn safety_metadata(&self) -> ToolSafetyMetadata {
672        read_tool_metadata(ToolOperationKind::Read)
673    }
674
675    fn policy_bindings(&self) -> ToolPolicyBindings {
676        ToolPolicyBindings {
677            path_fields: vec![PathPolicyBinding::read("path")],
678            result_limit_fields: vec![
679                ResultLimitBinding::new("max_bytes", ResultLimitKind::MaxFileSizeBytes),
680                ResultLimitBinding::new("max_lines", ResultLimitKind::MaxLines),
681            ],
682            ..Default::default()
683        }
684    }
685
686    async fn execute(&self, args: Value, ctx: ToolExecutionContext) -> ToolResult {
687        let input: FileReadInput = match serde_json::from_value(args) {
688            Ok(input) => input,
689            Err(error) => return ToolResult::error(format!("Invalid input: {}", error)),
690        };
691        let path = PathBuf::from(&input.path);
692        if let Err(reason) = ensure_safe_path(&path) {
693            return ToolResult::error(reason);
694        }
695        let metadata = match fs::metadata(&path) {
696            Ok(metadata) => metadata,
697            Err(error) => return ToolResult::error(format!("Metadata error: {}", error)),
698        };
699        if !metadata.is_file() {
700            return ToolResult::error(format!("Not a file: {}", input.path));
701        }
702        let max_bytes = input.max_bytes.unwrap_or(DEFAULT_MAX_FILE_BYTES).min(
703            ctx.limits
704                .max_file_size_bytes
705                .unwrap_or(DEFAULT_MAX_FILE_BYTES),
706        );
707        let max_lines = input.max_lines.unwrap_or(DEFAULT_FILE_READ_MAX_LINES).min(
708            ctx.limits
709                .max_results
710                .unwrap_or(DEFAULT_FILE_READ_MAX_LINES),
711        );
712        let has_range = input.start_line.is_some() || input.end_line.is_some();
713        let large_file = metadata.len() > max_bytes;
714        let start_line = input.start_line.unwrap_or(1).max(1);
715        let requested_end = input.end_line.unwrap_or(usize::MAX);
716        if requested_end < start_line {
717            return ToolResult::error("end_line must be greater than or equal to start_line");
718        }
719        let effective_end =
720            requested_end.min(start_line.saturating_add(max_lines).saturating_sub(1));
721        if large_file && !has_range {
722            let output = FileReadOutput {
723                path: input.path,
724                content: String::new(),
725                start_line: 0,
726                end_line: 0,
727                total_lines: 0,
728                bytes_read: 0,
729                file_size: metadata.len(),
730                truncated: true,
731                large_file: true,
732                encoding: "utf-8".to_string(),
733                version: None,
734            };
735            return json_result_with_caps(&output, true, None);
736        }
737        let sample = match read_prefix(&path, 8_192) {
738            Ok(sample) => sample,
739            Err(error) => return ToolResult::error(format!("Read error: {}", error)),
740        };
741        if looks_binary(&sample) || std::str::from_utf8(&sample).is_err() {
742            return ToolResult::error("Binary or non-UTF-8 files are not supported by file_read");
743        }
744
745        match read_text_range(&path, start_line, effective_end, max_lines, max_bytes).await {
746            Ok(mut output) => {
747                if let Ok(bytes) = fs::read(&path)
748                    && let Ok(version) = file_version_evidence(&path, &bytes)
749                {
750                    self.versions.record(version.clone());
751                    output.version = Some(version);
752                }
753                json_result_with_caps(&output, output.truncated, None)
754            }
755            Err(error) => ToolResult::error(error),
756        }
757    }
758}
759
760#[async_trait]
761impl Tool for FileListTool {
762    fn id(&self) -> &str {
763        "file_list"
764    }
765
766    fn name(&self) -> &str {
767        "File List"
768    }
769
770    fn description(&self) -> &str {
771        "List directory entries with recursive, glob, hidden-file, symlink, and pagination controls."
772    }
773
774    fn input_schema(&self) -> Value {
775        generate_schema::<FileListInput>()
776    }
777
778    fn safety_metadata(&self) -> ToolSafetyMetadata {
779        read_tool_metadata(ToolOperationKind::Read)
780    }
781
782    fn policy_bindings(&self) -> ToolPolicyBindings {
783        ToolPolicyBindings {
784            path_fields: vec![PathPolicyBinding::read("path")],
785            result_limit_fields: vec![ResultLimitBinding::new(
786                "max_results",
787                ResultLimitKind::MaxResults,
788            )],
789            ..Default::default()
790        }
791    }
792
793    async fn execute(&self, args: Value, ctx: ToolExecutionContext) -> ToolResult {
794        let input: FileListInput = match serde_json::from_value(args) {
795            Ok(input) => input,
796            Err(error) => return ToolResult::error(format!("Invalid input: {}", error)),
797        };
798        if let Err(error) = crate::validate_positive_max_results(ctx.limits.max_results) {
799            return ToolResult::error(format!("Invalid result limit: {error}"));
800        }
801        let root = PathBuf::from(&input.path);
802        if let Err(reason) = ensure_safe_path(&root) {
803            return ToolResult::error(reason);
804        }
805        if !root.is_dir() {
806            return ToolResult::error(format!("Not a directory: {}", input.path));
807        }
808        let include = match optional_matcher(input.include_glob.as_deref()) {
809            Ok(matcher) => matcher,
810            Err(error) => return ToolResult::error(format!("Invalid include_glob: {}", error)),
811        };
812        let exclude = match optional_matcher(input.exclude_glob.as_deref()) {
813            Ok(matcher) => matcher,
814            Err(error) => return ToolResult::error(format!("Invalid exclude_glob: {}", error)),
815        };
816        let canonical_root = fs::canonicalize(&root).unwrap_or_else(|_| root.clone());
817        let mut stack = vec![root.clone()];
818        let mut entries = Vec::new();
819        let mut notes = Vec::new();
820        let mut visited = 0usize;
821
822        while let Some(dir) = stack.pop() {
823            let read_dir = match fs::read_dir(&dir) {
824                Ok(read_dir) => read_dir,
825                Err(_) => continue,
826            };
827            for entry in read_dir.flatten() {
828                visited += 1;
829                if visited.is_multiple_of(128) {
830                    tokio::task::yield_now().await;
831                }
832                let path = entry.path();
833                let file_name = entry.file_name().to_string_lossy().to_string();
834                if !input.include_hidden && file_name.starts_with('.') {
835                    if file_name == ".git" {
836                        notes.push("blocked .git directory".to_string());
837                    }
838                    continue;
839                }
840                if is_default_ignored_dir(&file_name) {
841                    notes.push(format!("ignored default directory: {}", file_name));
842                    continue;
843                }
844                let metadata = match fs::symlink_metadata(&path) {
845                    Ok(metadata) => metadata,
846                    Err(_) => continue,
847                };
848                let relative = normalize_slashes(relative_path(&root, &path));
849                if include
850                    .as_ref()
851                    .is_some_and(|matcher| !matcher.matches(&relative))
852                {
853                    continue;
854                }
855                if exclude
856                    .as_ref()
857                    .is_some_and(|matcher| matcher.matches(&relative))
858                {
859                    continue;
860                }
861                let symlink = metadata.file_type().is_symlink();
862                let mut policy = None;
863                let mut kind = kind_from_metadata(&metadata).to_string();
864                let mut follow_for_recurse = metadata.is_dir();
865                if symlink {
866                    match fs::canonicalize(&path) {
867                        Ok(target) if !target.starts_with(&canonical_root) => {
868                            policy = Some("symlink_escape".to_string());
869                            follow_for_recurse = false;
870                            notes.push(format!("blocked symlink escape: {}", relative));
871                        }
872                        Ok(target) if target.is_dir() => {
873                            kind = "symlink_dir".to_string();
874                            follow_for_recurse = true;
875                        }
876                        Ok(_) => {
877                            kind = "symlink_file".to_string();
878                        }
879                        Err(_) => {
880                            policy = Some("broken_symlink".to_string());
881                        }
882                    }
883                }
884                entries.push(FileListEntry {
885                    path: relative,
886                    kind,
887                    size: metadata.is_file().then_some(metadata.len()),
888                    modified: metadata.modified().ok().map(system_time_rfc3339),
889                    symlink,
890                    policy,
891                });
892                if input.recursive && follow_for_recurse {
893                    stack.push(path);
894                }
895            }
896        }
897
898        sort_list_entries(&mut entries, input.sort.as_deref().unwrap_or("path"));
899        let total_count = entries.len();
900        let offset = input.offset.unwrap_or(0);
901        let max_results = input
902            .max_results
903            .unwrap_or(DEFAULT_MAX_RESULTS)
904            .min(DEFAULT_MAX_RESULTS * 5)
905            .min(ctx.limits.max_results.unwrap_or(DEFAULT_MAX_RESULTS * 5));
906        let entries: Vec<FileListEntry> =
907            entries.into_iter().skip(offset).take(max_results).collect();
908        notes.sort();
909        notes.dedup();
910        let output = FileListOutput {
911            path: input.path,
912            count: entries.len(),
913            total_count,
914            offset,
915            truncated: offset.saturating_add(entries.len()) < total_count,
916            entries,
917            policy_notes: notes,
918        };
919        json_result_with_caps(&output, output.truncated, None)
920    }
921}
922
923#[async_trait]
924impl Tool for FileInfoTool {
925    fn id(&self) -> &str {
926        "file_info"
927    }
928
929    fn name(&self) -> &str {
930        "File Info"
931    }
932
933    fn description(&self) -> &str {
934        "Inspect safe file or directory metadata without reading file contents."
935    }
936
937    fn input_schema(&self) -> Value {
938        generate_schema::<FileInfoInput>()
939    }
940
941    fn safety_metadata(&self) -> ToolSafetyMetadata {
942        read_tool_metadata(ToolOperationKind::Read)
943    }
944
945    fn policy_bindings(&self) -> ToolPolicyBindings {
946        ToolPolicyBindings {
947            path_fields: vec![PathPolicyBinding::read("path")],
948            ..Default::default()
949        }
950    }
951
952    async fn execute(&self, args: Value, _ctx: ToolExecutionContext) -> ToolResult {
953        let input: FileInfoInput = match serde_json::from_value(args) {
954            Ok(input) => input,
955            Err(error) => return ToolResult::error(format!("Invalid input: {}", error)),
956        };
957        let path = PathBuf::from(&input.path);
958        if let Err(reason) = ensure_safe_path_allow_missing(&path) {
959            return ToolResult::error(reason);
960        }
961        let symlink_metadata = match fs::symlink_metadata(&path) {
962            Ok(metadata) => metadata,
963            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
964                let output = FileInfoOutput {
965                    path: input.path,
966                    exists: false,
967                    kind: "missing".to_string(),
968                    size: None,
969                    modified: None,
970                    created: None,
971                    readonly: false,
972                    symlink: false,
973                    canonical_path: None,
974                    mime_hint: None,
975                    policy_classification: "allowed".to_string(),
976                };
977                return json_result_with_caps(&output, false, None);
978            }
979            Err(error) => return ToolResult::error(format!("Metadata error: {}", error)),
980        };
981        let is_symlink = symlink_metadata.file_type().is_symlink();
982        let mut policy = "allowed".to_string();
983        let mut canonical_path = None;
984        let metadata = if is_symlink && input.follow_symlinks {
985            let parent = path.parent().unwrap_or_else(|| Path::new("."));
986            let canonical_parent =
987                fs::canonicalize(parent).unwrap_or_else(|_| parent.to_path_buf());
988            match fs::canonicalize(&path) {
989                Ok(target) if !target.starts_with(&canonical_parent) => {
990                    policy = "symlink_escape".to_string();
991                    symlink_metadata.clone()
992                }
993                Ok(target) => {
994                    canonical_path = Some(target.to_string_lossy().to_string());
995                    fs::metadata(&path).unwrap_or_else(|_| symlink_metadata.clone())
996                }
997                Err(_) => {
998                    policy = "broken_symlink".to_string();
999                    symlink_metadata.clone()
1000                }
1001            }
1002        } else {
1003            if let Ok(canonical) = fs::canonicalize(&path) {
1004                canonical_path = Some(canonical.to_string_lossy().to_string());
1005            }
1006            symlink_metadata.clone()
1007        };
1008        let output = FileInfoOutput {
1009            path: input.path,
1010            exists: true,
1011            kind: kind_from_metadata(&metadata).to_string(),
1012            size: metadata.is_file().then_some(metadata.len()),
1013            modified: metadata.modified().ok().map(system_time_rfc3339),
1014            created: metadata.created().ok().map(system_time_rfc3339),
1015            readonly: metadata.permissions().readonly(),
1016            symlink: is_symlink,
1017            canonical_path: if policy == "allowed" {
1018                canonical_path
1019            } else {
1020                None
1021            },
1022            mime_hint: mime_hint(&path),
1023            policy_classification: policy,
1024        };
1025        json_result_with_caps(&output, false, None)
1026    }
1027}
1028
1029#[derive(Debug, Clone)]
1030struct SortablePath {
1031    path: String,
1032    modified: Option<std::time::SystemTime>,
1033    size: u64,
1034    kind: String,
1035}
1036
1037#[derive(Debug)]
1038struct GlobMatcher {
1039    regex: Regex,
1040}
1041
1042impl GlobMatcher {
1043    fn new(pattern: &str) -> Result<Self, regex::Error> {
1044        Regex::new(&glob_to_regex(pattern)).map(|regex| Self { regex })
1045    }
1046
1047    fn matches(&self, path: &str) -> bool {
1048        self.regex.is_match(&normalize_slashes(path.to_string()))
1049    }
1050}
1051
1052#[derive(Debug)]
1053struct LineHit {
1054    line: usize,
1055    text: String,
1056    is_match: bool,
1057}
1058
1059fn read_tool_metadata(operation: ToolOperationKind) -> ToolSafetyMetadata {
1060    ToolSafetyMetadata {
1061        read_only: true,
1062        concurrency_safe: true,
1063        operation,
1064        side_effect_level: ToolSideEffectLevel::None,
1065        requires_network: false,
1066        destructive: false,
1067        open_world: false,
1068        host_dependent: false,
1069        requires_user_interaction: false,
1070        supports_cancellation: true,
1071        default_requires_approval: false,
1072        should_defer_schema: false,
1073        max_output_chars: Some(DEFAULT_MAX_OUTPUT_CHARS),
1074        max_result_size_chars: Some(DEFAULT_MAX_OUTPUT_CHARS),
1075    }
1076}
1077
1078fn ensure_safe_path(path: &Path) -> Result<(), String> {
1079    ensure_safe_path_allow_missing(path)?;
1080    if path.exists()
1081        && let Ok(canonical) = fs::canonicalize(path)
1082    {
1083        ensure_no_blocked_components(&canonical)?;
1084    }
1085    Ok(())
1086}
1087
1088fn ensure_safe_path_allow_missing(path: &Path) -> Result<(), String> {
1089    ensure_no_blocked_components(path)?;
1090    Ok(())
1091}
1092
1093fn ensure_no_blocked_components(path: &Path) -> Result<(), String> {
1094    for component in path.components() {
1095        let Component::Normal(value) = component else {
1096            continue;
1097        };
1098        let value = value.to_string_lossy();
1099        if value == ".git" {
1100            return Err("Access to raw .git paths is blocked".to_string());
1101        }
1102    }
1103    Ok(())
1104}
1105
1106fn is_default_ignored_dir(name: &str) -> bool {
1107    DEFAULT_IGNORED_DIRS.iter().any(|ignored| ignored == &name)
1108}
1109
1110fn optional_matcher(pattern: Option<&str>) -> Result<Option<GlobMatcher>, regex::Error> {
1111    pattern.map(GlobMatcher::new).transpose()
1112}
1113
1114fn glob_to_regex(pattern: &str) -> String {
1115    let mut regex = String::from("^");
1116    let chars: Vec<char> = pattern.chars().collect();
1117    let mut index = 0usize;
1118    while index < chars.len() {
1119        match chars[index] {
1120            '*' if chars.get(index + 1) == Some(&'*') => {
1121                regex.push_str(".*");
1122                index += 2;
1123            }
1124            '*' => {
1125                regex.push_str("[^/]*");
1126                index += 1;
1127            }
1128            '?' => {
1129                regex.push_str("[^/]");
1130                index += 1;
1131            }
1132            '/' | '\\' => {
1133                regex.push('/');
1134                index += 1;
1135            }
1136            ch => {
1137                regex.push_str(&regex::escape(&ch.to_string()));
1138                index += 1;
1139            }
1140        }
1141    }
1142    regex.push('$');
1143    regex
1144}
1145
1146fn relative_path(root: &Path, path: &Path) -> String {
1147    path.strip_prefix(root)
1148        .unwrap_or(path)
1149        .to_string_lossy()
1150        .to_string()
1151}
1152
1153fn normalize_slashes(path: String) -> String {
1154    path.replace('\\', "/")
1155}
1156
1157fn sort_paths(entries: &mut [SortablePath], sort: &str) {
1158    match sort {
1159        "modified" => entries.sort_by(|a, b| {
1160            a.modified
1161                .cmp(&b.modified)
1162                .then_with(|| a.path.cmp(&b.path))
1163        }),
1164        "size" => entries.sort_by(|a, b| a.size.cmp(&b.size).then_with(|| a.path.cmp(&b.path))),
1165        "kind" => entries.sort_by(|a, b| a.kind.cmp(&b.kind).then_with(|| a.path.cmp(&b.path))),
1166        _ => entries.sort_by(|a, b| a.path.cmp(&b.path)),
1167    }
1168}
1169
1170fn sort_list_entries(entries: &mut [FileListEntry], sort: &str) {
1171    match sort {
1172        "modified" => entries.sort_by(|a, b| {
1173            a.modified
1174                .cmp(&b.modified)
1175                .then_with(|| a.path.cmp(&b.path))
1176        }),
1177        "size" => entries.sort_by(|a, b| a.size.cmp(&b.size).then_with(|| a.path.cmp(&b.path))),
1178        "kind" => entries.sort_by(|a, b| a.kind.cmp(&b.kind).then_with(|| a.path.cmp(&b.path))),
1179        _ => entries.sort_by(|a, b| a.path.cmp(&b.path)),
1180    }
1181}
1182
1183async fn collect_files(root: &Path, include: Option<&GlobMatcher>) -> Vec<PathBuf> {
1184    let mut files = Vec::new();
1185    if root.is_file() {
1186        files.push(root.to_path_buf());
1187        return files;
1188    }
1189    let mut stack = vec![root.to_path_buf()];
1190    let mut visited = 0usize;
1191    while let Some(dir) = stack.pop() {
1192        let read_dir = match fs::read_dir(&dir) {
1193            Ok(read_dir) => read_dir,
1194            Err(_) => continue,
1195        };
1196        for entry in read_dir.flatten() {
1197            visited += 1;
1198            if visited.is_multiple_of(128) {
1199                tokio::task::yield_now().await;
1200            }
1201            let path = entry.path();
1202            let file_name = entry.file_name().to_string_lossy().to_string();
1203            if is_default_ignored_dir(&file_name) {
1204                continue;
1205            }
1206            let metadata = match entry.metadata() {
1207                Ok(metadata) => metadata,
1208                Err(_) => continue,
1209            };
1210            if metadata.is_dir() {
1211                stack.push(path);
1212            } else if metadata.is_file() {
1213                let relative = normalize_slashes(relative_path(root, &path));
1214                if include.is_none_or(|matcher| matcher.matches(&relative)) {
1215                    files.push(path);
1216                }
1217            }
1218        }
1219    }
1220    files.sort();
1221    files
1222}
1223
1224fn build_search_regex(
1225    pattern: &str,
1226    mode: &GrepMode,
1227    case_sensitive: bool,
1228) -> Result<Regex, regex::Error> {
1229    let source = match mode {
1230        GrepMode::Regex => pattern.to_string(),
1231        GrepMode::Literal => regex::escape(pattern),
1232    };
1233    RegexBuilder::new(&source)
1234        .case_insensitive(!case_sensitive)
1235        .build()
1236}
1237
1238fn line_matches(text: &str, regex: &Regex, context: usize) -> Vec<LineHit> {
1239    let lines: Vec<&str> = text.lines().collect();
1240    let mut included = BTreeSet::new();
1241    let mut matching = BTreeSet::new();
1242    for (index, line) in lines.iter().enumerate() {
1243        if regex.is_match(line) {
1244            matching.insert(index + 1);
1245            let start = index.saturating_sub(context);
1246            let end = (index + context).min(lines.len().saturating_sub(1));
1247            for ctx in start..=end {
1248                included.insert(ctx + 1);
1249            }
1250        }
1251    }
1252    included
1253        .into_iter()
1254        .filter_map(|line_number| {
1255            lines.get(line_number - 1).map(|line| LineHit {
1256                line: line_number,
1257                text: (*line).to_string(),
1258                is_match: matching.contains(&line_number),
1259            })
1260        })
1261        .collect()
1262}
1263
1264fn output_mode_name(mode: &GrepOutputMode) -> &'static str {
1265    match mode {
1266        GrepOutputMode::Content => "content",
1267        GrepOutputMode::FilesWithMatches => "files_with_matches",
1268        GrepOutputMode::Count => "count",
1269    }
1270}
1271
1272fn looks_binary(bytes: &[u8]) -> bool {
1273    bytes.iter().take(8_192).any(|byte| *byte == 0)
1274}
1275
1276fn read_prefix(path: &Path, max_bytes: usize) -> std::io::Result<Vec<u8>> {
1277    use std::io::Read;
1278    let mut file = fs::File::open(path)?;
1279    let mut buffer = vec![0u8; max_bytes];
1280    let read = file.read(&mut buffer)?;
1281    buffer.truncate(read);
1282    Ok(buffer)
1283}
1284
1285async fn read_text_range(
1286    path: &Path,
1287    start_line: usize,
1288    end_line: usize,
1289    max_lines: usize,
1290    max_bytes: u64,
1291) -> Result<FileReadOutput, String> {
1292    let file = fs::File::open(path).map_err(|error| format!("Read error: {}", error))?;
1293    let file_size = file
1294        .metadata()
1295        .map_err(|error| format!("Metadata error: {}", error))?
1296        .len();
1297    let mut reader = BufReader::new(file);
1298    let mut line = String::new();
1299    let mut line_number = 0usize;
1300    let mut content = String::new();
1301    let mut bytes_read = 0usize;
1302    let mut lines_returned = 0usize;
1303    let mut truncated = false;
1304
1305    loop {
1306        line.clear();
1307        let read = reader
1308            .read_line(&mut line)
1309            .map_err(|error| format!("Read error: {}", error))?;
1310        if read == 0 {
1311            break;
1312        }
1313        line_number += 1;
1314        if line_number < start_line {
1315            continue;
1316        }
1317        if line_number > end_line || lines_returned >= max_lines {
1318            truncated = true;
1319            break;
1320        }
1321        if bytes_read.saturating_add(read) > max_bytes as usize {
1322            truncated = true;
1323            break;
1324        }
1325        content.push_str(&line);
1326        bytes_read += read;
1327        lines_returned += 1;
1328        if line_number.is_multiple_of(256) {
1329            tokio::task::yield_now().await;
1330        }
1331    }
1332
1333    Ok(FileReadOutput {
1334        path: path.to_string_lossy().to_string(),
1335        content,
1336        start_line,
1337        end_line: if lines_returned == 0 {
1338            0
1339        } else {
1340            start_line + lines_returned - 1
1341        },
1342        total_lines: line_number,
1343        bytes_read,
1344        file_size,
1345        truncated,
1346        large_file: file_size > max_bytes,
1347        encoding: "utf-8".to_string(),
1348        version: None,
1349    })
1350}
1351
1352fn kind_from_metadata(metadata: &fs::Metadata) -> &'static str {
1353    let file_type = metadata.file_type();
1354    if file_type.is_symlink() {
1355        "symlink"
1356    } else if metadata.is_dir() {
1357        "directory"
1358    } else if metadata.is_file() {
1359        "file"
1360    } else {
1361        "other"
1362    }
1363}
1364
1365fn system_time_rfc3339(time: std::time::SystemTime) -> String {
1366    let datetime: chrono::DateTime<chrono::Utc> = time.into();
1367    datetime.to_rfc3339()
1368}
1369
1370fn mime_hint(path: &Path) -> Option<String> {
1371    let ext = path.extension()?.to_string_lossy().to_ascii_lowercase();
1372    let mime = match ext.as_str() {
1373        "rs" | "toml" | "yaml" | "yml" | "json" | "md" | "txt" | "html" | "css" | "js" | "ts"
1374        | "tsx" | "jsx" | "py" | "sh" => "text/plain",
1375        "png" => "image/png",
1376        "jpg" | "jpeg" => "image/jpeg",
1377        "gif" => "image/gif",
1378        "pdf" => "application/pdf",
1379        _ => return None,
1380    };
1381    Some(mime.to_string())
1382}
1383
1384fn truncate_chars(text: String, max_chars: usize) -> (String, bool) {
1385    let mut chars = text.chars();
1386    let truncated: String = chars.by_ref().take(max_chars).collect();
1387    if chars.next().is_some() {
1388        (truncated, true)
1389    } else {
1390        (text, false)
1391    }
1392}
1393
1394fn json_result_with_caps<T: Serialize>(
1395    output: &T,
1396    truncated: bool,
1397    max_output_chars: Option<usize>,
1398) -> ToolResult {
1399    let json = match serde_json::to_string(output) {
1400        Ok(json) => json,
1401        Err(error) => return ToolResult::error(format!("Serialization error: {}", error)),
1402    };
1403    let (bounded, output_truncated) = if let Some(max) = max_output_chars {
1404        truncate_chars(json, max)
1405    } else {
1406        (json, false)
1407    };
1408    let mut metadata = HashMap::new();
1409    metadata.insert(
1410        "truncated".to_string(),
1411        Value::Bool(truncated || output_truncated),
1412    );
1413    if let Some(max) = max_output_chars {
1414        metadata.insert("max_output_chars".to_string(), Value::from(max));
1415    }
1416    ToolResult::ok_with_metadata(bounded, metadata)
1417}
1418
1419#[cfg(test)]
1420mod tests {
1421    use super::*;
1422    use tempfile::tempdir;
1423
1424    fn value(output: &str) -> Value {
1425        serde_json::from_str(output).unwrap()
1426    }
1427
1428    #[tokio::test]
1429    async fn result_list_tools_reject_zero_max_results() {
1430        let context = ai_agents_core::ToolExecutionContext::test("test");
1431        let glob = GlobTool::new()
1432            .execute(
1433                serde_json::json!({"pattern": "*", "max_results": 0}),
1434                context.clone(),
1435            )
1436            .await;
1437        let grep = GrepTool::new()
1438            .execute(
1439                serde_json::json!({"pattern": "test", "max_results": 0}),
1440                context.clone(),
1441            )
1442            .await;
1443        let file_list = FileListTool::new()
1444            .execute(serde_json::json!({"path": ".", "max_results": 0}), context)
1445            .await;
1446
1447        for result in [glob, grep, file_list] {
1448            assert!(!result.success);
1449            assert!(result.output.contains("max_results must be greater than 0"));
1450        }
1451    }
1452
1453    #[tokio::test]
1454    async fn result_list_tools_reject_zero_execution_context_limit() {
1455        let mut context = ai_agents_core::ToolExecutionContext::test("test");
1456        context.limits.max_results = Some(0);
1457        let glob = GlobTool::new()
1458            .execute(serde_json::json!({"pattern": "*"}), context.clone())
1459            .await;
1460        let grep = GrepTool::new()
1461            .execute(serde_json::json!({"pattern": "test"}), context.clone())
1462            .await;
1463        let file_list = FileListTool::new()
1464            .execute(serde_json::json!({"path": "."}), context)
1465            .await;
1466
1467        for result in [glob, grep, file_list] {
1468            assert!(!result.success);
1469            assert!(result.output.contains("max_results must be greater than 0"));
1470        }
1471    }
1472
1473    #[tokio::test]
1474    async fn glob_sorts_and_offsets_results() {
1475        let dir = tempdir().unwrap();
1476        fs::write(dir.path().join("b.rs"), "").unwrap();
1477        fs::write(dir.path().join("a.rs"), "").unwrap();
1478        fs::create_dir(dir.path().join("target")).unwrap();
1479        fs::write(dir.path().join("target/ignored.rs"), "").unwrap();
1480        let result = GlobTool::new()
1481            .execute(
1482                serde_json::json!({
1483                    "pattern": "*.rs",
1484                    "path": dir.path(),
1485                    "max_results": 1,
1486                    "offset": 1
1487                }),
1488                ai_agents_core::ToolExecutionContext::test("test"),
1489            )
1490            .await;
1491        assert!(result.success);
1492        let output = value(&result.output);
1493        assert_eq!(output["paths"][0], "b.rs");
1494        assert_eq!(output["total_count"], 2);
1495    }
1496
1497    #[tokio::test]
1498    async fn grep_supports_literal_and_content_offsets() {
1499        let dir = tempdir().unwrap();
1500        fs::write(dir.path().join("one.txt"), "alpha\nbeta\n").unwrap();
1501        fs::write(dir.path().join("two.txt"), "alpha\ngamma\n").unwrap();
1502        let result = GrepTool::new()
1503            .execute(
1504                serde_json::json!({
1505                    "pattern": "alpha",
1506                    "mode": "literal",
1507                    "path": dir.path(),
1508                    "output_mode": "content",
1509                    "max_results": 1,
1510                    "offset": 1
1511                }),
1512                ai_agents_core::ToolExecutionContext::test("test"),
1513            )
1514            .await;
1515        assert!(result.success);
1516        let output = value(&result.output);
1517        assert_eq!(output["matches"].as_array().unwrap().len(), 1);
1518        assert!(output["truncated"].as_bool().unwrap());
1519    }
1520
1521    #[tokio::test]
1522    async fn grep_skips_binary_files() {
1523        let dir = tempdir().unwrap();
1524        fs::write(dir.path().join("bad.bin"), b"a\0b").unwrap();
1525        let result = GrepTool::new()
1526            .execute(
1527                serde_json::json!({
1528                    "pattern": "a",
1529                    "path": dir.path()
1530                }),
1531                ai_agents_core::ToolExecutionContext::test("test"),
1532            )
1533            .await;
1534        assert!(result.success);
1535        let output = value(&result.output);
1536        assert_eq!(output["skipped_binary"], 1);
1537    }
1538
1539    #[tokio::test]
1540    async fn file_read_handles_line_ranges_and_unicode() {
1541        let dir = tempdir().unwrap();
1542        let path = dir.path().join("unicode.txt");
1543        fs::write(&path, "one\nė•ˆë…•\nthree\n").unwrap();
1544        let result = FileReadTool::new()
1545            .execute(
1546                serde_json::json!({
1547                    "path": path,
1548                    "start_line": 2,
1549                    "end_line": 2
1550                }),
1551                ai_agents_core::ToolExecutionContext::test("test"),
1552            )
1553            .await;
1554        assert!(result.success);
1555        let output = value(&result.output);
1556        assert_eq!(output["content"], "ė•ˆë…•\n");
1557        assert_eq!(output["start_line"], 2);
1558    }
1559
1560    #[tokio::test]
1561    async fn file_read_large_file_without_range_returns_metadata() {
1562        let dir = tempdir().unwrap();
1563        let path = dir.path().join("large.txt");
1564        fs::write(&path, "abcdef").unwrap();
1565        let result = FileReadTool::new()
1566            .execute(
1567                serde_json::json!({
1568                    "path": path,
1569                    "max_bytes": 2
1570                }),
1571                ai_agents_core::ToolExecutionContext::test("test"),
1572            )
1573            .await;
1574        assert!(result.success);
1575        let output = value(&result.output);
1576        assert!(output["large_file"].as_bool().unwrap());
1577        assert!(output["truncated"].as_bool().unwrap());
1578    }
1579
1580    #[tokio::test]
1581    async fn file_list_paginates_and_notes_symlink_escape() {
1582        let dir = tempdir().unwrap();
1583        fs::write(dir.path().join("a.txt"), "a").unwrap();
1584        fs::write(dir.path().join("b.txt"), "b").unwrap();
1585        #[cfg(unix)]
1586        std::os::unix::fs::symlink("/", dir.path().join("escape")).unwrap();
1587        let result = FileListTool::new()
1588            .execute(
1589                serde_json::json!({
1590                    "path": dir.path(),
1591                    "recursive": true,
1592                    "max_results": 1,
1593                    "offset": 1
1594                }),
1595                ai_agents_core::ToolExecutionContext::test("test"),
1596            )
1597            .await;
1598        assert!(result.success);
1599        let output = value(&result.output);
1600        assert_eq!(output["entries"].as_array().unwrap().len(), 1);
1601    }
1602
1603    #[tokio::test]
1604    async fn file_info_reports_symlink_policy() {
1605        let dir = tempdir().unwrap();
1606        let target = dir.path().join("target.txt");
1607        fs::write(&target, "hello").unwrap();
1608        let link = dir.path().join("link.txt");
1609        #[cfg(unix)]
1610        std::os::unix::fs::symlink(&target, &link).unwrap();
1611        #[cfg(windows)]
1612        match std::os::windows::fs::symlink_file(&target, &link) {
1613            Ok(()) => {}
1614            Err(error)
1615                if error.kind() == std::io::ErrorKind::PermissionDenied
1616                    || error.raw_os_error() == Some(1314) =>
1617            {
1618                return;
1619            }
1620            Err(error) => panic!("failed to create file symlink: {error}"),
1621        }
1622        let result = FileInfoTool::new()
1623            .execute(
1624                serde_json::json!({
1625                    "path": link,
1626                    "follow_symlinks": true
1627                }),
1628                ai_agents_core::ToolExecutionContext::test("test"),
1629            )
1630            .await;
1631        assert!(result.success);
1632        let output = value(&result.output);
1633        assert!(output["symlink"].as_bool().unwrap());
1634        assert_eq!(output["policy_classification"], "allowed");
1635    }
1636}