Skip to main content

hanzo_mcp/tools/
fs_tool.rs

1/// Unified filesystem tool (HIP-0300)
2///
3/// Handles all file operations:
4/// - read: Read file contents
5/// - write: Write file contents
6/// - edit: Edit file with old/new replacement
7/// - patch: Apply Rust-style patch format
8/// - tree: Display directory tree
9/// - find: Find files by pattern
10/// - search: Search file contents
11
12use 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/// Actions for the fs tool
20#[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/// Arguments for fs tool
66#[derive(Debug, Clone, Default, Serialize, Deserialize)]
67pub struct FsToolArgs {
68    #[serde(default)]
69    pub action: String,
70    /// File path
71    pub path: Option<String>,
72    /// Alias for path
73    pub file_path: Option<String>,
74    /// Content for write
75    pub content: Option<String>,
76    /// Old text for edit
77    pub old_string: Option<String>,
78    /// Alias for old_string
79    pub old_text: Option<String>,
80    /// New text for edit
81    pub new_string: Option<String>,
82    /// Alias for new_string
83    pub new_text: Option<String>,
84    /// Replace all occurrences
85    #[serde(default)]
86    pub replace_all: bool,
87    /// Patch text
88    pub patch: Option<String>,
89    /// Pattern for find/search
90    pub pattern: Option<String>,
91    /// Max depth for tree
92    pub depth: Option<usize>,
93    /// Limit results
94    pub limit: Option<usize>,
95    /// Offset for pagination
96    pub offset: Option<usize>,
97    /// Include hidden files
98    #[serde(default)]
99    pub include_hidden: bool,
100    /// Context lines for search
101    pub context: Option<usize>,
102    /// Case insensitive
103    #[serde(default)]
104    pub ignore_case: bool,
105    /// Destination path for mv
106    pub destination: Option<String>,
107    /// Confirmation for rm (required)
108    #[serde(default)]
109    pub confirm: bool,
110}
111
112/// Patch operation type
113#[derive(Debug, Clone, PartialEq)]
114pub enum PatchOp {
115    Add,
116    Update,
117    Delete,
118}
119
120/// Parsed patch file
121#[derive(Debug, Clone)]
122pub struct PatchFile {
123    pub op: PatchOp,
124    pub path: String,
125    pub hunks: Vec<PatchHunk>,
126}
127
128/// Patch hunk
129#[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
136/// File system tool
137pub 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        // Apply offset and limit
179        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        // Ensure parent directory exists
207        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        // Handle creating new file
232        if old_string.is_empty() {
233            // Create new file
234            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        // Read existing file
246        let content = tokio::fs::read_to_string(&path).await?;
247
248        // Count occurrences
249        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        // Replace
263        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                    // Create new file
292                    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                    // Context line - add to both
444                    hunk.old_lines.push(line[1..].to_string());
445                    hunk.new_lines.push(line[1..].to_string());
446                }
447            }
448        }
449
450        // Flush remaining
451        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                // Skip binary files
574                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        // Ensure destination parent directory exists
657        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/// MCP Tool Definition
734#[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, // Temp dirs start with '.' so we need to include hidden
867            ..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}