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
35pub struct GlobTool;
37
38impl GlobTool {
39 pub fn new() -> Self {
41 Self
42 }
43}
44
45impl Default for GlobTool {
46 fn default() -> Self {
47 Self::new()
48 }
49}
50
51pub struct GrepTool;
53
54impl GrepTool {
55 pub fn new() -> Self {
57 Self
58 }
59}
60
61impl Default for GrepTool {
62 fn default() -> Self {
63 Self::new()
64 }
65}
66
67pub struct FileReadTool {
69 versions: FileVersionStore,
70}
71
72impl FileReadTool {
73 pub fn new() -> Self {
75 Self::with_version_store(FileVersionStore::default())
76 }
77
78 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
90pub struct FileListTool;
92
93impl FileListTool {
94 pub fn new() -> Self {
96 Self
97 }
98}
99
100impl Default for FileListTool {
101 fn default() -> Self {
102 Self::new()
103 }
104}
105
106pub struct FileInfoTool;
108
109impl FileInfoTool {
110 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 pattern: String,
126 #[serde(default)]
128 path: Option<String>,
129 #[serde(
131 default,
132 deserialize_with = "crate::deserialize_optional_positive_usize"
133 )]
134 #[schemars(range(min = 1))]
135 max_results: Option<usize>,
136 #[serde(default)]
138 offset: Option<usize>,
139 #[serde(default)]
141 include_dirs: bool,
142 #[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 pattern: String,
178 #[serde(default)]
180 mode: GrepMode,
181 #[serde(default)]
183 path: Option<String>,
184 #[serde(default)]
186 include_glob: Option<String>,
187 #[serde(default)]
189 case_sensitive: bool,
190 #[serde(default)]
192 output_mode: GrepOutputMode,
193 #[serde(default)]
195 context: Option<usize>,
196 #[serde(
198 default,
199 deserialize_with = "crate::deserialize_optional_positive_usize"
200 )]
201 #[schemars(range(min = 1))]
202 max_results: Option<usize>,
203 #[serde(default)]
205 offset: Option<usize>,
206 #[serde(default)]
208 max_file_size_bytes: Option<u64>,
209 #[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 path: String,
242 #[serde(default)]
244 start_line: Option<usize>,
245 #[serde(default)]
247 end_line: Option<usize>,
248 #[serde(default)]
250 max_lines: Option<usize>,
251 #[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 path: String,
276 #[serde(default)]
278 recursive: bool,
279 #[serde(default)]
281 include_glob: Option<String>,
282 #[serde(default)]
284 exclude_glob: Option<String>,
285 #[serde(default)]
287 include_hidden: bool,
288 #[serde(
290 default,
291 deserialize_with = "crate::deserialize_optional_positive_usize"
292 )]
293 #[schemars(range(min = 1))]
294 max_results: Option<usize>,
295 #[serde(default)]
297 offset: Option<usize>,
298 #[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 path: String,
331 #[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, ®ex, 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(®ex::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}