返回 DeepSeek-TUI-2026
file.rs
根目录 / crates / tui / src / tools / file.rs
1 //! File system tools: `read_file`, `write_file`, `edit_file`, `list_dir`
2 //!
3 //! These tools provide safe file system operations within the workspace,
4 //! with path validation to prevent escaping the workspace boundary.
5
6 use super::diff_format::make_unified_diff;
7 use super::spec::{
8 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
9 lsp_diagnostics_for_paths, optional_str, required_str,
10 };
11 use async_trait::async_trait;
12 use serde_json::{Value, json};
13 use std::fs;
14 use std::path::Path;
15 use std::process::{Command, Stdio};
16
17 // === ReadFileTool ===
18
19 /// Tool for reading UTF-8 files from the workspace.
20 pub struct ReadFileTool;
21
22 #[async_trait]
23 impl ToolSpec for ReadFileTool {
24 fn name(&self) -> &'static str {
25 "read_file"
26 }
27
28 fn description(&self) -> &'static str {
29 "Read a file from the workspace. Plain text is returned as-is; PDFs are auto-extracted via `pdftotext` (poppler) when available."
30 }
31
32 fn input_schema(&self) -> Value {
33 json!({
34 "type": "object",
35 "properties": {
36 "path": {
37 "type": "string",
38 "description": "Path to the file (relative to workspace or absolute)"
39 },
40 "pages": {
41 "type": "string",
42 "description": "PDF only: page range to extract, e.g. \"1-5\" or \"10\". Ignored for non-PDF files."
43 }
44 },
45 "required": ["path"]
46 })
47 }
48
49 fn capabilities(&self) -> Vec<ToolCapability> {
50 vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
51 }
52
53 fn supports_parallel(&self) -> bool {
54 true
55 }
56
57 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
58 let path_str = required_str(&input, "path")?;
59 let file_path = context.resolve_path(path_str)?;
60 let pages = optional_str(&input, "pages");
61
62 if is_pdf(&file_path)? {
63 return read_pdf(&file_path, pages);
64 }
65
66 let contents = fs::read_to_string(&file_path).map_err(|e| {
67 ToolError::execution_failed(format!("Failed to read {}: {}", file_path.display(), e))
68 })?;
69
70 Ok(ToolResult::success(contents))
71 }
72 }
73
74 /// Detect a PDF by extension OR by sniffing the `%PDF-` magic bytes.
75 /// Files without an extension are still recognized as PDFs when the header
76 /// matches.
77 fn is_pdf(path: &Path) -> Result<bool, ToolError> {
78 if path
79 .extension()
80 .and_then(|e| e.to_str())
81 .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
82 {
83 return Ok(true);
84 }
85 // Sniff first 4 bytes. Don't error if the file doesn't exist — let the
86 // caller's `read_to_string` produce the canonical not-found error.
87 let mut buf = [0u8; 4];
88 let result = match fs::File::open(path) {
89 Ok(mut f) => {
90 use std::io::Read;
91 f.read_exact(&mut buf).map(|_| buf)
92 }
93 Err(_) => return Ok(false),
94 };
95 Ok(matches!(result, Ok(b) if &b == b"%PDF"))
96 }
97
98 fn parse_pages_arg(spec: &str) -> Option<(u32, u32)> {
99 let trimmed = spec.trim();
100 if trimmed.is_empty() {
101 return None;
102 }
103 if let Some((a, b)) = trimmed.split_once('-') {
104 let start: u32 = a.trim().parse().ok()?;
105 let end: u32 = b.trim().parse().ok()?;
106 if start == 0 || end < start {
107 return None;
108 }
109 Some((start, end))
110 } else {
111 let n: u32 = trimmed.parse().ok()?;
112 if n == 0 {
113 return None;
114 }
115 Some((n, n))
116 }
117 }
118
119 fn read_pdf(path: &Path, pages: Option<&str>) -> Result<ToolResult, ToolError> {
120 // Try pdftotext (from the poppler suite). Other extractors (mutool,
121 // pdfminer) could be added later behind the same dispatch.
122 let mut cmd = Command::new("pdftotext");
123 cmd.arg("-layout");
124
125 if let Some(spec) = pages {
126 match parse_pages_arg(spec) {
127 Some((start, end)) => {
128 cmd.arg("-f").arg(start.to_string());
129 cmd.arg("-l").arg(end.to_string());
130 }
131 None => {
132 return Err(ToolError::invalid_input(format!(
133 "invalid `pages` value `{spec}` (expected `N` or `N-M`, e.g. `1-5`)"
134 )));
135 }
136 }
137 }
138
139 cmd.arg(path).arg("-"); // output to stdout
140 cmd.stdin(Stdio::null())
141 .stdout(Stdio::piped())
142 .stderr(Stdio::piped());
143
144 let child = match cmd.spawn() {
145 Ok(c) => c,
146 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
147 // Structured "binary unavailable" — caller knows what to suggest.
148 return ToolResult::json(&json!({
149 "type": "binary_unavailable",
150 "path": path.display().to_string(),
151 "kind": "pdf",
152 "reason": "pdftotext not installed",
153 "hint": "install poppler (macOS: `brew install poppler`; Debian/Ubuntu: `apt install poppler-utils`)"
154 }))
155 .map_err(|e| {
156 ToolError::execution_failed(format!("failed to serialize response: {e}"))
157 });
158 }
159 Err(e) => {
160 return Err(ToolError::execution_failed(format!(
161 "failed to launch pdftotext: {e}"
162 )));
163 }
164 };
165
166 let output = child
167 .wait_with_output()
168 .map_err(|e| ToolError::execution_failed(format!("pdftotext failed to complete: {e}")))?;
169
170 if !output.status.success() {
171 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
172 return Err(ToolError::execution_failed(format!(
173 "pdftotext failed (exit {:?}): {stderr}",
174 output.status.code()
175 )));
176 }
177
178 let text = String::from_utf8_lossy(&output.stdout).to_string();
179 Ok(ToolResult::success(text))
180 }
181
182 // === WriteFileTool ===
183
184 /// Tool for writing UTF-8 files to the workspace.
185 pub struct WriteFileTool;
186
187 #[async_trait]
188 impl ToolSpec for WriteFileTool {
189 fn name(&self) -> &'static str {
190 "write_file"
191 }
192
193 fn description(&self) -> &'static str {
194 "Write content to a UTF-8 file in the workspace."
195 }
196
197 fn input_schema(&self) -> Value {
198 json!({
199 "type": "object",
200 "properties": {
201 "path": {
202 "type": "string",
203 "description": "Path to the file"
204 },
205 "content": {
206 "type": "string",
207 "description": "Content to write"
208 }
209 },
210 "required": ["path", "content"]
211 })
212 }
213
214 fn capabilities(&self) -> Vec<ToolCapability> {
215 vec![
216 ToolCapability::WritesFiles,
217 ToolCapability::Sandboxable,
218 ToolCapability::RequiresApproval,
219 ]
220 }
221
222 fn approval_requirement(&self) -> ApprovalRequirement {
223 ApprovalRequirement::Suggest
224 }
225
226 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
227 let path_str = required_str(&input, "path")?;
228 let file_content = required_str(&input, "content")?;
229
230 let file_path = context.resolve_path(path_str)?;
231
232 // Snapshot the existing contents (if any) before we overwrite — used
233 // to render an inline diff in the tool result.
234 let existed_before = file_path.exists();
235 let prior_contents = if existed_before {
236 fs::read_to_string(&file_path).unwrap_or_default()
237 } else {
238 String::new()
239 };
240
241 // Create parent directories if needed
242 if let Some(parent) = file_path.parent() {
243 fs::create_dir_all(parent).map_err(|e| {
244 ToolError::execution_failed(format!(
245 "Failed to create directory {}: {}",
246 parent.display(),
247 e
248 ))
249 })?;
250 }
251
252 fs::write(&file_path, file_content).map_err(|e| {
253 ToolError::execution_failed(format!("Failed to write {}: {}", file_path.display(), e))
254 })?;
255
256 let display = file_path.display().to_string();
257 let diff = make_unified_diff(&display, &prior_contents, file_content);
258 let summary = if existed_before {
259 format!("Wrote {} bytes to {}", file_content.len(), display)
260 } else {
261 format!("Created {} ({} bytes)", display, file_content.len())
262 };
263 let body = if diff.is_empty() {
264 format!("{summary}\n(no changes)")
265 } else {
266 format!("{diff}\n{summary}")
267 };
268
269 // Append LSP diagnostics for the written file when enabled (#428).
270 let diag_block = lsp_diagnostics_for_paths(context, &[file_path]).await;
271 let full_body = if diag_block.is_empty() {
272 body
273 } else {
274 format!("{body}\n{diag_block}")
275 };
276
277 Ok(ToolResult::success(full_body))
278 }
279 }
280
281 // === EditFileTool ===
282
283 /// Tool for search/replace editing of files.
284 pub struct EditFileTool;
285
286 #[async_trait]
287 impl ToolSpec for EditFileTool {
288 fn name(&self) -> &'static str {
289 "edit_file"
290 }
291
292 fn description(&self) -> &'static str {
293 "Replace text in a file using search/replace. Required: 'path' (file to edit), 'search' (exact text to find), 'replace' (text to substitute)."
294 }
295
296 fn input_schema(&self) -> Value {
297 json!({
298 "type": "object",
299 "properties": {
300 "path": {
301 "type": "string",
302 "description": "Path to the file"
303 },
304 "search": {
305 "type": "string",
306 "description": "Text to search for"
307 },
308 "replace": {
309 "type": "string",
310 "description": "Text to replace with"
311 }
312 },
313 "required": ["path", "search", "replace"]
314 })
315 }
316
317 fn capabilities(&self) -> Vec<ToolCapability> {
318 vec![
319 ToolCapability::WritesFiles,
320 ToolCapability::Sandboxable,
321 ToolCapability::RequiresApproval,
322 ]
323 }
324
325 fn approval_requirement(&self) -> ApprovalRequirement {
326 ApprovalRequirement::Suggest
327 }
328
329 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
330 let path_str = required_str(&input, "path")?;
331 let search = required_str(&input, "search")?;
332 let replace = required_str(&input, "replace")?;
333
334 let file_path = context.resolve_path(path_str)?;
335
336 let contents = fs::read_to_string(&file_path).map_err(|e| {
337 ToolError::execution_failed(format!("Failed to read {}: {}", file_path.display(), e))
338 })?;
339
340 let count = contents.matches(search).count();
341 if count == 0 {
342 return Err(ToolError::execution_failed(format!(
343 "Search string not found in {}",
344 file_path.display()
345 )));
346 }
347
348 let updated = contents.replace(search, replace);
349
350 fs::write(&file_path, &updated).map_err(|e| {
351 ToolError::execution_failed(format!("Failed to write {}: {}", file_path.display(), e))
352 })?;
353
354 let display = file_path.display().to_string();
355 let diff = make_unified_diff(&display, &contents, &updated);
356 let summary = format!("Replaced {count} occurrence(s) in {display}");
357 let body = if diff.is_empty() {
358 format!("{summary}\n(no textual changes)")
359 } else {
360 format!("{diff}\n{summary}")
361 };
362
363 // Append LSP diagnostics for the edited file when enabled (#428).
364 let diag_block = lsp_diagnostics_for_paths(context, &[file_path]).await;
365 let full_body = if diag_block.is_empty() {
366 body
367 } else {
368 format!("{body}\n{diag_block}")
369 };
370
371 Ok(ToolResult::success(full_body))
372 }
373 }
374
375 // === ListDirTool ===
376
377 /// Tool for listing directory contents.
378 pub struct ListDirTool;
379
380 #[async_trait]
381 impl ToolSpec for ListDirTool {
382 fn name(&self) -> &'static str {
383 "list_dir"
384 }
385
386 fn description(&self) -> &'static str {
387 "List entries in a directory relative to the workspace."
388 }
389
390 fn input_schema(&self) -> Value {
391 json!({
392 "type": "object",
393 "properties": {
394 "path": {
395 "type": "string",
396 "description": "Relative path (default: .)"
397 }
398 },
399 "required": []
400 })
401 }
402
403 fn capabilities(&self) -> Vec<ToolCapability> {
404 vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
405 }
406
407 fn supports_parallel(&self) -> bool {
408 true
409 }
410
411 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
412 let path_str = optional_str(&input, "path").unwrap_or(".");
413 let dir_path = context.resolve_path(path_str)?;
414
415 let mut entries = Vec::new();
416
417 for entry in fs::read_dir(&dir_path).map_err(|e| {
418 ToolError::execution_failed(format!(
419 "Failed to read directory {}: {}",
420 dir_path.display(),
421 e
422 ))
423 })? {
424 let entry = entry.map_err(|e| ToolError::execution_failed(e.to_string()))?;
425 let file_type = entry
426 .file_type()
427 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
428
429 entries.push(json!({
430 "name": entry.file_name().to_string_lossy().to_string(),
431 "is_dir": file_type.is_dir(),
432 }));
433 }
434
435 ToolResult::json(&entries).map_err(|e| ToolError::execution_failed(e.to_string()))
436 }
437 }
438
439 // === Unit Tests ===
440
441 #[cfg(test)]
442 mod tests {
443 use super::*;
444 use tempfile::tempdir;
445
446 #[tokio::test]
447 async fn test_read_file_tool() {
448 let tmp = tempdir().expect("tempdir");
449 let ctx = ToolContext::new(tmp.path().to_path_buf());
450
451 // Create a test file
452 let test_file = tmp.path().join("test.txt");
453 fs::write(&test_file, "hello world").expect("write");
454
455 let tool = ReadFileTool;
456 let result = tool
457 .execute(json!({"path": "test.txt"}), &ctx)
458 .await
459 .expect("execute");
460
461 assert!(result.success);
462 assert_eq!(result.content, "hello world");
463 }
464
465 #[tokio::test]
466 async fn test_read_file_not_found() {
467 let tmp = tempdir().expect("tempdir");
468 let ctx = ToolContext::new(tmp.path().to_path_buf());
469
470 let tool = ReadFileTool;
471 let result = tool.execute(json!({"path": "nonexistent.txt"}), &ctx).await;
472
473 assert!(result.is_err());
474 }
475
476 #[tokio::test]
477 async fn test_read_file_missing_path() {
478 let tmp = tempdir().expect("tempdir");
479 let ctx = ToolContext::new(tmp.path().to_path_buf());
480
481 let tool = ReadFileTool;
482 let result = tool.execute(json!({}), &ctx).await;
483
484 assert!(result.is_err());
485 let err = result.unwrap_err();
486 assert!(
487 err.to_string()
488 .contains("Failed to validate input: missing required field 'path'")
489 );
490 }
491
492 #[test]
493 fn pdf_detected_by_extension() {
494 let tmp = tempdir().expect("tempdir");
495 let path = tmp.path().join("paper.PDF");
496 fs::write(&path, b"not really a pdf, but extension says yes").unwrap();
497 assert!(is_pdf(&path).unwrap());
498 }
499
500 #[test]
501 fn pdf_detected_by_magic_bytes_without_extension() {
502 let tmp = tempdir().expect("tempdir");
503 let path = tmp.path().join("blob");
504 fs::write(&path, b"%PDF-1.7\nrest of bytes").unwrap();
505 assert!(is_pdf(&path).unwrap());
506 }
507
508 #[test]
509 fn non_pdf_not_detected() {
510 let tmp = tempdir().expect("tempdir");
511 let path = tmp.path().join("notes.txt");
512 fs::write(&path, "hello").unwrap();
513 assert!(!is_pdf(&path).unwrap());
514 }
515
516 #[test]
517 fn pages_arg_parses_single_and_range() {
518 assert_eq!(parse_pages_arg("5"), Some((5, 5)));
519 assert_eq!(parse_pages_arg("1-10"), Some((1, 10)));
520 assert_eq!(parse_pages_arg(" 3 - 7 "), Some((3, 7)));
521 assert_eq!(parse_pages_arg("0"), None);
522 assert_eq!(parse_pages_arg("10-3"), None);
523 assert_eq!(parse_pages_arg(""), None);
524 assert_eq!(parse_pages_arg("abc"), None);
525 }
526
527 #[tokio::test]
528 async fn read_file_returns_binary_unavailable_when_pdftotext_missing() {
529 // We can't reliably remove pdftotext from $PATH in a test, but if
530 // it's missing on the runner this test exercises that branch. If
531 // it's installed, the test exits early — covered by the parse_pages
532 // and is_pdf tests above.
533 if Command::new("pdftotext")
534 .arg("-v")
535 .stdout(Stdio::null())
536 .stderr(Stdio::null())
537 .status()
538 .is_ok()
539 {
540 return;
541 }
542 let tmp = tempdir().expect("tempdir");
543 let path = tmp.path().join("doc.pdf");
544 fs::write(&path, b"%PDF-1.7\n%%EOF").unwrap();
545 let ctx = ToolContext::new(tmp.path().to_path_buf());
546 let result = ReadFileTool
547 .execute(json!({"path": "doc.pdf"}), &ctx)
548 .await
549 .expect("structured response, not error");
550 assert!(result.success);
551 assert!(result.content.contains("binary_unavailable"));
552 assert!(result.content.contains("pdftotext"));
553 }
554
555 #[tokio::test]
556 async fn test_write_file_tool() {
557 let tmp = tempdir().expect("tempdir");
558 let ctx = ToolContext::new(tmp.path().to_path_buf());
559
560 let tool = WriteFileTool;
561 let result = tool
562 .execute(
563 json!({"path": "output.txt", "content": "test content"}),
564 &ctx,
565 )
566 .await
567 .expect("execute");
568
569 assert!(result.success);
570 // New file → "Created …" summary; the unified diff above the summary
571 // primes the TUI's diff-aware renderer (#505).
572 assert!(result.content.contains("Created"), "{}", result.content);
573 assert!(result.content.contains("--- a/"), "{}", result.content);
574 assert!(
575 result.content.contains("+test content"),
576 "{}",
577 result.content
578 );
579
580 // Verify file was written
581 let written = fs::read_to_string(tmp.path().join("output.txt")).expect("read");
582 assert_eq!(written, "test content");
583 }
584
585 #[tokio::test]
586 async fn test_write_file_creates_dirs() {
587 let tmp = tempdir().expect("tempdir");
588 let ctx = ToolContext::new(tmp.path().to_path_buf());
589
590 let tool = WriteFileTool;
591 let result = tool
592 .execute(
593 json!({"path": "subdir/nested/file.txt", "content": "nested content"}),
594 &ctx,
595 )
596 .await
597 .expect("execute");
598
599 assert!(result.success);
600
601 // Verify nested file was created
602 let written = fs::read_to_string(tmp.path().join("subdir/nested/file.txt")).expect("read");
603 assert_eq!(written, "nested content");
604 }
605
606 #[tokio::test]
607 async fn test_edit_file_tool() {
608 let tmp = tempdir().expect("tempdir");
609 let ctx = ToolContext::new(tmp.path().to_path_buf());
610
611 // Create a file to edit
612 let test_file = tmp.path().join("edit_me.txt");
613 fs::write(&test_file, "hello world hello").expect("write");
614
615 let tool = EditFileTool;
616 let result = tool
617 .execute(
618 json!({"path": "edit_me.txt", "search": "hello", "replace": "hi"}),
619 &ctx,
620 )
621 .await
622 .expect("execute");
623
624 assert!(result.success);
625 assert!(result.content.contains("2 occurrence(s)"));
626 // Inline diff (#505) — the unified diff lands above the summary
627 // line so the TUI's diff-aware renderer kicks in.
628 assert!(result.content.contains("--- a/"), "{}", result.content);
629 assert!(
630 result.content.contains("-hello world hello"),
631 "{}",
632 result.content
633 );
634 assert!(
635 result.content.contains("+hi world hi"),
636 "{}",
637 result.content
638 );
639
640 // Verify edit was applied
641 let edited = fs::read_to_string(&test_file).expect("read");
642 assert_eq!(edited, "hi world hi");
643 }
644
645 #[tokio::test]
646 async fn test_edit_file_not_found() {
647 let tmp = tempdir().expect("tempdir");
648 let ctx = ToolContext::new(tmp.path().to_path_buf());
649
650 // Create a file without the search string
651 let test_file = tmp.path().join("no_match.txt");
652 fs::write(&test_file, "foo bar baz").expect("write");
653
654 let tool = EditFileTool;
655 let result = tool
656 .execute(
657 json!({"path": "no_match.txt", "search": "hello", "replace": "hi"}),
658 &ctx,
659 )
660 .await;
661
662 assert!(result.is_err());
663 let err = result.unwrap_err();
664 assert!(err.to_string().contains("not found"));
665 }
666
667 /// #157 — When the model uses `replacement` instead of `replace`,
668 /// the error should name the provided fields so the model can
669 /// self-correct without a second round-trip.
670 #[tokio::test]
671 async fn test_edit_file_wrong_param_name_shows_provided_fields() {
672 let tmp = tempdir().expect("tempdir");
673 let ctx = ToolContext::new(tmp.path().to_path_buf());
674
675 let test_file = tmp.path().join("test.txt");
676 fs::write(&test_file, "hello world").expect("write");
677
678 let tool = EditFileTool;
679 // Model uses `replacement` instead of `replace`.
680 let result = tool
681 .execute(
682 json!({"path": "test.txt", "search": "hello", "replacement": "hi"}),
683 &ctx,
684 )
685 .await;
686
687 assert!(result.is_err());
688 let err = result.unwrap_err().to_string();
689 // The error must name both the missing field AND the provided ones.
690 assert!(
691 err.contains("missing required field 'replace'"),
692 "error must name the missing field: {err}"
693 );
694 assert!(
695 err.contains("Input provided:") || err.contains("provided:"),
696 "error must list the fields the model did supply: {err}"
697 );
698 }
699
700 #[tokio::test]
701 async fn test_list_dir_tool() {
702 let tmp = tempdir().expect("tempdir");
703 let ctx = ToolContext::new(tmp.path().to_path_buf());
704
705 // Create some files and directories
706 fs::write(tmp.path().join("file1.txt"), "").expect("write");
707 fs::write(tmp.path().join("file2.txt"), "").expect("write");
708 fs::create_dir(tmp.path().join("subdir")).expect("mkdir");
709
710 let tool = ListDirTool;
711 let result = tool.execute(json!({}), &ctx).await.expect("execute");
712
713 assert!(result.success);
714 assert!(result.content.contains("file1.txt"));
715 assert!(result.content.contains("file2.txt"));
716 assert!(result.content.contains("subdir"));
717 assert!(result.content.contains("\"is_dir\": true"));
718 }
719
720 #[tokio::test]
721 async fn test_list_dir_with_path() {
722 let tmp = tempdir().expect("tempdir");
723 let ctx = ToolContext::new(tmp.path().to_path_buf());
724
725 // Create a subdirectory with files
726 let subdir = tmp.path().join("mydir");
727 fs::create_dir(&subdir).expect("mkdir");
728 fs::write(subdir.join("nested.txt"), "").expect("write");
729
730 let tool = ListDirTool;
731 let result = tool
732 .execute(json!({"path": "mydir"}), &ctx)
733 .await
734 .expect("execute");
735
736 assert!(result.success);
737 assert!(result.content.contains("nested.txt"));
738 }
739
740 #[test]
741 fn test_read_file_tool_properties() {
742 let tool = ReadFileTool;
743 assert_eq!(tool.name(), "read_file");
744 assert!(tool.is_read_only());
745 assert!(tool.is_sandboxable());
746 assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto);
747 }
748
749 #[test]
750 fn test_write_file_tool_properties() {
751 let tool = WriteFileTool;
752 assert_eq!(tool.name(), "write_file");
753 assert!(!tool.is_read_only());
754 assert!(tool.is_sandboxable());
755 assert_eq!(tool.approval_requirement(), ApprovalRequirement::Suggest);
756 }
757
758 #[test]
759 fn test_edit_file_tool_properties() {
760 let tool = EditFileTool;
761 assert_eq!(tool.name(), "edit_file");
762 assert!(!tool.is_read_only());
763 assert!(tool.is_sandboxable());
764 assert_eq!(tool.approval_requirement(), ApprovalRequirement::Suggest);
765 }
766
767 #[test]
768 fn test_list_dir_tool_properties() {
769 let tool = ListDirTool;
770 assert_eq!(tool.name(), "list_dir");
771 assert!(tool.is_read_only());
772 assert!(tool.is_sandboxable());
773 assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto);
774 }
775
776 #[test]
777 fn test_parallel_support_flags() {
778 let read_tool = ReadFileTool;
779 let list_tool = ListDirTool;
780 let write_tool = WriteFileTool;
781
782 assert!(read_tool.supports_parallel());
783 assert!(list_tool.supports_parallel());
784 assert!(!write_tool.supports_parallel());
785 }
786
787 #[test]
788 fn test_input_schemas() {
789 // Verify all tools have valid JSON schemas
790 let read_schema = ReadFileTool.input_schema();
791 assert!(read_schema.get("type").is_some());
792 assert!(read_schema.get("properties").is_some());
793
794 let write_schema = WriteFileTool.input_schema();
795 let required = write_schema
796 .get("required")
797 .and_then(|value| value.as_array())
798 .expect("write schema should include required array");
799 assert!(required.iter().any(|v| v.as_str() == Some("path")));
800 assert!(required.iter().any(|v| v.as_str() == Some("content")));
801
802 let edit_schema = EditFileTool.input_schema();
803 let required = edit_schema
804 .get("required")
805 .and_then(|value| value.as_array())
806 .expect("edit schema should include required array");
807 assert_eq!(required.len(), 3);
808
809 let list_schema = ListDirTool.input_schema();
810 let required = list_schema
811 .get("required")
812 .and_then(|value| value.as_array())
813 .expect("list schema should include required array");
814 assert!(required.is_empty()); // path is optional
815 }
816 }
817
817 lines RUST