1use anyhow::{anyhow, Result};
13use serde::{Deserialize, Serialize};
14use serde_json::{json, Value};
15use std::collections::HashMap;
16use std::path::{Path, PathBuf};
17use walkdir::WalkDir;
18
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
21#[serde(rename_all = "snake_case")]
22pub enum FsAction {
23 Read,
24 Write,
25 Edit,
26 Patch,
27 Tree,
28 Find,
29 Search,
30 Info,
31 Mv,
32 Mkdir,
33 Rm,
34 Help,
35}
36
37impl Default for FsAction {
38 fn default() -> Self {
39 Self::Help
40 }
41}
42
43impl std::str::FromStr for FsAction {
44 type Err = anyhow::Error;
45
46 fn from_str(s: &str) -> Result<Self> {
47 match s.to_lowercase().as_str() {
48 "read" => Ok(Self::Read),
49 "write" => Ok(Self::Write),
50 "edit" => Ok(Self::Edit),
51 "patch" | "apply_patch" => Ok(Self::Patch),
52 "tree" | "ls" => Ok(Self::Tree),
53 "find" | "glob" => Ok(Self::Find),
54 "search" | "grep" => Ok(Self::Search),
55 "info" | "stat" => Ok(Self::Info),
56 "mv" | "move" | "rename" => Ok(Self::Mv),
57 "mkdir" => Ok(Self::Mkdir),
58 "rm" | "remove" | "delete" => Ok(Self::Rm),
59 "help" | "" => Ok(Self::Help),
60 _ => Err(anyhow!("Unknown action: {}", s)),
61 }
62 }
63}
64
65#[derive(Debug, Clone, Default, Serialize, Deserialize)]
67pub struct FsToolArgs {
68 #[serde(default)]
69 pub action: String,
70 pub path: Option<String>,
72 pub file_path: Option<String>,
74 pub content: Option<String>,
76 pub old_string: Option<String>,
78 pub old_text: Option<String>,
80 pub new_string: Option<String>,
82 pub new_text: Option<String>,
84 #[serde(default)]
86 pub replace_all: bool,
87 pub patch: Option<String>,
89 pub pattern: Option<String>,
91 pub depth: Option<usize>,
93 pub limit: Option<usize>,
95 pub offset: Option<usize>,
97 #[serde(default)]
99 pub include_hidden: bool,
100 pub context: Option<usize>,
102 #[serde(default)]
104 pub ignore_case: bool,
105 pub destination: Option<String>,
107 #[serde(default)]
109 pub confirm: bool,
110}
111
112#[derive(Debug, Clone, PartialEq)]
114pub enum PatchOp {
115 Add,
116 Update,
117 Delete,
118}
119
120#[derive(Debug, Clone)]
122pub struct PatchFile {
123 pub op: PatchOp,
124 pub path: String,
125 pub hunks: Vec<PatchHunk>,
126}
127
128#[derive(Debug, Clone)]
130pub struct PatchHunk {
131 pub context: Option<String>,
132 pub old_lines: Vec<String>,
133 pub new_lines: Vec<String>,
134}
135
136pub struct FsTool;
138
139impl FsTool {
140 pub fn new() -> Self {
141 Self
142 }
143
144 pub async fn execute(&self, args: FsToolArgs) -> Result<String> {
145 let action: FsAction = if args.action.is_empty() {
146 FsAction::Help
147 } else {
148 args.action.parse()?
149 };
150
151 let result = match action {
152 FsAction::Read => self.read(args).await?,
153 FsAction::Write => self.write(args).await?,
154 FsAction::Edit => self.edit(args).await?,
155 FsAction::Patch => self.patch(args).await?,
156 FsAction::Tree => self.tree(args).await?,
157 FsAction::Find => self.find(args).await?,
158 FsAction::Search => self.search(args).await?,
159 FsAction::Info => self.info(args).await?,
160 FsAction::Mv => self.mv(args).await?,
161 FsAction::Mkdir => self.mkdir(args).await?,
162 FsAction::Rm => self.rm(args).await?,
163 FsAction::Help => self.help()?,
164 };
165
166 Ok(serde_json::to_string(&result)?)
167 }
168
169 async fn read(&self, args: FsToolArgs) -> Result<Value> {
170 let path = args.file_path.or(args.path)
171 .ok_or_else(|| anyhow!("path required"))?;
172 let path = shellexpand::tilde(&path).to_string();
173
174 let content = tokio::fs::read_to_string(&path).await?;
175 let lines: Vec<&str> = content.lines().collect();
176 let total_lines = lines.len();
177
178 let offset = args.offset.unwrap_or(0);
180 let limit = args.limit.unwrap_or(2000);
181
182 let lines: Vec<String> = lines
183 .into_iter()
184 .skip(offset)
185 .take(limit)
186 .enumerate()
187 .map(|(i, line)| format!("{:>6}\u{2192}{}", offset + i + 1, line))
188 .collect();
189
190 Ok(json!({
191 "path": path,
192 "content": lines.join("\n"),
193 "lines": lines.len(),
194 "total_lines": total_lines,
195 "offset": offset,
196 "truncated": total_lines > offset + limit
197 }))
198 }
199
200 async fn write(&self, args: FsToolArgs) -> Result<Value> {
201 let path = args.file_path.or(args.path)
202 .ok_or_else(|| anyhow!("path required"))?;
203 let path = shellexpand::tilde(&path).to_string();
204 let content = args.content.ok_or_else(|| anyhow!("content required"))?;
205
206 if let Some(parent) = Path::new(&path).parent() {
208 tokio::fs::create_dir_all(parent).await?;
209 }
210
211 tokio::fs::write(&path, &content).await?;
212
213 Ok(json!({
214 "path": path,
215 "bytes": content.len(),
216 "lines": content.lines().count(),
217 "success": true
218 }))
219 }
220
221 async fn edit(&self, args: FsToolArgs) -> Result<Value> {
222 let path = args.file_path.or(args.path)
223 .ok_or_else(|| anyhow!("path required"))?;
224 let path = shellexpand::tilde(&path).to_string();
225
226 let old_string = args.old_string.or(args.old_text)
227 .ok_or_else(|| anyhow!("old_string required"))?;
228 let new_string = args.new_string.or(args.new_text)
229 .ok_or_else(|| anyhow!("new_string required"))?;
230
231 if old_string.is_empty() {
233 if let Some(parent) = Path::new(&path).parent() {
235 tokio::fs::create_dir_all(parent).await?;
236 }
237 tokio::fs::write(&path, &new_string).await?;
238 return Ok(json!({
239 "path": path,
240 "created": true,
241 "bytes": new_string.len()
242 }));
243 }
244
245 let content = tokio::fs::read_to_string(&path).await?;
247
248 let count = content.matches(&old_string).count();
250
251 if count == 0 {
252 return Err(anyhow!("old_string not found in file"));
253 }
254
255 if count > 1 && !args.replace_all {
256 return Err(anyhow!(
257 "old_string matches {} locations. Use replace_all=true or provide more context.",
258 count
259 ));
260 }
261
262 let new_content = if args.replace_all {
264 content.replace(&old_string, &new_string)
265 } else {
266 content.replacen(&old_string, &new_string, 1)
267 };
268
269 tokio::fs::write(&path, &new_content).await?;
270
271 Ok(json!({
272 "path": path,
273 "replacements": if args.replace_all { count } else { 1 },
274 "bytes": new_content.len(),
275 "success": true
276 }))
277 }
278
279 async fn patch(&self, args: FsToolArgs) -> Result<Value> {
280 let patch_text = args.patch.or(args.content)
281 .ok_or_else(|| anyhow!("patch required"))?;
282
283 let patches = self.parse_patch(&patch_text)?;
284 let mut results = Vec::new();
285
286 for patch_file in patches {
287 let path = shellexpand::tilde(&patch_file.path).to_string();
288
289 match patch_file.op {
290 PatchOp::Add => {
291 if let Some(parent) = Path::new(&path).parent() {
293 tokio::fs::create_dir_all(parent).await?;
294 }
295 let content: String = patch_file.hunks
296 .iter()
297 .flat_map(|h| &h.new_lines)
298 .cloned()
299 .collect::<Vec<_>>()
300 .join("\n");
301 tokio::fs::write(&path, &content).await?;
302 results.push(json!({
303 "path": path,
304 "op": "add",
305 "success": true
306 }));
307 }
308 PatchOp::Delete => {
309 tokio::fs::remove_file(&path).await?;
310 results.push(json!({
311 "path": path,
312 "op": "delete",
313 "success": true
314 }));
315 }
316 PatchOp::Update => {
317 let mut content = tokio::fs::read_to_string(&path).await?;
318
319 for hunk in &patch_file.hunks {
320 let old_text = hunk.old_lines.join("\n");
321 let new_text = hunk.new_lines.join("\n");
322
323 if !content.contains(&old_text) {
324 return Err(anyhow!("Hunk not found in {}", path));
325 }
326
327 content = content.replacen(&old_text, &new_text, 1);
328 }
329
330 tokio::fs::write(&path, &content).await?;
331 results.push(json!({
332 "path": path,
333 "op": "update",
334 "hunks": patch_file.hunks.len(),
335 "success": true
336 }));
337 }
338 }
339 }
340
341 Ok(json!({
342 "applied": results.len(),
343 "results": results
344 }))
345 }
346
347 fn parse_patch(&self, text: &str) -> Result<Vec<PatchFile>> {
348 let mut patches = Vec::new();
349 let mut current: Option<PatchFile> = None;
350 let mut current_hunk: Option<PatchHunk> = None;
351
352 for line in text.lines() {
353 if line.starts_with("*** Begin Patch") {
354 continue;
355 }
356 if line.starts_with("*** End Patch") {
357 if let Some(mut hunk) = current_hunk.take() {
358 if let Some(ref mut patch) = current {
359 patch.hunks.push(hunk);
360 }
361 }
362 if let Some(patch) = current.take() {
363 patches.push(patch);
364 }
365 continue;
366 }
367 if line.starts_with("*** Add File:") {
368 if let Some(mut hunk) = current_hunk.take() {
369 if let Some(ref mut patch) = current {
370 patch.hunks.push(hunk);
371 }
372 }
373 if let Some(patch) = current.take() {
374 patches.push(patch);
375 }
376 let path = line.trim_start_matches("*** Add File:").trim().to_string();
377 current = Some(PatchFile {
378 op: PatchOp::Add,
379 path,
380 hunks: Vec::new(),
381 });
382 current_hunk = Some(PatchHunk {
383 context: None,
384 old_lines: Vec::new(),
385 new_lines: Vec::new(),
386 });
387 continue;
388 }
389 if line.starts_with("*** Update File:") {
390 if let Some(mut hunk) = current_hunk.take() {
391 if let Some(ref mut patch) = current {
392 patch.hunks.push(hunk);
393 }
394 }
395 if let Some(patch) = current.take() {
396 patches.push(patch);
397 }
398 let path = line.trim_start_matches("*** Update File:").trim().to_string();
399 current = Some(PatchFile {
400 op: PatchOp::Update,
401 path,
402 hunks: Vec::new(),
403 });
404 continue;
405 }
406 if line.starts_with("*** Delete File:") {
407 if let Some(mut hunk) = current_hunk.take() {
408 if let Some(ref mut patch) = current {
409 patch.hunks.push(hunk);
410 }
411 }
412 if let Some(patch) = current.take() {
413 patches.push(patch);
414 }
415 let path = line.trim_start_matches("*** Delete File:").trim().to_string();
416 current = Some(PatchFile {
417 op: PatchOp::Delete,
418 path,
419 hunks: Vec::new(),
420 });
421 continue;
422 }
423 if line.starts_with("@@") {
424 if let Some(hunk) = current_hunk.take() {
425 if let Some(ref mut patch) = current {
426 patch.hunks.push(hunk);
427 }
428 }
429 current_hunk = Some(PatchHunk {
430 context: Some(line.to_string()),
431 old_lines: Vec::new(),
432 new_lines: Vec::new(),
433 });
434 continue;
435 }
436
437 if let Some(ref mut hunk) = current_hunk {
438 if line.starts_with('+') {
439 hunk.new_lines.push(line[1..].to_string());
440 } else if line.starts_with('-') {
441 hunk.old_lines.push(line[1..].to_string());
442 } else if line.starts_with(' ') {
443 hunk.old_lines.push(line[1..].to_string());
445 hunk.new_lines.push(line[1..].to_string());
446 }
447 }
448 }
449
450 if let Some(mut hunk) = current_hunk.take() {
452 if let Some(ref mut patch) = current {
453 patch.hunks.push(hunk);
454 }
455 }
456 if let Some(patch) = current.take() {
457 patches.push(patch);
458 }
459
460 Ok(patches)
461 }
462
463 async fn tree(&self, args: FsToolArgs) -> Result<Value> {
464 let path = args.path.unwrap_or_else(|| ".".to_string());
465 let path = shellexpand::tilde(&path).to_string();
466 let depth = args.depth.unwrap_or(3);
467 let include_hidden = args.include_hidden;
468
469 let mut entries = Vec::new();
470 let mut dirs = 0;
471 let mut files = 0;
472
473 for entry in WalkDir::new(&path)
474 .max_depth(depth)
475 .into_iter()
476 .filter_entry(|e| {
477 include_hidden || !e.file_name().to_string_lossy().starts_with('.')
478 })
479 {
480 if let Ok(entry) = entry {
481 let relative = entry.path().strip_prefix(&path).unwrap_or(entry.path());
482 let depth = relative.components().count();
483 let prefix = " ".repeat(depth);
484 let name = entry.file_name().to_string_lossy();
485
486 if entry.file_type().is_dir() {
487 dirs += 1;
488 entries.push(format!("{}\u{251c}\u{2500} {}/", prefix, name));
489 } else {
490 files += 1;
491 entries.push(format!("{}\u{251c}\u{2500} {}", prefix, name));
492 }
493 }
494 }
495
496 Ok(json!({
497 "path": path,
498 "tree": entries.join("\n"),
499 "directories": dirs,
500 "files": files
501 }))
502 }
503
504 async fn find(&self, args: FsToolArgs) -> Result<Value> {
505 let path = args.path.unwrap_or_else(|| ".".to_string());
506 let path = shellexpand::tilde(&path).to_string();
507 let pattern = args.pattern.ok_or_else(|| anyhow!("pattern required"))?;
508 let limit = args.limit.unwrap_or(100);
509 let include_hidden = args.include_hidden;
510
511 let glob = glob::Pattern::new(&pattern)?;
512 let mut matches = Vec::new();
513
514 for entry in WalkDir::new(&path)
515 .into_iter()
516 .filter_entry(|e| {
517 include_hidden || !e.file_name().to_string_lossy().starts_with('.')
518 })
519 {
520 if matches.len() >= limit {
521 break;
522 }
523
524 if let Ok(entry) = entry {
525 let name = entry.file_name().to_string_lossy();
526 if glob.matches(&name) {
527 matches.push(entry.path().to_string_lossy().to_string());
528 }
529 }
530 }
531
532 Ok(json!({
533 "path": path,
534 "pattern": pattern,
535 "matches": matches,
536 "count": matches.len(),
537 "truncated": matches.len() >= limit
538 }))
539 }
540
541 async fn search(&self, args: FsToolArgs) -> Result<Value> {
542 let path = args.path.unwrap_or_else(|| ".".to_string());
543 let path = shellexpand::tilde(&path).to_string();
544 let pattern = args.pattern.ok_or_else(|| anyhow!("pattern required"))?;
545 let limit = args.limit.unwrap_or(50);
546 let context = args.context.unwrap_or(2);
547 let ignore_case = args.ignore_case;
548 let include_hidden = args.include_hidden;
549
550 let regex = if ignore_case {
551 regex::RegexBuilder::new(&pattern)
552 .case_insensitive(true)
553 .build()?
554 } else {
555 regex::Regex::new(&pattern)?
556 };
557
558 let mut results = Vec::new();
559
560 for entry in WalkDir::new(&path)
561 .into_iter()
562 .filter_entry(|e| include_hidden || !e.file_name().to_string_lossy().starts_with('.'))
563 {
564 if results.len() >= limit {
565 break;
566 }
567
568 if let Ok(entry) = entry {
569 if !entry.file_type().is_file() {
570 continue;
571 }
572
573 let path_str = entry.path().to_string_lossy();
575 if path_str.ends_with(".exe") || path_str.ends_with(".bin") ||
576 path_str.ends_with(".so") || path_str.ends_with(".dylib") {
577 continue;
578 }
579
580 if let Ok(content) = tokio::fs::read_to_string(entry.path()).await {
581 let lines: Vec<&str> = content.lines().collect();
582 for (i, line) in lines.iter().enumerate() {
583 if regex.is_match(line) {
584 let start = i.saturating_sub(context);
585 let end = (i + context + 1).min(lines.len());
586 let context_lines: Vec<String> = lines[start..end]
587 .iter()
588 .enumerate()
589 .map(|(j, l)| format!("{:>4}:{}", start + j + 1, l))
590 .collect();
591
592 results.push(json!({
593 "file": path_str,
594 "line": i + 1,
595 "match": line,
596 "context": context_lines.join("\n")
597 }));
598
599 if results.len() >= limit {
600 break;
601 }
602 }
603 }
604 }
605 }
606 }
607
608 Ok(json!({
609 "pattern": pattern,
610 "path": path,
611 "results": results,
612 "count": results.len(),
613 "truncated": results.len() >= limit
614 }))
615 }
616
617 async fn info(&self, args: FsToolArgs) -> Result<Value> {
618 let path = args.file_path.or(args.path)
619 .ok_or_else(|| anyhow!("path required"))?;
620 let path = shellexpand::tilde(&path).to_string();
621
622 let metadata = tokio::fs::metadata(&path).await?;
623 let file_type = if metadata.is_dir() {
624 "directory"
625 } else if metadata.is_file() {
626 "file"
627 } else if metadata.is_symlink() {
628 "symlink"
629 } else {
630 "unknown"
631 };
632
633 Ok(json!({
634 "path": path,
635 "type": file_type,
636 "size": metadata.len(),
637 "readonly": metadata.permissions().readonly(),
638 "modified": metadata.modified().ok().map(|t| {
639 chrono::DateTime::<chrono::Utc>::from(t).to_rfc3339()
640 })
641 }))
642 }
643
644 async fn mv(&self, args: FsToolArgs) -> Result<Value> {
645 let source = args.file_path.or(args.path)
646 .ok_or_else(|| anyhow!("path required"))?;
647 let source = shellexpand::tilde(&source).to_string();
648 let destination = args.destination
649 .ok_or_else(|| anyhow!("destination required"))?;
650 let destination = shellexpand::tilde(&destination).to_string();
651
652 if !Path::new(&source).exists() {
653 return Err(anyhow!("Source not found: {}", source));
654 }
655
656 if let Some(parent) = Path::new(&destination).parent() {
658 tokio::fs::create_dir_all(parent).await?;
659 }
660
661 tokio::fs::rename(&source, &destination).await?;
662
663 Ok(json!({
664 "source": source,
665 "destination": destination,
666 "moved": true
667 }))
668 }
669
670 async fn mkdir(&self, args: FsToolArgs) -> Result<Value> {
671 let path = args.file_path.or(args.path)
672 .ok_or_else(|| anyhow!("path required"))?;
673 let path = shellexpand::tilde(&path).to_string();
674
675 let p = Path::new(&path);
676 if p.exists() {
677 if p.is_dir() {
678 return Ok(json!({ "path": path, "created": false }));
679 }
680 return Err(anyhow!("Path exists and is not a directory: {}", path));
681 }
682
683 tokio::fs::create_dir_all(&path).await?;
684
685 Ok(json!({ "path": path, "created": true }))
686 }
687
688 async fn rm(&self, args: FsToolArgs) -> Result<Value> {
689 if !args.confirm {
690 return Err(anyhow!("rm requires confirm=true for safety"));
691 }
692
693 let path = args.file_path.or(args.path)
694 .ok_or_else(|| anyhow!("path required"))?;
695 let path = shellexpand::tilde(&path).to_string();
696
697 let p = Path::new(&path);
698 if !p.exists() {
699 return Err(anyhow!("Path not found: {}", path));
700 }
701
702 if p.is_file() {
703 tokio::fs::remove_file(&path).await?;
704 } else {
705 tokio::fs::remove_dir_all(&path).await?;
706 }
707
708 Ok(json!({ "path": path, "removed": true }))
709 }
710
711 fn help(&self) -> Result<Value> {
712 Ok(json!({
713 "name": "fs",
714 "version": "0.12.0",
715 "description": "Unified filesystem tool (HIP-0300)",
716 "actions": {
717 "read": "Read file contents",
718 "write": "Write file contents",
719 "edit": "Edit file with old/new replacement",
720 "patch": "Apply Rust-style patch format",
721 "tree": "Display directory tree",
722 "find": "Find files by pattern",
723 "search": "Search file contents",
724 "info": "Get file info",
725 "mv": "Move or rename file/directory",
726 "mkdir": "Create directory",
727 "rm": "Remove file or directory (requires confirm=true)"
728 }
729 }))
730 }
731}
732
733#[derive(Debug, Serialize, Deserialize)]
735pub struct FsToolDefinition {
736 pub name: String,
737 pub description: String,
738 pub input_schema: Value,
739}
740
741impl FsToolDefinition {
742 pub fn new() -> Self {
743 Self {
744 name: "fs".to_string(),
745 description: r#"Unified filesystem tool (HIP-0300).
746
747Actions:
748- read: Read file contents
749- write: Write file contents
750- edit: Edit file with old/new replacement
751- patch: Apply Rust-style patch format
752- tree: Display directory tree
753- find: Find files by pattern
754- search: Search file contents
755- info: Get file info
756- mv: Move or rename file/directory
757- mkdir: Create directory
758- rm: Remove file or directory (requires confirm=true)"#.to_string(),
759 input_schema: json!({
760 "type": "object",
761 "properties": {
762 "action": {
763 "type": "string",
764 "enum": ["read", "write", "edit", "patch", "tree", "find", "search", "info", "mv", "mkdir", "rm", "help"],
765 "default": "help"
766 },
767 "path": {"type": "string", "description": "File or directory path"},
768 "file_path": {"type": "string", "description": "Alias for path"},
769 "content": {"type": "string", "description": "Content for write"},
770 "old_string": {"type": "string", "description": "Text to replace"},
771 "new_string": {"type": "string", "description": "Replacement text"},
772 "replace_all": {"type": "boolean", "description": "Replace all occurrences", "default": false},
773 "patch": {"type": "string", "description": "Patch text"},
774 "pattern": {"type": "string", "description": "Pattern for find/search"},
775 "depth": {"type": "integer", "description": "Max depth for tree"},
776 "limit": {"type": "integer", "description": "Limit results"},
777 "offset": {"type": "integer", "description": "Offset for pagination"},
778 "include_hidden": {"type": "boolean", "description": "Include hidden files", "default": false},
779 "context": {"type": "integer", "description": "Context lines for search"},
780 "ignore_case": {"type": "boolean", "description": "Case insensitive search", "default": false},
781 "destination": {"type": "string", "description": "Destination path for mv"},
782 "confirm": {"type": "boolean", "description": "Confirmation required for rm", "default": false}
783 }
784 }),
785 }
786 }
787}
788
789#[cfg(test)]
790mod tests {
791 use super::*;
792 use tempfile::TempDir;
793
794 #[tokio::test]
795 async fn test_read_file() {
796 let dir = TempDir::new().unwrap();
797 let file_path = dir.path().join("test.txt");
798 std::fs::write(&file_path, "hello\nworld").unwrap();
799
800 let tool = FsTool::new();
801 let args = FsToolArgs {
802 action: "read".to_string(),
803 path: Some(file_path.to_string_lossy().to_string()),
804 ..Default::default()
805 };
806
807 let result = tool.execute(args).await;
808 assert!(result.is_ok());
809 let output = result.unwrap();
810 assert!(output.contains("hello"));
811 assert!(output.contains("world"));
812 }
813
814 #[tokio::test]
815 async fn test_write_file() {
816 let dir = TempDir::new().unwrap();
817 let file_path = dir.path().join("new.txt");
818
819 let tool = FsTool::new();
820 let args = FsToolArgs {
821 action: "write".to_string(),
822 path: Some(file_path.to_string_lossy().to_string()),
823 content: Some("test content".to_string()),
824 ..Default::default()
825 };
826
827 let result = tool.execute(args).await;
828 assert!(result.is_ok());
829
830 let content = std::fs::read_to_string(&file_path).unwrap();
831 assert_eq!(content, "test content");
832 }
833
834 #[tokio::test]
835 async fn test_edit_file() {
836 let dir = TempDir::new().unwrap();
837 let file_path = dir.path().join("edit.txt");
838 std::fs::write(&file_path, "hello world").unwrap();
839
840 let tool = FsTool::new();
841 let args = FsToolArgs {
842 action: "edit".to_string(),
843 path: Some(file_path.to_string_lossy().to_string()),
844 old_string: Some("world".to_string()),
845 new_string: Some("rust".to_string()),
846 ..Default::default()
847 };
848
849 let result = tool.execute(args).await;
850 assert!(result.is_ok());
851
852 let content = std::fs::read_to_string(&file_path).unwrap();
853 assert_eq!(content, "hello rust");
854 }
855
856 #[tokio::test]
857 async fn test_tree() {
858 let dir = TempDir::new().unwrap();
859 std::fs::create_dir(dir.path().join("subdir")).unwrap();
860 std::fs::write(dir.path().join("file.txt"), "").unwrap();
861
862 let tool = FsTool::new();
863 let args = FsToolArgs {
864 action: "tree".to_string(),
865 path: Some(dir.path().to_string_lossy().to_string()),
866 include_hidden: true, ..Default::default()
868 };
869
870 let result = tool.execute(args).await;
871 assert!(result.is_ok());
872 let output = result.unwrap();
873 assert!(output.contains("subdir"), "Missing subdir in: {}", output);
874 assert!(output.contains("file.txt"), "Missing file.txt in: {}", output);
875 }
876
877 #[tokio::test]
878 async fn test_mv_file() {
879 let dir = TempDir::new().unwrap();
880 let src = dir.path().join("src.txt");
881 let dst = dir.path().join("nested/dst.txt");
882 std::fs::write(&src, "payload").unwrap();
883
884 let tool = FsTool::new();
885 let args = FsToolArgs {
886 action: "mv".to_string(),
887 path: Some(src.to_string_lossy().to_string()),
888 destination: Some(dst.to_string_lossy().to_string()),
889 ..Default::default()
890 };
891
892 let result = tool.execute(args).await;
893 assert!(result.is_ok(), "{:?}", result);
894 assert!(!src.exists());
895 assert_eq!(std::fs::read_to_string(&dst).unwrap(), "payload");
896 }
897
898 #[tokio::test]
899 async fn test_mkdir() {
900 let dir = TempDir::new().unwrap();
901 let target = dir.path().join("a/b/c");
902
903 let tool = FsTool::new();
904 let args = FsToolArgs {
905 action: "mkdir".to_string(),
906 path: Some(target.to_string_lossy().to_string()),
907 ..Default::default()
908 };
909
910 let result = tool.execute(args).await;
911 assert!(result.is_ok());
912 assert!(target.is_dir());
913 assert!(result.unwrap().contains("\"created\":true"));
914 }
915
916 #[tokio::test]
917 async fn test_rm_requires_confirm() {
918 let dir = TempDir::new().unwrap();
919 let file = dir.path().join("gone.txt");
920 std::fs::write(&file, "x").unwrap();
921
922 let tool = FsTool::new();
923 let no_confirm = FsToolArgs {
924 action: "rm".to_string(),
925 path: Some(file.to_string_lossy().to_string()),
926 ..Default::default()
927 };
928 assert!(tool.execute(no_confirm).await.is_err());
929 assert!(file.exists());
930
931 let confirmed = FsToolArgs {
932 action: "rm".to_string(),
933 path: Some(file.to_string_lossy().to_string()),
934 confirm: true,
935 ..Default::default()
936 };
937 assert!(tool.execute(confirmed).await.is_ok());
938 assert!(!file.exists());
939 }
940
941 #[tokio::test]
942 async fn test_help() {
943 let tool = FsTool::new();
944 let args = FsToolArgs {
945 action: "help".to_string(),
946 ..Default::default()
947 };
948
949 let result = tool.execute(args).await;
950 assert!(result.is_ok());
951 let output = result.unwrap();
952 assert!(output.contains("fs"));
953 assert!(output.contains("read"));
954 assert!(output.contains("write"));
955 }
956}