Skip to main content

vtcode_core/tools/file_ops/
tool.rs

1//! File operations tool implementation.
2
3use crate::config::constants::tools;
4use crate::tools::edited_file_monitor::EditedFileMonitor;
5use crate::tools::error_helpers::deserialize_tool_args;
6use crate::tools::grep_file::GrepSearchManager;
7use crate::tools::traits::{CacheableTool, FileTool, ModeTool, Tool};
8use crate::tools::types::{ListInput, PathArgs};
9use crate::tools::validation::paths::validate_non_root_listing_path;
10use crate::utils::path::canonicalize_workspace;
11use crate::utils::vtcodegitignore::should_exclude_file;
12use anyhow::{Result, anyhow};
13use async_trait::async_trait;
14use serde_json::Value;
15use std::borrow::Cow;
16use std::path::{Path, PathBuf};
17use std::sync::Arc;
18
19/// File operations tool with multiple modes
20#[derive(Clone)]
21pub struct FileOpsTool {
22    pub(super) workspace_root: PathBuf,
23    pub(super) canonical_workspace_root: PathBuf,
24    pub(super) grep_manager: Arc<GrepSearchManager>,
25    pub(super) edited_file_monitor: Arc<EditedFileMonitor>,
26}
27
28impl FileOpsTool {
29    pub fn new(workspace_root: PathBuf, grep_search: Arc<GrepSearchManager>) -> Self {
30        let edited_file_monitor = Arc::new(EditedFileMonitor::new());
31        Self::new_with_monitor(workspace_root, grep_search, edited_file_monitor)
32    }
33
34    pub fn new_with_monitor(
35        workspace_root: PathBuf,
36        grep_search: Arc<GrepSearchManager>,
37        edited_file_monitor: Arc<EditedFileMonitor>,
38    ) -> Self {
39        // grep_file manager is unused; keep param to avoid broad call-site churn
40        let canonical_workspace_root = canonicalize_workspace(&workspace_root);
41
42        Self {
43            workspace_root,
44            canonical_workspace_root,
45            grep_manager: grep_search,
46            edited_file_monitor,
47        }
48    }
49
50    /// Borrow the edited-file monitor without exposing shared ownership.
51    pub fn edited_file_monitor_ref(&self) -> &EditedFileMonitor {
52        self.edited_file_monitor.as_ref()
53    }
54
55    /// Get the shared edited-file monitor handle for callers that need to clone it.
56    pub fn edited_file_monitor(&self) -> &Arc<EditedFileMonitor> {
57        &self.edited_file_monitor
58    }
59
60    fn normalize_list_mode(mode: &str) -> Option<&'static str> {
61        if mode.eq_ignore_ascii_case("list")
62            || mode.eq_ignore_ascii_case("file")
63            || mode.eq_ignore_ascii_case("files")
64        {
65            Some("list")
66        } else if mode.eq_ignore_ascii_case("recursive") {
67            Some("recursive")
68        } else if mode.eq_ignore_ascii_case("find_name") {
69            Some("find_name")
70        } else if mode.eq_ignore_ascii_case("find_content") {
71            Some("find_content")
72        } else if mode.eq_ignore_ascii_case("largest") {
73            Some("largest")
74        } else if mode.eq_ignore_ascii_case("tree") {
75            Some("tree")
76        } else {
77            None
78        }
79    }
80
81    /// Get relative path from workspace root, avoiding allocation when possible
82    #[inline]
83    pub(super) fn relative_path<'a>(&self, path: &'a Path) -> Cow<'a, str> {
84        path.strip_prefix(&self.workspace_root)
85            .unwrap_or(path)
86            .to_string_lossy()
87    }
88
89    /// Get relative path as JSON value (for API responses)
90    #[inline]
91    pub(super) fn relative_path_json(&self, path: &Path) -> String {
92        self.relative_path(path).into_owned()
93    }
94}
95
96#[async_trait]
97impl Tool for FileOpsTool {
98    async fn execute(&self, args: Value) -> Result<Value> {
99        let mut input: ListInput = deserialize_tool_args(&args, "list_files")?;
100        if input
101            .mode
102            .as_deref()
103            .is_some_and(|mode| mode.trim().is_empty())
104        {
105            input.mode = None;
106        }
107
108        // Standard path extraction from args (handles aliases)
109        let path_args: PathArgs = serde_json::from_value(args).unwrap_or(PathArgs {
110            path: input.path.clone(),
111        });
112        input.path = path_args.path;
113
114        // Normalize path: strip /workspace prefix if present (common LLM pattern)
115        if input.path.starts_with("/workspace/") {
116            if let Some(stripped) = input.path.strip_prefix("/workspace/") {
117                input.path = stripped.to_string();
118            }
119        } else if input.path == "/workspace" {
120            input.path = ".".to_string();
121        }
122
123        validate_non_root_listing_path(Some(input.path.as_str()))?;
124
125        let should_promote_glob_to_recursive = input.mode.is_none()
126            && input
127                .glob_pattern
128                .as_deref()
129                .map(str::trim)
130                .is_some_and(|pattern| {
131                    !pattern.is_empty() && (pattern.contains('/') || pattern.contains("**"))
132                });
133        if should_promote_glob_to_recursive {
134            input.mode = Some("recursive".to_string());
135        }
136
137        let raw_mode = input
138            .mode
139            .as_deref()
140            .map(str::trim)
141            .filter(|mode| !mode.is_empty())
142            .unwrap_or("list")
143            .to_string();
144        let mode = Self::normalize_list_mode(&raw_mode).unwrap_or(raw_mode.as_str());
145        input.mode = Some(mode.to_string());
146
147        self.execute_mode(mode, serde_json::to_value(input)?).await
148    }
149
150    fn name(&self) -> &str {
151        tools::LIST_FILES
152    }
153
154    fn description(&self) -> &str {
155        "Enhanced file discovery tool with multiple modes: list (default), recursive, find_name, find_content, largest (size ranking), tree (visual directory structure)"
156    }
157}
158
159#[async_trait]
160impl FileTool for FileOpsTool {
161    fn workspace_root(&self) -> &PathBuf {
162        &self.workspace_root
163    }
164
165    async fn should_exclude(&self, path: &Path) -> bool {
166        should_exclude_file(path).await
167    }
168}
169
170#[async_trait]
171impl ModeTool for FileOpsTool {
172    fn supported_modes(&self) -> Vec<&'static str> {
173        vec![
174            "list",
175            "recursive",
176            "find_name",
177            "find_content",
178            "largest",
179            "tree",
180        ]
181    }
182
183    async fn execute_mode(&self, mode: &str, args: Value) -> Result<Value> {
184        let input: ListInput = serde_json::from_value(args)?;
185
186        match mode {
187            "list" => self.execute_basic_list(&input).await,
188            "recursive" => self.execute_recursive_search(&input).await,
189            "find_name" => self.execute_find_by_name(&input).await,
190            "find_content" => self.execute_find_by_content(&input).await,
191            "largest" => self.execute_largest_files(&input).await,
192            "tree" => self.execute_tree_view(&input).await,
193            _ => Err(anyhow!("Unsupported file operation mode: {}", mode)),
194        }
195    }
196}
197
198#[async_trait]
199impl CacheableTool for FileOpsTool {
200    fn cache_key(&self, args: &Value) -> String {
201        format!(
202            "files:{}:{}",
203            args.get("path").and_then(|p| p.as_str()).unwrap_or(""),
204            args.get("mode").and_then(|m| m.as_str()).unwrap_or("list")
205        )
206    }
207
208    fn should_cache(&self, args: &Value) -> bool {
209        // Cache list and recursive modes, but not content-based searches
210        let mode = args.get("mode").and_then(|m| m.as_str()).unwrap_or("list");
211        matches!(mode, "list" | "recursive" | "largest")
212    }
213
214    fn cache_ttl(&self) -> u64 {
215        60 // 1 minute for file listings
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use crate::config::constants::diff;
223    use crate::tools::file_ops::diff_preview::{build_diff_preview, diff_preview_error_skip};
224    use crate::tools::grep_file::GrepSearchManager;
225    use serde_json::json;
226    use std::fs;
227    use std::sync::Arc;
228    use tempfile::TempDir;
229
230    #[test]
231    fn diff_preview_reports_truncation_and_omission() {
232        let after = (0..(diff::MAX_PREVIEW_LINES + 40))
233            .map(|idx| format!("line {idx}\n"))
234            .collect::<String>();
235
236        let preview = build_diff_preview("sample.txt", None, &after);
237
238        assert_eq!(preview["skipped"], Value::Bool(false));
239        assert_eq!(preview["truncated"], Value::Bool(true));
240        assert!(preview["omitted_line_count"].as_u64().unwrap() > 0);
241
242        let content = preview["content"].as_str().unwrap();
243        assert!(content.contains("lines omitted"));
244        assert!(content.lines().count() <= diff::HEAD_LINE_COUNT + diff::TAIL_LINE_COUNT + 1);
245    }
246
247    #[test]
248    fn diff_preview_skip_handles_error_detail() {
249        let preview = diff_preview_error_skip("failed", Some("InvalidData"));
250        assert_eq!(preview["reason"], Value::String("failed".to_string()));
251        assert_eq!(preview["detail"], Value::String("InvalidData".to_string()));
252        assert_eq!(preview["skipped"], Value::Bool(true));
253    }
254
255    #[tokio::test]
256    async fn globbed_list_pattern_promotes_to_recursive_mode() {
257        let temp_dir = TempDir::new().expect("workspace tempdir");
258        fs::create_dir_all(temp_dir.path().join("src/nested")).expect("create nested src");
259        fs::write(temp_dir.path().join("src/lib.rs"), "pub fn lib() {}\n").expect("write lib");
260        fs::write(
261            temp_dir.path().join("src/nested/mod.rs"),
262            "pub fn nested() {}\n",
263        )
264        .expect("write nested");
265        fs::write(temp_dir.path().join("src/notes.md"), "# notes\n").expect("write notes");
266
267        let grep_manager = Arc::new(GrepSearchManager::new(temp_dir.path().to_path_buf()));
268        let file_ops = FileOpsTool::new(temp_dir.path().to_path_buf(), grep_manager);
269
270        let result = file_ops
271            .execute(json!({
272                "path": "src",
273                "pattern": "**/*.rs",
274                "response_format": "detailed"
275            }))
276            .await
277            .expect("recursive glob list should succeed");
278
279        assert_eq!(result["mode"], json!("recursive"));
280        assert_eq!(result["pattern"], json!("**/*.rs"));
281
282        let items = result["items"].as_array().expect("items array");
283        assert_eq!(items.len(), 2);
284        assert!(items.iter().all(|item| {
285            item["path"]
286                .as_str()
287                .is_some_and(|path| path.ends_with(".rs"))
288        }));
289    }
290
291    #[tokio::test]
292    async fn file_mode_alias_executes_basic_list() {
293        let temp_dir = TempDir::new().expect("workspace tempdir");
294        fs::create_dir_all(temp_dir.path().join("src")).expect("create src");
295        fs::write(temp_dir.path().join("src/lib.rs"), "pub fn lib() {}\n").expect("write lib");
296
297        let grep_manager = Arc::new(GrepSearchManager::new(temp_dir.path().to_path_buf()));
298        let file_ops = FileOpsTool::new(temp_dir.path().to_path_buf(), grep_manager);
299
300        let result = file_ops
301            .execute(json!({
302                "path": "src",
303                "mode": "file"
304            }))
305            .await
306            .expect("file alias should behave like list");
307
308        assert_eq!(result["mode"], json!("list"));
309        let items = result["items"].as_array().expect("items array");
310        assert_eq!(items.len(), 1);
311        assert_eq!(items[0]["path"], json!("src/lib.rs"));
312    }
313
314    #[tokio::test]
315    async fn blank_mode_defaults_to_basic_list() {
316        let temp_dir = TempDir::new().expect("workspace tempdir");
317        fs::create_dir_all(temp_dir.path().join("src")).expect("create src");
318        fs::write(temp_dir.path().join("src/lib.rs"), "pub fn lib() {}\n").expect("write lib");
319
320        let grep_manager = Arc::new(GrepSearchManager::new(temp_dir.path().to_path_buf()));
321        let file_ops = FileOpsTool::new(temp_dir.path().to_path_buf(), grep_manager);
322
323        let result = file_ops
324            .execute(json!({
325                "path": "src",
326                "mode": "",
327                "pattern": "*.rs"
328            }))
329            .await
330            .expect("blank mode should behave like missing mode");
331
332        assert_eq!(result["mode"], json!("list"));
333        let items = result["items"].as_array().expect("items array");
334        assert_eq!(items.len(), 1);
335        assert_eq!(items[0]["path"], json!("src/lib.rs"));
336    }
337
338    #[tokio::test]
339    async fn blank_mode_allows_recursive_glob_promotion() {
340        let temp_dir = TempDir::new().expect("workspace tempdir");
341        fs::create_dir_all(temp_dir.path().join("src/nested")).expect("create nested src");
342        fs::write(temp_dir.path().join("src/nested/lib.rs"), "pub fn lib() {}\n")
343            .expect("write lib");
344
345        let grep_manager = Arc::new(GrepSearchManager::new(temp_dir.path().to_path_buf()));
346        let file_ops = FileOpsTool::new(temp_dir.path().to_path_buf(), grep_manager);
347
348        let result = file_ops
349            .execute(json!({
350                "path": "src",
351                "mode": "",
352                "pattern": "**/*.rs"
353            }))
354            .await
355            .expect("blank mode should still allow recursive glob promotion");
356
357        assert_eq!(result["mode"], json!("recursive"));
358        let items = result["items"].as_array().expect("items array");
359        assert_eq!(items.len(), 1);
360        assert_eq!(items[0]["path"], json!("src/nested/lib.rs"));
361    }
362}