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::path::{Path, PathBuf};
16use std::sync::Arc;
17use vtcode_commons::workspace_relative_display;
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") || mode.eq_ignore_ascii_case("file") || mode.eq_ignore_ascii_case("files")
62        {
63            Some("list")
64        } else if mode.eq_ignore_ascii_case("recursive") {
65            Some("recursive")
66        } else if mode.eq_ignore_ascii_case("find_name") {
67            Some("find_name")
68        } else if mode.eq_ignore_ascii_case("find_content") {
69            Some("find_content")
70        } else if mode.eq_ignore_ascii_case("largest") {
71            Some("largest")
72        } else if mode.eq_ignore_ascii_case("tree") {
73            Some("tree")
74        } else {
75            None
76        }
77    }
78
79    /// Get relative path as JSON value (for API responses)
80    #[inline]
81    pub(super) fn relative_path_json(&self, path: &Path) -> String {
82        workspace_relative_display(&self.workspace_root, path)
83    }
84}
85
86#[async_trait]
87impl Tool for FileOpsTool {
88    async fn execute(&self, args: Value) -> Result<Value> {
89        let mut input: ListInput = deserialize_tool_args(&args, "list_files")?;
90        if input.mode.as_deref().is_some_and(|mode| mode.trim().is_empty()) {
91            input.mode = None;
92        }
93
94        // Standard path extraction from args (handles aliases)
95        let path_args: PathArgs = serde_json::from_value(args).unwrap_or(PathArgs { path: input.path.clone() });
96        input.path = path_args.path;
97
98        // Normalize path: strip /workspace prefix if present (common LLM pattern)
99        if input.path.starts_with("/workspace/") {
100            if let Some(stripped) = input.path.strip_prefix("/workspace/") {
101                input.path = stripped.to_string();
102            }
103        } else if input.path == "/workspace" {
104            input.path = ".".to_string();
105        }
106
107        validate_non_root_listing_path(Some(input.path.as_str()))?;
108
109        let should_promote_glob_to_recursive = input.mode.is_none()
110            && input
111                .glob_pattern
112                .as_deref()
113                .map(str::trim)
114                .is_some_and(|pattern| !pattern.is_empty() && (pattern.contains('/') || pattern.contains("**")));
115        if should_promote_glob_to_recursive {
116            input.mode = Some("recursive".to_string());
117        }
118
119        let raw_mode = input
120            .mode
121            .as_deref()
122            .map(str::trim)
123            .filter(|mode| !mode.is_empty())
124            .unwrap_or("list")
125            .to_string();
126        let mode = Self::normalize_list_mode(&raw_mode).unwrap_or(raw_mode.as_str());
127        input.mode = Some(mode.to_string());
128
129        self.execute_mode(mode, serde_json::to_value(input)?).await
130    }
131
132    fn name(&self) -> &str {
133        tools::LIST_FILES
134    }
135
136    fn description(&self) -> &str {
137        "Enhanced file discovery tool with multiple modes: list (default), recursive, find_name, find_content, largest (size ranking), tree (visual directory structure)"
138    }
139}
140
141#[async_trait]
142impl FileTool for FileOpsTool {
143    fn workspace_root(&self) -> &PathBuf {
144        &self.workspace_root
145    }
146
147    async fn should_exclude(&self, path: &Path) -> bool {
148        should_exclude_file(path).await
149    }
150}
151
152#[async_trait]
153impl ModeTool for FileOpsTool {
154    fn supported_modes(&self) -> Vec<&'static str> {
155        vec!["list", "recursive", "find_name", "find_content", "largest", "tree"]
156    }
157
158    async fn execute_mode(&self, mode: &str, args: Value) -> Result<Value> {
159        let input: ListInput = serde_json::from_value(args)?;
160
161        match mode {
162            "list" => self.execute_basic_list(&input).await,
163            "recursive" => self.execute_recursive_search(&input).await,
164            "find_name" => self.execute_find_by_name(&input).await,
165            "find_content" => self.execute_find_by_content(&input).await,
166            "largest" => self.execute_largest_files(&input).await,
167            "tree" => self.execute_tree_view(&input).await,
168            _ => Err(anyhow!("Unsupported file operation mode: {mode}")),
169        }
170    }
171}
172
173#[async_trait]
174impl CacheableTool for FileOpsTool {
175    fn cache_key(&self, args: &Value) -> String {
176        format!(
177            "files:{}:{}",
178            args.get("path").and_then(|p| p.as_str()).unwrap_or(""),
179            args.get("mode").and_then(|m| m.as_str()).unwrap_or("list")
180        )
181    }
182
183    fn should_cache(&self, args: &Value) -> bool {
184        // Cache list and recursive modes, but not content-based searches
185        let mode = args.get("mode").and_then(|m| m.as_str()).unwrap_or("list");
186        matches!(mode, "list" | "recursive" | "largest")
187    }
188
189    fn cache_ttl(&self) -> u64 {
190        60 // 1 minute for file listings
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use crate::config::constants::diff;
198    use crate::tools::file_ops::diff_preview::{build_diff_preview, diff_preview_error_skip};
199    use crate::tools::grep_file::GrepSearchManager;
200    use serde_json::json;
201    use std::fs;
202    use std::sync::Arc;
203    use tempfile::TempDir;
204
205    #[test]
206    fn diff_preview_reports_truncation_and_omission() {
207        let after = (0..(diff::MAX_PREVIEW_LINES + 40))
208            .map(|idx| format!("line {idx}\n"))
209            .collect::<String>();
210
211        let preview = build_diff_preview("sample.txt", None, &after);
212
213        assert_eq!(preview["skipped"], Value::Bool(false));
214        assert_eq!(preview["truncated"], Value::Bool(true));
215        assert!(preview["omitted_line_count"].as_u64().unwrap() > 0);
216
217        let content = preview["content"].as_str().unwrap();
218        assert!(content.contains("lines omitted"));
219        assert!(content.lines().count() <= diff::HEAD_LINE_COUNT + diff::TAIL_LINE_COUNT + 1);
220    }
221
222    #[test]
223    fn diff_preview_skip_handles_error_detail() {
224        let preview = diff_preview_error_skip("failed", Some("InvalidData"));
225        assert_eq!(preview["reason"], Value::String("failed".to_string()));
226        assert_eq!(preview["detail"], Value::String("InvalidData".to_string()));
227        assert_eq!(preview["skipped"], Value::Bool(true));
228    }
229
230    #[tokio::test]
231    async fn globbed_list_pattern_promotes_to_recursive_mode() {
232        let temp_dir = TempDir::new().expect("workspace tempdir");
233        fs::create_dir_all(temp_dir.path().join("src/nested")).expect("create nested src");
234        fs::write(temp_dir.path().join("src/lib.rs"), "pub fn lib() {}\n").expect("write lib");
235        fs::write(temp_dir.path().join("src/nested/mod.rs"), "pub fn nested() {}\n").expect("write nested");
236        fs::write(temp_dir.path().join("src/notes.md"), "# notes\n").expect("write notes");
237
238        let grep_manager = Arc::new(GrepSearchManager::new(temp_dir.path().to_path_buf()));
239        let file_ops = FileOpsTool::new(temp_dir.path().to_path_buf(), grep_manager);
240
241        let result = file_ops
242            .execute(json!({
243                "path": "src",
244                "pattern": "**/*.rs",
245                "response_format": "detailed"
246            }))
247            .await
248            .expect("recursive glob list should succeed");
249
250        assert_eq!(result["mode"], json!("recursive"));
251        assert_eq!(result["pattern"], json!("**/*.rs"));
252
253        let items = result["items"].as_array().expect("items array");
254        assert_eq!(items.len(), 2);
255        assert!(
256            items
257                .iter()
258                .all(|item| { item["path"].as_str().is_some_and(|path| path.ends_with(".rs")) })
259        );
260    }
261
262    #[tokio::test]
263    async fn file_mode_alias_executes_basic_list() {
264        let temp_dir = TempDir::new().expect("workspace tempdir");
265        fs::create_dir_all(temp_dir.path().join("src")).expect("create src");
266        fs::write(temp_dir.path().join("src/lib.rs"), "pub fn lib() {}\n").expect("write lib");
267
268        let grep_manager = Arc::new(GrepSearchManager::new(temp_dir.path().to_path_buf()));
269        let file_ops = FileOpsTool::new(temp_dir.path().to_path_buf(), grep_manager);
270
271        let result = file_ops
272            .execute(json!({
273                "path": "src",
274                "mode": "file"
275            }))
276            .await
277            .expect("file alias should behave like list");
278
279        assert_eq!(result["mode"], json!("list"));
280        let items = result["items"].as_array().expect("items array");
281        assert_eq!(items.len(), 1);
282        assert_eq!(items[0]["path"], json!("src/lib.rs"));
283    }
284
285    #[tokio::test]
286    async fn blank_mode_defaults_to_basic_list() {
287        let temp_dir = TempDir::new().expect("workspace tempdir");
288        fs::create_dir_all(temp_dir.path().join("src")).expect("create src");
289        fs::write(temp_dir.path().join("src/lib.rs"), "pub fn lib() {}\n").expect("write lib");
290
291        let grep_manager = Arc::new(GrepSearchManager::new(temp_dir.path().to_path_buf()));
292        let file_ops = FileOpsTool::new(temp_dir.path().to_path_buf(), grep_manager);
293
294        let result = file_ops
295            .execute(json!({
296                "path": "src",
297                "mode": "",
298                "pattern": "*.rs"
299            }))
300            .await
301            .expect("blank mode should behave like missing mode");
302
303        assert_eq!(result["mode"], json!("list"));
304        let items = result["items"].as_array().expect("items array");
305        assert_eq!(items.len(), 1);
306        assert_eq!(items[0]["path"], json!("src/lib.rs"));
307    }
308
309    #[tokio::test]
310    async fn blank_mode_allows_recursive_glob_promotion() {
311        let temp_dir = TempDir::new().expect("workspace tempdir");
312        fs::create_dir_all(temp_dir.path().join("src/nested")).expect("create nested src");
313        fs::write(temp_dir.path().join("src/nested/lib.rs"), "pub fn lib() {}\n").expect("write lib");
314
315        let grep_manager = Arc::new(GrepSearchManager::new(temp_dir.path().to_path_buf()));
316        let file_ops = FileOpsTool::new(temp_dir.path().to_path_buf(), grep_manager);
317
318        let result = file_ops
319            .execute(json!({
320                "path": "src",
321                "mode": "",
322                "pattern": "**/*.rs"
323            }))
324            .await
325            .expect("blank mode should still allow recursive glob promotion");
326
327        assert_eq!(result["mode"], json!("recursive"));
328        let items = result["items"].as_array().expect("items array");
329        assert_eq!(items.len(), 1);
330        assert_eq!(items[0]["path"], json!("src/nested/lib.rs"));
331    }
332}