vtcode_core/tools/file_ops/
tool.rs1use 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#[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 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 pub fn edited_file_monitor_ref(&self) -> &EditedFileMonitor {
52 self.edited_file_monitor.as_ref()
53 }
54
55 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 #[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 let path_args: PathArgs = serde_json::from_value(args).unwrap_or(PathArgs { path: input.path.clone() });
96 input.path = path_args.path;
97
98 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 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 }
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}