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::borrow::Cow;
16use std::path::{Path, PathBuf};
17use std::sync::Arc;
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")
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 #[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 #[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 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 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 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 }
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}