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