Skip to main content

zeph_tools/
file.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::path::{Path, PathBuf};
5
6use schemars::JsonSchema;
7use serde::Deserialize;
8
9use crate::executor::{
10    DiffData, ToolCall, ToolError, ToolExecutor, ToolOutput, deserialize_params,
11};
12use crate::registry::{InvocationHint, ToolDef};
13
14#[derive(Deserialize, JsonSchema)]
15pub(crate) struct ReadParams {
16    /// File path
17    path: String,
18    /// Line offset
19    offset: Option<u32>,
20    /// Max lines
21    limit: Option<u32>,
22}
23
24#[derive(Deserialize, JsonSchema)]
25struct WriteParams {
26    /// File path
27    path: String,
28    /// Content to write
29    content: String,
30}
31
32#[derive(Deserialize, JsonSchema)]
33struct EditParams {
34    /// File path
35    path: String,
36    /// Text to find
37    old_string: String,
38    /// Replacement text
39    new_string: String,
40}
41
42#[derive(Deserialize, JsonSchema)]
43struct FindPathParams {
44    /// Glob pattern
45    pattern: String,
46}
47
48#[derive(Deserialize, JsonSchema)]
49struct GrepParams {
50    /// Regex pattern
51    pattern: String,
52    /// Search path
53    path: Option<String>,
54    /// Case sensitive
55    case_sensitive: Option<bool>,
56}
57
58#[derive(Deserialize, JsonSchema)]
59struct ListDirectoryParams {
60    /// Directory path
61    path: String,
62}
63
64#[derive(Deserialize, JsonSchema)]
65struct CreateDirectoryParams {
66    /// Directory path to create (including parents)
67    path: String,
68}
69
70#[derive(Deserialize, JsonSchema)]
71struct DeletePathParams {
72    /// Path to delete
73    path: String,
74    /// Delete non-empty directories recursively
75    #[serde(default)]
76    recursive: bool,
77}
78
79#[derive(Deserialize, JsonSchema)]
80struct MovePathParams {
81    /// Source path
82    source: String,
83    /// Destination path
84    destination: String,
85}
86
87#[derive(Deserialize, JsonSchema)]
88struct CopyPathParams {
89    /// Source path
90    source: String,
91    /// Destination path
92    destination: String,
93}
94
95/// File operations executor sandboxed to allowed paths.
96#[derive(Debug)]
97pub struct FileExecutor {
98    allowed_paths: Vec<PathBuf>,
99}
100
101impl FileExecutor {
102    #[must_use]
103    pub fn new(allowed_paths: Vec<PathBuf>) -> Self {
104        let paths = if allowed_paths.is_empty() {
105            vec![std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))]
106        } else {
107            allowed_paths
108        };
109        Self {
110            allowed_paths: paths
111                .into_iter()
112                .map(|p| p.canonicalize().unwrap_or(p))
113                .collect(),
114        }
115    }
116
117    fn validate_path(&self, path: &Path) -> Result<PathBuf, ToolError> {
118        let resolved = if path.is_absolute() {
119            path.to_path_buf()
120        } else {
121            std::env::current_dir()
122                .unwrap_or_else(|_| PathBuf::from("."))
123                .join(path)
124        };
125        let canonical = resolve_via_ancestors(&resolved);
126        if !self.allowed_paths.iter().any(|a| canonical.starts_with(a)) {
127            return Err(ToolError::SandboxViolation {
128                path: canonical.display().to_string(),
129            });
130        }
131        Ok(canonical)
132    }
133
134    /// Execute a tool call by `tool_id` and params.
135    ///
136    /// # Errors
137    ///
138    /// Returns `ToolError` on sandbox violations or I/O failures.
139    pub fn execute_file_tool(
140        &self,
141        tool_id: &str,
142        params: &serde_json::Map<String, serde_json::Value>,
143    ) -> Result<Option<ToolOutput>, ToolError> {
144        match tool_id {
145            "read" => {
146                let p: ReadParams = deserialize_params(params)?;
147                self.handle_read(&p)
148            }
149            "write" => {
150                let p: WriteParams = deserialize_params(params)?;
151                self.handle_write(&p)
152            }
153            "edit" => {
154                let p: EditParams = deserialize_params(params)?;
155                self.handle_edit(&p)
156            }
157            "find_path" => {
158                let p: FindPathParams = deserialize_params(params)?;
159                self.handle_find_path(&p)
160            }
161            "grep" => {
162                let p: GrepParams = deserialize_params(params)?;
163                self.handle_grep(&p)
164            }
165            "list_directory" => {
166                let p: ListDirectoryParams = deserialize_params(params)?;
167                self.handle_list_directory(&p)
168            }
169            "create_directory" => {
170                let p: CreateDirectoryParams = deserialize_params(params)?;
171                self.handle_create_directory(&p)
172            }
173            "delete_path" => {
174                let p: DeletePathParams = deserialize_params(params)?;
175                self.handle_delete_path(&p)
176            }
177            "move_path" => {
178                let p: MovePathParams = deserialize_params(params)?;
179                self.handle_move_path(&p)
180            }
181            "copy_path" => {
182                let p: CopyPathParams = deserialize_params(params)?;
183                self.handle_copy_path(&p)
184            }
185            _ => Ok(None),
186        }
187    }
188
189    fn handle_read(&self, params: &ReadParams) -> Result<Option<ToolOutput>, ToolError> {
190        let path = self.validate_path(Path::new(&params.path))?;
191        let content = std::fs::read_to_string(&path)?;
192
193        let offset = params.offset.unwrap_or(0) as usize;
194        let limit = params.limit.map_or(usize::MAX, |l| l as usize);
195
196        let selected: Vec<String> = content
197            .lines()
198            .skip(offset)
199            .take(limit)
200            .enumerate()
201            .map(|(i, line)| format!("{:>4}\t{line}", offset + i + 1))
202            .collect();
203
204        Ok(Some(ToolOutput {
205            tool_name: "read".to_owned(),
206            summary: selected.join("\n"),
207            blocks_executed: 1,
208            filter_stats: None,
209            diff: None,
210            streamed: false,
211            terminal_id: None,
212            locations: None,
213            raw_response: None,
214        }))
215    }
216
217    fn handle_write(&self, params: &WriteParams) -> Result<Option<ToolOutput>, ToolError> {
218        let path = self.validate_path(Path::new(&params.path))?;
219        let old_content = std::fs::read_to_string(&path).unwrap_or_default();
220
221        if let Some(parent) = path.parent() {
222            std::fs::create_dir_all(parent)?;
223        }
224        std::fs::write(&path, &params.content)?;
225
226        Ok(Some(ToolOutput {
227            tool_name: "write".to_owned(),
228            summary: format!("Wrote {} bytes to {}", params.content.len(), params.path),
229            blocks_executed: 1,
230            filter_stats: None,
231            diff: Some(DiffData {
232                file_path: params.path.clone(),
233                old_content,
234                new_content: params.content.clone(),
235            }),
236            streamed: false,
237            terminal_id: None,
238            locations: None,
239            raw_response: None,
240        }))
241    }
242
243    fn handle_edit(&self, params: &EditParams) -> Result<Option<ToolOutput>, ToolError> {
244        let path = self.validate_path(Path::new(&params.path))?;
245        let content = std::fs::read_to_string(&path)?;
246
247        if !content.contains(&params.old_string) {
248            return Err(ToolError::Execution(std::io::Error::new(
249                std::io::ErrorKind::NotFound,
250                format!("old_string not found in {}", params.path),
251            )));
252        }
253
254        let new_content = content.replacen(&params.old_string, &params.new_string, 1);
255        std::fs::write(&path, &new_content)?;
256
257        Ok(Some(ToolOutput {
258            tool_name: "edit".to_owned(),
259            summary: format!("Edited {}", params.path),
260            blocks_executed: 1,
261            filter_stats: None,
262            diff: Some(DiffData {
263                file_path: params.path.clone(),
264                old_content: content,
265                new_content,
266            }),
267            streamed: false,
268            terminal_id: None,
269            locations: None,
270            raw_response: None,
271        }))
272    }
273
274    fn handle_find_path(&self, params: &FindPathParams) -> Result<Option<ToolOutput>, ToolError> {
275        let matches: Vec<String> = glob::glob(&params.pattern)
276            .map_err(|e| {
277                ToolError::Execution(std::io::Error::new(
278                    std::io::ErrorKind::InvalidInput,
279                    e.to_string(),
280                ))
281            })?
282            .filter_map(Result::ok)
283            .filter(|p| {
284                let canonical = p.canonicalize().unwrap_or_else(|_| p.clone());
285                self.allowed_paths.iter().any(|a| canonical.starts_with(a))
286            })
287            .map(|p| p.display().to_string())
288            .collect();
289
290        Ok(Some(ToolOutput {
291            tool_name: "find_path".to_owned(),
292            summary: if matches.is_empty() {
293                format!("No files matching: {}", params.pattern)
294            } else {
295                matches.join("\n")
296            },
297            blocks_executed: 1,
298            filter_stats: None,
299            diff: None,
300            streamed: false,
301            terminal_id: None,
302            locations: None,
303            raw_response: None,
304        }))
305    }
306
307    fn handle_grep(&self, params: &GrepParams) -> Result<Option<ToolOutput>, ToolError> {
308        let search_path = params.path.as_deref().unwrap_or(".");
309        let case_sensitive = params.case_sensitive.unwrap_or(true);
310        let path = self.validate_path(Path::new(search_path))?;
311
312        let regex = if case_sensitive {
313            regex::Regex::new(&params.pattern)
314        } else {
315            regex::RegexBuilder::new(&params.pattern)
316                .case_insensitive(true)
317                .build()
318        }
319        .map_err(|e| {
320            ToolError::Execution(std::io::Error::new(
321                std::io::ErrorKind::InvalidInput,
322                e.to_string(),
323            ))
324        })?;
325
326        let mut results = Vec::new();
327        grep_recursive(&path, &regex, &mut results, 100)?;
328
329        Ok(Some(ToolOutput {
330            tool_name: "grep".to_owned(),
331            summary: if results.is_empty() {
332                format!("No matches for: {}", params.pattern)
333            } else {
334                results.join("\n")
335            },
336            blocks_executed: 1,
337            filter_stats: None,
338            diff: None,
339            streamed: false,
340            terminal_id: None,
341            locations: None,
342            raw_response: None,
343        }))
344    }
345
346    fn handle_list_directory(
347        &self,
348        params: &ListDirectoryParams,
349    ) -> Result<Option<ToolOutput>, ToolError> {
350        let path = self.validate_path(Path::new(&params.path))?;
351
352        if !path.is_dir() {
353            return Err(ToolError::Execution(std::io::Error::new(
354                std::io::ErrorKind::NotADirectory,
355                format!("{} is not a directory", params.path),
356            )));
357        }
358
359        let mut dirs = Vec::new();
360        let mut files = Vec::new();
361        let mut symlinks = Vec::new();
362
363        for entry in std::fs::read_dir(&path)? {
364            let entry = entry?;
365            let name = entry.file_name().to_string_lossy().into_owned();
366            // Use symlink_metadata (lstat) to detect symlinks without following them.
367            let meta = std::fs::symlink_metadata(entry.path())?;
368            if meta.is_symlink() {
369                symlinks.push(format!("[symlink] {name}"));
370            } else if meta.is_dir() {
371                dirs.push(format!("[dir]  {name}"));
372            } else {
373                files.push(format!("[file] {name}"));
374            }
375        }
376
377        dirs.sort();
378        files.sort();
379        symlinks.sort();
380
381        let mut entries = dirs;
382        entries.extend(files);
383        entries.extend(symlinks);
384
385        Ok(Some(ToolOutput {
386            tool_name: "list_directory".to_owned(),
387            summary: if entries.is_empty() {
388                format!("Empty directory: {}", params.path)
389            } else {
390                entries.join("\n")
391            },
392            blocks_executed: 1,
393            filter_stats: None,
394            diff: None,
395            streamed: false,
396            terminal_id: None,
397            locations: None,
398            raw_response: None,
399        }))
400    }
401
402    fn handle_create_directory(
403        &self,
404        params: &CreateDirectoryParams,
405    ) -> Result<Option<ToolOutput>, ToolError> {
406        let path = self.validate_path(Path::new(&params.path))?;
407        std::fs::create_dir_all(&path)?;
408
409        Ok(Some(ToolOutput {
410            tool_name: "create_directory".to_owned(),
411            summary: format!("Created directory: {}", params.path),
412            blocks_executed: 1,
413            filter_stats: None,
414            diff: None,
415            streamed: false,
416            terminal_id: None,
417            locations: None,
418            raw_response: None,
419        }))
420    }
421
422    fn handle_delete_path(
423        &self,
424        params: &DeletePathParams,
425    ) -> Result<Option<ToolOutput>, ToolError> {
426        let path = self.validate_path(Path::new(&params.path))?;
427
428        // Refuse to delete the sandbox root itself
429        if self.allowed_paths.iter().any(|a| &path == a) {
430            return Err(ToolError::SandboxViolation {
431                path: path.display().to_string(),
432            });
433        }
434
435        if path.is_dir() {
436            if params.recursive {
437                // Accepted risk: remove_dir_all has no depth/size guard within the sandbox.
438                // Resource exhaustion is bounded by the filesystem and OS limits.
439                std::fs::remove_dir_all(&path)?;
440            } else {
441                // remove_dir only succeeds on empty dirs
442                std::fs::remove_dir(&path)?;
443            }
444        } else {
445            std::fs::remove_file(&path)?;
446        }
447
448        Ok(Some(ToolOutput {
449            tool_name: "delete_path".to_owned(),
450            summary: format!("Deleted: {}", params.path),
451            blocks_executed: 1,
452            filter_stats: None,
453            diff: None,
454            streamed: false,
455            terminal_id: None,
456            locations: None,
457            raw_response: None,
458        }))
459    }
460
461    fn handle_move_path(&self, params: &MovePathParams) -> Result<Option<ToolOutput>, ToolError> {
462        let src = self.validate_path(Path::new(&params.source))?;
463        let dst = self.validate_path(Path::new(&params.destination))?;
464        std::fs::rename(&src, &dst)?;
465
466        Ok(Some(ToolOutput {
467            tool_name: "move_path".to_owned(),
468            summary: format!("Moved: {} -> {}", params.source, params.destination),
469            blocks_executed: 1,
470            filter_stats: None,
471            diff: None,
472            streamed: false,
473            terminal_id: None,
474            locations: None,
475            raw_response: None,
476        }))
477    }
478
479    fn handle_copy_path(&self, params: &CopyPathParams) -> Result<Option<ToolOutput>, ToolError> {
480        let src = self.validate_path(Path::new(&params.source))?;
481        let dst = self.validate_path(Path::new(&params.destination))?;
482
483        if src.is_dir() {
484            copy_dir_recursive(&src, &dst)?;
485        } else {
486            if let Some(parent) = dst.parent() {
487                std::fs::create_dir_all(parent)?;
488            }
489            std::fs::copy(&src, &dst)?;
490        }
491
492        Ok(Some(ToolOutput {
493            tool_name: "copy_path".to_owned(),
494            summary: format!("Copied: {} -> {}", params.source, params.destination),
495            blocks_executed: 1,
496            filter_stats: None,
497            diff: None,
498            streamed: false,
499            terminal_id: None,
500            locations: None,
501            raw_response: None,
502        }))
503    }
504}
505
506impl ToolExecutor for FileExecutor {
507    async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
508        Ok(None)
509    }
510
511    async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
512        self.execute_file_tool(&call.tool_id, &call.params)
513    }
514
515    fn tool_definitions(&self) -> Vec<ToolDef> {
516        vec![
517            ToolDef {
518                id: "read".into(),
519                description: "Read file contents with line numbers.\n\nParameters: path (string, required) - absolute or relative file path; offset (integer, optional) - start line (0-based); limit (integer, optional) - max lines to return\nReturns: file content with line numbers, or error if file not found\nErrors: SandboxViolation if path outside allowed dirs; Execution if file not found or unreadable\nExample: {\"path\": \"src/main.rs\", \"offset\": 10, \"limit\": 50}".into(),
520                schema: schemars::schema_for!(ReadParams),
521                invocation: InvocationHint::ToolCall,
522            },
523            ToolDef {
524                id: "write".into(),
525                description: "Create or overwrite a file with the given content.\n\nParameters: path (string, required) - file path; content (string, required) - full file content\nReturns: confirmation message with bytes written\nErrors: SandboxViolation if path outside allowed dirs; Execution on I/O failure\nExample: {\"path\": \"output.txt\", \"content\": \"Hello, world!\"}".into(),
526                schema: schemars::schema_for!(WriteParams),
527                invocation: InvocationHint::ToolCall,
528            },
529            ToolDef {
530                id: "edit".into(),
531                description: "Find and replace a text substring in a file.\n\nParameters: path (string, required) - file path; old_string (string, required) - exact text to find; new_string (string, required) - replacement text\nReturns: confirmation with match count, or error if old_string not found\nErrors: SandboxViolation; Execution if file not found or old_string has no matches\nExample: {\"path\": \"config.toml\", \"old_string\": \"debug = true\", \"new_string\": \"debug = false\"}".into(),
532                schema: schemars::schema_for!(EditParams),
533                invocation: InvocationHint::ToolCall,
534            },
535            ToolDef {
536                id: "find_path".into(),
537                description: "Find files and directories matching a glob pattern.\n\nParameters: pattern (string, required) - glob pattern (e.g. \"**/*.rs\", \"src/*.toml\")\nReturns: newline-separated list of matching paths, or \"(no matches)\" if none found\nErrors: SandboxViolation if search root is outside allowed dirs\nExample: {\"pattern\": \"**/*.rs\"}".into(),
538                schema: schemars::schema_for!(FindPathParams),
539                invocation: InvocationHint::ToolCall,
540            },
541            ToolDef {
542                id: "grep".into(),
543                description: "Search file contents for lines matching a regex pattern.\n\nParameters: pattern (string, required) - regex pattern; path (string, optional) - directory or file to search (default: cwd); case_sensitive (boolean, optional) - default true\nReturns: matching lines with file paths and line numbers, or \"(no matches)\"\nErrors: SandboxViolation; InvalidParams if regex is invalid\nExample: {\"pattern\": \"fn main\", \"path\": \"src/\"}".into(),
544                schema: schemars::schema_for!(GrepParams),
545                invocation: InvocationHint::ToolCall,
546            },
547            ToolDef {
548                id: "list_directory".into(),
549                description: "List files and subdirectories in a directory.\n\nParameters: path (string, required) - directory path\nReturns: sorted listing with [dir]/[file] prefixes, or \"Empty directory\" if empty\nErrors: SandboxViolation; Execution if path is not a directory or does not exist\nExample: {\"path\": \"src/\"}".into(),
550                schema: schemars::schema_for!(ListDirectoryParams),
551                invocation: InvocationHint::ToolCall,
552            },
553            ToolDef {
554                id: "create_directory".into(),
555                description: "Create a directory, including any missing parent directories.\n\nParameters: path (string, required) - directory path to create\nReturns: confirmation message\nErrors: SandboxViolation; Execution on I/O failure\nExample: {\"path\": \"src/utils/helpers\"}".into(),
556                schema: schemars::schema_for!(CreateDirectoryParams),
557                invocation: InvocationHint::ToolCall,
558            },
559            ToolDef {
560                id: "delete_path".into(),
561                description: "Delete a file or directory.\n\nParameters: path (string, required) - path to delete; recursive (boolean, optional) - if true, delete non-empty directories recursively (default: false)\nReturns: confirmation message\nErrors: SandboxViolation; Execution if path not found or directory non-empty without recursive=true\nExample: {\"path\": \"tmp/old_file.txt\"}".into(),
562                schema: schemars::schema_for!(DeletePathParams),
563                invocation: InvocationHint::ToolCall,
564            },
565            ToolDef {
566                id: "move_path".into(),
567                description: "Move or rename a file or directory.\n\nParameters: source (string, required) - current path; destination (string, required) - new path\nReturns: confirmation message\nErrors: SandboxViolation if either path is outside allowed dirs; Execution if source not found\nExample: {\"source\": \"old_name.rs\", \"destination\": \"new_name.rs\"}".into(),
568                schema: schemars::schema_for!(MovePathParams),
569                invocation: InvocationHint::ToolCall,
570            },
571            ToolDef {
572                id: "copy_path".into(),
573                description: "Copy a file or directory to a new location.\n\nParameters: source (string, required) - path to copy; destination (string, required) - target path\nReturns: confirmation message\nErrors: SandboxViolation; Execution if source not found or I/O failure\nExample: {\"source\": \"template.rs\", \"destination\": \"new_module.rs\"}".into(),
574                schema: schemars::schema_for!(CopyPathParams),
575                invocation: InvocationHint::ToolCall,
576            },
577        ]
578    }
579}
580
581/// Canonicalize a path by walking up to the nearest existing ancestor.
582///
583/// Walks up `path` until an existing ancestor is found, calls `canonicalize()` on it
584/// (which follows symlinks), then re-appends the non-existing suffix. The sandbox check
585/// in `validate_path` uses `starts_with` on the resulting canonical path, so symlinks
586/// that resolve outside `allowed_paths` are correctly rejected.
587fn resolve_via_ancestors(path: &Path) -> PathBuf {
588    let mut existing = path;
589    let mut suffix = PathBuf::new();
590    while !existing.exists() {
591        if let Some(parent) = existing.parent() {
592            if let Some(name) = existing.file_name() {
593                if suffix.as_os_str().is_empty() {
594                    suffix = PathBuf::from(name);
595                } else {
596                    suffix = PathBuf::from(name).join(&suffix);
597                }
598            }
599            existing = parent;
600        } else {
601            break;
602        }
603    }
604    let base = existing.canonicalize().unwrap_or(existing.to_path_buf());
605    if suffix.as_os_str().is_empty() {
606        base
607    } else {
608        base.join(&suffix)
609    }
610}
611
612const IGNORED_DIRS: &[&str] = &[".git", "target", "node_modules", ".hg"];
613
614fn grep_recursive(
615    path: &Path,
616    regex: &regex::Regex,
617    results: &mut Vec<String>,
618    limit: usize,
619) -> Result<(), ToolError> {
620    if results.len() >= limit {
621        return Ok(());
622    }
623    if path.is_file() {
624        if let Ok(content) = std::fs::read_to_string(path) {
625            for (i, line) in content.lines().enumerate() {
626                if regex.is_match(line) {
627                    results.push(format!("{}:{}: {line}", path.display(), i + 1));
628                    if results.len() >= limit {
629                        return Ok(());
630                    }
631                }
632            }
633        }
634    } else if path.is_dir() {
635        let entries = std::fs::read_dir(path)?;
636        for entry in entries.flatten() {
637            let p = entry.path();
638            let name = p.file_name().and_then(|n| n.to_str());
639            if name.is_some_and(|n| n.starts_with('.') || IGNORED_DIRS.contains(&n)) {
640                continue;
641            }
642            grep_recursive(&p, regex, results, limit)?;
643        }
644    }
645    Ok(())
646}
647
648fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), ToolError> {
649    std::fs::create_dir_all(dst)?;
650    for entry in std::fs::read_dir(src)? {
651        let entry = entry?;
652        // Use symlink_metadata (lstat) so we classify symlinks without following them.
653        // Symlinks are skipped to prevent escaping the sandbox via a symlink pointing
654        // to a path outside allowed_paths.
655        let meta = std::fs::symlink_metadata(entry.path())?;
656        let src_path = entry.path();
657        let dst_path = dst.join(entry.file_name());
658        if meta.is_dir() {
659            copy_dir_recursive(&src_path, &dst_path)?;
660        } else if meta.is_file() {
661            std::fs::copy(&src_path, &dst_path)?;
662        }
663        // Symlinks are intentionally skipped.
664    }
665    Ok(())
666}
667
668#[cfg(test)]
669mod tests {
670    use super::*;
671    use std::fs;
672
673    fn temp_dir() -> tempfile::TempDir {
674        tempfile::tempdir().unwrap()
675    }
676
677    fn make_params(
678        pairs: &[(&str, serde_json::Value)],
679    ) -> serde_json::Map<String, serde_json::Value> {
680        pairs
681            .iter()
682            .map(|(k, v)| ((*k).to_owned(), v.clone()))
683            .collect()
684    }
685
686    #[test]
687    fn read_file() {
688        let dir = temp_dir();
689        let file = dir.path().join("test.txt");
690        fs::write(&file, "line1\nline2\nline3\n").unwrap();
691
692        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
693        let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
694        let result = exec.execute_file_tool("read", &params).unwrap().unwrap();
695        assert_eq!(result.tool_name, "read");
696        assert!(result.summary.contains("line1"));
697        assert!(result.summary.contains("line3"));
698    }
699
700    #[test]
701    fn read_with_offset_and_limit() {
702        let dir = temp_dir();
703        let file = dir.path().join("test.txt");
704        fs::write(&file, "a\nb\nc\nd\ne\n").unwrap();
705
706        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
707        let params = make_params(&[
708            ("path", serde_json::json!(file.to_str().unwrap())),
709            ("offset", serde_json::json!(1)),
710            ("limit", serde_json::json!(2)),
711        ]);
712        let result = exec.execute_file_tool("read", &params).unwrap().unwrap();
713        assert!(result.summary.contains("b"));
714        assert!(result.summary.contains("c"));
715        assert!(!result.summary.contains("a"));
716        assert!(!result.summary.contains("d"));
717    }
718
719    #[test]
720    fn write_file() {
721        let dir = temp_dir();
722        let file = dir.path().join("out.txt");
723
724        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
725        let params = make_params(&[
726            ("path", serde_json::json!(file.to_str().unwrap())),
727            ("content", serde_json::json!("hello world")),
728        ]);
729        let result = exec.execute_file_tool("write", &params).unwrap().unwrap();
730        assert!(result.summary.contains("11 bytes"));
731        assert_eq!(fs::read_to_string(&file).unwrap(), "hello world");
732    }
733
734    #[test]
735    fn edit_file() {
736        let dir = temp_dir();
737        let file = dir.path().join("edit.txt");
738        fs::write(&file, "foo bar baz").unwrap();
739
740        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
741        let params = make_params(&[
742            ("path", serde_json::json!(file.to_str().unwrap())),
743            ("old_string", serde_json::json!("bar")),
744            ("new_string", serde_json::json!("qux")),
745        ]);
746        let result = exec.execute_file_tool("edit", &params).unwrap().unwrap();
747        assert!(result.summary.contains("Edited"));
748        assert_eq!(fs::read_to_string(&file).unwrap(), "foo qux baz");
749    }
750
751    #[test]
752    fn edit_not_found() {
753        let dir = temp_dir();
754        let file = dir.path().join("edit.txt");
755        fs::write(&file, "foo bar").unwrap();
756
757        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
758        let params = make_params(&[
759            ("path", serde_json::json!(file.to_str().unwrap())),
760            ("old_string", serde_json::json!("nonexistent")),
761            ("new_string", serde_json::json!("x")),
762        ]);
763        let result = exec.execute_file_tool("edit", &params);
764        assert!(result.is_err());
765    }
766
767    #[test]
768    fn sandbox_violation() {
769        let dir = temp_dir();
770        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
771        let params = make_params(&[("path", serde_json::json!("/etc/passwd"))]);
772        let result = exec.execute_file_tool("read", &params);
773        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
774    }
775
776    #[test]
777    fn unknown_tool_returns_none() {
778        let exec = FileExecutor::new(vec![]);
779        let params = serde_json::Map::new();
780        let result = exec.execute_file_tool("unknown", &params).unwrap();
781        assert!(result.is_none());
782    }
783
784    #[test]
785    fn find_path_finds_files() {
786        let dir = temp_dir();
787        fs::write(dir.path().join("a.rs"), "").unwrap();
788        fs::write(dir.path().join("b.rs"), "").unwrap();
789
790        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
791        let pattern = format!("{}/*.rs", dir.path().display());
792        let params = make_params(&[("pattern", serde_json::json!(pattern))]);
793        let result = exec
794            .execute_file_tool("find_path", &params)
795            .unwrap()
796            .unwrap();
797        assert!(result.summary.contains("a.rs"));
798        assert!(result.summary.contains("b.rs"));
799    }
800
801    #[test]
802    fn grep_finds_matches() {
803        let dir = temp_dir();
804        fs::write(
805            dir.path().join("test.txt"),
806            "hello world\nfoo bar\nhello again\n",
807        )
808        .unwrap();
809
810        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
811        let params = make_params(&[
812            ("pattern", serde_json::json!("hello")),
813            ("path", serde_json::json!(dir.path().to_str().unwrap())),
814        ]);
815        let result = exec.execute_file_tool("grep", &params).unwrap().unwrap();
816        assert!(result.summary.contains("hello world"));
817        assert!(result.summary.contains("hello again"));
818        assert!(!result.summary.contains("foo bar"));
819    }
820
821    #[test]
822    fn write_sandbox_bypass_nonexistent_path() {
823        let dir = temp_dir();
824        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
825        let params = make_params(&[
826            ("path", serde_json::json!("/tmp/evil/escape.txt")),
827            ("content", serde_json::json!("pwned")),
828        ]);
829        let result = exec.execute_file_tool("write", &params);
830        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
831        assert!(!Path::new("/tmp/evil/escape.txt").exists());
832    }
833
834    #[test]
835    fn find_path_filters_outside_sandbox() {
836        let sandbox = temp_dir();
837        let outside = temp_dir();
838        fs::write(outside.path().join("secret.rs"), "secret").unwrap();
839
840        let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
841        let pattern = format!("{}/*.rs", outside.path().display());
842        let params = make_params(&[("pattern", serde_json::json!(pattern))]);
843        let result = exec
844            .execute_file_tool("find_path", &params)
845            .unwrap()
846            .unwrap();
847        assert!(!result.summary.contains("secret.rs"));
848    }
849
850    #[tokio::test]
851    async fn tool_executor_execute_tool_call_delegates() {
852        let dir = temp_dir();
853        let file = dir.path().join("test.txt");
854        fs::write(&file, "content").unwrap();
855
856        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
857        let call = ToolCall {
858            tool_id: "read".to_owned(),
859            params: make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]),
860        };
861        let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
862        assert_eq!(result.tool_name, "read");
863        assert!(result.summary.contains("content"));
864    }
865
866    #[test]
867    fn tool_executor_tool_definitions_lists_all() {
868        let exec = FileExecutor::new(vec![]);
869        let defs = exec.tool_definitions();
870        let ids: Vec<&str> = defs.iter().map(|d| d.id.as_ref()).collect();
871        assert!(ids.contains(&"read"));
872        assert!(ids.contains(&"write"));
873        assert!(ids.contains(&"edit"));
874        assert!(ids.contains(&"find_path"));
875        assert!(ids.contains(&"grep"));
876        assert!(ids.contains(&"list_directory"));
877        assert!(ids.contains(&"create_directory"));
878        assert!(ids.contains(&"delete_path"));
879        assert!(ids.contains(&"move_path"));
880        assert!(ids.contains(&"copy_path"));
881        assert_eq!(defs.len(), 10);
882    }
883
884    #[test]
885    fn grep_relative_path_validated() {
886        let sandbox = temp_dir();
887        let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
888        let params = make_params(&[
889            ("pattern", serde_json::json!("password")),
890            ("path", serde_json::json!("../../etc")),
891        ]);
892        let result = exec.execute_file_tool("grep", &params);
893        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
894    }
895
896    #[test]
897    fn tool_definitions_returns_ten_tools() {
898        let exec = FileExecutor::new(vec![]);
899        let defs = exec.tool_definitions();
900        assert_eq!(defs.len(), 10);
901        let ids: Vec<&str> = defs.iter().map(|d| d.id.as_ref()).collect();
902        assert_eq!(
903            ids,
904            vec![
905                "read",
906                "write",
907                "edit",
908                "find_path",
909                "grep",
910                "list_directory",
911                "create_directory",
912                "delete_path",
913                "move_path",
914                "copy_path",
915            ]
916        );
917    }
918
919    #[test]
920    fn tool_definitions_all_use_tool_call() {
921        let exec = FileExecutor::new(vec![]);
922        for def in exec.tool_definitions() {
923            assert_eq!(def.invocation, InvocationHint::ToolCall);
924        }
925    }
926
927    #[test]
928    fn tool_definitions_read_schema_has_params() {
929        let exec = FileExecutor::new(vec![]);
930        let defs = exec.tool_definitions();
931        let read = defs.iter().find(|d| d.id.as_ref() == "read").unwrap();
932        let obj = read.schema.as_object().unwrap();
933        let props = obj["properties"].as_object().unwrap();
934        assert!(props.contains_key("path"));
935        assert!(props.contains_key("offset"));
936        assert!(props.contains_key("limit"));
937    }
938
939    #[test]
940    fn missing_required_path_returns_invalid_params() {
941        let dir = temp_dir();
942        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
943        let params = serde_json::Map::new();
944        let result = exec.execute_file_tool("read", &params);
945        assert!(matches!(result, Err(ToolError::InvalidParams { .. })));
946    }
947
948    // --- list_directory tests ---
949
950    #[test]
951    fn list_directory_returns_entries() {
952        let dir = temp_dir();
953        fs::write(dir.path().join("file.txt"), "").unwrap();
954        fs::create_dir(dir.path().join("subdir")).unwrap();
955
956        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
957        let params = make_params(&[("path", serde_json::json!(dir.path().to_str().unwrap()))]);
958        let result = exec
959            .execute_file_tool("list_directory", &params)
960            .unwrap()
961            .unwrap();
962        assert!(result.summary.contains("[dir]  subdir"));
963        assert!(result.summary.contains("[file] file.txt"));
964        // dirs listed before files
965        let dir_pos = result.summary.find("[dir]").unwrap();
966        let file_pos = result.summary.find("[file]").unwrap();
967        assert!(dir_pos < file_pos);
968    }
969
970    #[test]
971    fn list_directory_empty_dir() {
972        let dir = temp_dir();
973        let subdir = dir.path().join("empty");
974        fs::create_dir(&subdir).unwrap();
975
976        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
977        let params = make_params(&[("path", serde_json::json!(subdir.to_str().unwrap()))]);
978        let result = exec
979            .execute_file_tool("list_directory", &params)
980            .unwrap()
981            .unwrap();
982        assert!(result.summary.contains("Empty directory"));
983    }
984
985    #[test]
986    fn list_directory_sandbox_violation() {
987        let dir = temp_dir();
988        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
989        let params = make_params(&[("path", serde_json::json!("/etc"))]);
990        let result = exec.execute_file_tool("list_directory", &params);
991        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
992    }
993
994    #[test]
995    fn list_directory_nonexistent_returns_error() {
996        let dir = temp_dir();
997        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
998        let missing = dir.path().join("nonexistent");
999        let params = make_params(&[("path", serde_json::json!(missing.to_str().unwrap()))]);
1000        let result = exec.execute_file_tool("list_directory", &params);
1001        assert!(result.is_err());
1002    }
1003
1004    #[test]
1005    fn list_directory_on_file_returns_error() {
1006        let dir = temp_dir();
1007        let file = dir.path().join("file.txt");
1008        fs::write(&file, "content").unwrap();
1009
1010        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1011        let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
1012        let result = exec.execute_file_tool("list_directory", &params);
1013        assert!(result.is_err());
1014    }
1015
1016    // --- create_directory tests ---
1017
1018    #[test]
1019    fn create_directory_creates_nested() {
1020        let dir = temp_dir();
1021        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1022        let nested = dir.path().join("a/b/c");
1023        let params = make_params(&[("path", serde_json::json!(nested.to_str().unwrap()))]);
1024        let result = exec
1025            .execute_file_tool("create_directory", &params)
1026            .unwrap()
1027            .unwrap();
1028        assert!(result.summary.contains("Created"));
1029        assert!(nested.is_dir());
1030    }
1031
1032    #[test]
1033    fn create_directory_sandbox_violation() {
1034        let dir = temp_dir();
1035        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1036        let params = make_params(&[("path", serde_json::json!("/tmp/evil_dir"))]);
1037        let result = exec.execute_file_tool("create_directory", &params);
1038        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1039    }
1040
1041    // --- delete_path tests ---
1042
1043    #[test]
1044    fn delete_path_file() {
1045        let dir = temp_dir();
1046        let file = dir.path().join("del.txt");
1047        fs::write(&file, "bye").unwrap();
1048
1049        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1050        let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
1051        exec.execute_file_tool("delete_path", &params)
1052            .unwrap()
1053            .unwrap();
1054        assert!(!file.exists());
1055    }
1056
1057    #[test]
1058    fn delete_path_empty_directory() {
1059        let dir = temp_dir();
1060        let subdir = dir.path().join("empty_sub");
1061        fs::create_dir(&subdir).unwrap();
1062
1063        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1064        let params = make_params(&[("path", serde_json::json!(subdir.to_str().unwrap()))]);
1065        exec.execute_file_tool("delete_path", &params)
1066            .unwrap()
1067            .unwrap();
1068        assert!(!subdir.exists());
1069    }
1070
1071    #[test]
1072    fn delete_path_non_empty_dir_without_recursive_fails() {
1073        let dir = temp_dir();
1074        let subdir = dir.path().join("nonempty");
1075        fs::create_dir(&subdir).unwrap();
1076        fs::write(subdir.join("file.txt"), "x").unwrap();
1077
1078        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1079        let params = make_params(&[("path", serde_json::json!(subdir.to_str().unwrap()))]);
1080        let result = exec.execute_file_tool("delete_path", &params);
1081        assert!(result.is_err());
1082    }
1083
1084    #[test]
1085    fn delete_path_recursive() {
1086        let dir = temp_dir();
1087        let subdir = dir.path().join("recurse");
1088        fs::create_dir(&subdir).unwrap();
1089        fs::write(subdir.join("f.txt"), "x").unwrap();
1090
1091        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1092        let params = make_params(&[
1093            ("path", serde_json::json!(subdir.to_str().unwrap())),
1094            ("recursive", serde_json::json!(true)),
1095        ]);
1096        exec.execute_file_tool("delete_path", &params)
1097            .unwrap()
1098            .unwrap();
1099        assert!(!subdir.exists());
1100    }
1101
1102    #[test]
1103    fn delete_path_sandbox_violation() {
1104        let dir = temp_dir();
1105        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1106        let params = make_params(&[("path", serde_json::json!("/etc/hosts"))]);
1107        let result = exec.execute_file_tool("delete_path", &params);
1108        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1109    }
1110
1111    #[test]
1112    fn delete_path_refuses_sandbox_root() {
1113        let dir = temp_dir();
1114        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1115        let params = make_params(&[
1116            ("path", serde_json::json!(dir.path().to_str().unwrap())),
1117            ("recursive", serde_json::json!(true)),
1118        ]);
1119        let result = exec.execute_file_tool("delete_path", &params);
1120        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1121    }
1122
1123    // --- move_path tests ---
1124
1125    #[test]
1126    fn move_path_renames_file() {
1127        let dir = temp_dir();
1128        let src = dir.path().join("src.txt");
1129        let dst = dir.path().join("dst.txt");
1130        fs::write(&src, "data").unwrap();
1131
1132        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1133        let params = make_params(&[
1134            ("source", serde_json::json!(src.to_str().unwrap())),
1135            ("destination", serde_json::json!(dst.to_str().unwrap())),
1136        ]);
1137        exec.execute_file_tool("move_path", &params)
1138            .unwrap()
1139            .unwrap();
1140        assert!(!src.exists());
1141        assert_eq!(fs::read_to_string(&dst).unwrap(), "data");
1142    }
1143
1144    #[test]
1145    fn move_path_cross_sandbox_denied() {
1146        let sandbox = temp_dir();
1147        let outside = temp_dir();
1148        let src = sandbox.path().join("src.txt");
1149        fs::write(&src, "x").unwrap();
1150
1151        let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1152        let dst = outside.path().join("dst.txt");
1153        let params = make_params(&[
1154            ("source", serde_json::json!(src.to_str().unwrap())),
1155            ("destination", serde_json::json!(dst.to_str().unwrap())),
1156        ]);
1157        let result = exec.execute_file_tool("move_path", &params);
1158        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1159    }
1160
1161    // --- copy_path tests ---
1162
1163    #[test]
1164    fn copy_path_file() {
1165        let dir = temp_dir();
1166        let src = dir.path().join("src.txt");
1167        let dst = dir.path().join("dst.txt");
1168        fs::write(&src, "hello").unwrap();
1169
1170        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1171        let params = make_params(&[
1172            ("source", serde_json::json!(src.to_str().unwrap())),
1173            ("destination", serde_json::json!(dst.to_str().unwrap())),
1174        ]);
1175        exec.execute_file_tool("copy_path", &params)
1176            .unwrap()
1177            .unwrap();
1178        assert_eq!(fs::read_to_string(&src).unwrap(), "hello");
1179        assert_eq!(fs::read_to_string(&dst).unwrap(), "hello");
1180    }
1181
1182    #[test]
1183    fn copy_path_directory_recursive() {
1184        let dir = temp_dir();
1185        let src_dir = dir.path().join("src_dir");
1186        fs::create_dir(&src_dir).unwrap();
1187        fs::write(src_dir.join("a.txt"), "aaa").unwrap();
1188
1189        let dst_dir = dir.path().join("dst_dir");
1190
1191        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1192        let params = make_params(&[
1193            ("source", serde_json::json!(src_dir.to_str().unwrap())),
1194            ("destination", serde_json::json!(dst_dir.to_str().unwrap())),
1195        ]);
1196        exec.execute_file_tool("copy_path", &params)
1197            .unwrap()
1198            .unwrap();
1199        assert_eq!(fs::read_to_string(dst_dir.join("a.txt")).unwrap(), "aaa");
1200    }
1201
1202    #[test]
1203    fn copy_path_sandbox_violation() {
1204        let sandbox = temp_dir();
1205        let outside = temp_dir();
1206        let src = sandbox.path().join("src.txt");
1207        fs::write(&src, "x").unwrap();
1208
1209        let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1210        let dst = outside.path().join("dst.txt");
1211        let params = make_params(&[
1212            ("source", serde_json::json!(src.to_str().unwrap())),
1213            ("destination", serde_json::json!(dst.to_str().unwrap())),
1214        ]);
1215        let result = exec.execute_file_tool("copy_path", &params);
1216        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1217    }
1218
1219    // CR-11: invalid glob pattern returns error
1220    #[test]
1221    fn find_path_invalid_pattern_returns_error() {
1222        let dir = temp_dir();
1223        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1224        let params = make_params(&[("pattern", serde_json::json!("[invalid"))]);
1225        let result = exec.execute_file_tool("find_path", &params);
1226        assert!(result.is_err());
1227    }
1228
1229    // CR-12: create_directory is idempotent on existing dir
1230    #[test]
1231    fn create_directory_idempotent() {
1232        let dir = temp_dir();
1233        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1234        let target = dir.path().join("exists");
1235        fs::create_dir(&target).unwrap();
1236
1237        let params = make_params(&[("path", serde_json::json!(target.to_str().unwrap()))]);
1238        let result = exec.execute_file_tool("create_directory", &params);
1239        assert!(result.is_ok());
1240        assert!(target.is_dir());
1241    }
1242
1243    // CR-13: move_path source sandbox violation
1244    #[test]
1245    fn move_path_source_sandbox_violation() {
1246        let sandbox = temp_dir();
1247        let outside = temp_dir();
1248        let src = outside.path().join("src.txt");
1249        fs::write(&src, "x").unwrap();
1250
1251        let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1252        let dst = sandbox.path().join("dst.txt");
1253        let params = make_params(&[
1254            ("source", serde_json::json!(src.to_str().unwrap())),
1255            ("destination", serde_json::json!(dst.to_str().unwrap())),
1256        ]);
1257        let result = exec.execute_file_tool("move_path", &params);
1258        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1259    }
1260
1261    // CR-13: copy_path source sandbox violation
1262    #[test]
1263    fn copy_path_source_sandbox_violation() {
1264        let sandbox = temp_dir();
1265        let outside = temp_dir();
1266        let src = outside.path().join("src.txt");
1267        fs::write(&src, "x").unwrap();
1268
1269        let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1270        let dst = sandbox.path().join("dst.txt");
1271        let params = make_params(&[
1272            ("source", serde_json::json!(src.to_str().unwrap())),
1273            ("destination", serde_json::json!(dst.to_str().unwrap())),
1274        ]);
1275        let result = exec.execute_file_tool("copy_path", &params);
1276        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1277    }
1278
1279    // CR-01: copy_dir_recursive skips symlinks
1280    #[cfg(unix)]
1281    #[test]
1282    fn copy_dir_skips_symlinks() {
1283        let dir = temp_dir();
1284        let src_dir = dir.path().join("src");
1285        fs::create_dir(&src_dir).unwrap();
1286        fs::write(src_dir.join("real.txt"), "real").unwrap();
1287
1288        // Create a symlink inside src pointing outside sandbox
1289        let outside = temp_dir();
1290        std::os::unix::fs::symlink(outside.path(), src_dir.join("link")).unwrap();
1291
1292        let dst_dir = dir.path().join("dst");
1293        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1294        let params = make_params(&[
1295            ("source", serde_json::json!(src_dir.to_str().unwrap())),
1296            ("destination", serde_json::json!(dst_dir.to_str().unwrap())),
1297        ]);
1298        exec.execute_file_tool("copy_path", &params)
1299            .unwrap()
1300            .unwrap();
1301        // Real file copied
1302        assert_eq!(
1303            fs::read_to_string(dst_dir.join("real.txt")).unwrap(),
1304            "real"
1305        );
1306        // Symlink not copied
1307        assert!(!dst_dir.join("link").exists());
1308    }
1309
1310    // CR-04: list_directory detects symlinks
1311    #[cfg(unix)]
1312    #[test]
1313    fn list_directory_shows_symlinks() {
1314        let dir = temp_dir();
1315        let target = dir.path().join("target.txt");
1316        fs::write(&target, "x").unwrap();
1317        std::os::unix::fs::symlink(&target, dir.path().join("link")).unwrap();
1318
1319        let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1320        let params = make_params(&[("path", serde_json::json!(dir.path().to_str().unwrap()))]);
1321        let result = exec
1322            .execute_file_tool("list_directory", &params)
1323            .unwrap()
1324            .unwrap();
1325        assert!(result.summary.contains("[symlink] link"));
1326        assert!(result.summary.contains("[file] target.txt"));
1327    }
1328}