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
101 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 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 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 }
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}