opendev_tools_impl/
file_list.rs1use std::collections::HashMap;
4use std::path::Path;
5
6use crate::path_utils::{resolve_dir_path, validate_path_access};
7
8use opendev_tools_core::{BaseTool, ToolContext, ToolResult};
9
10use crate::dir_hints::list_available_dirs;
11use crate::file_search::{DEFAULT_SEARCH_EXCLUDE_GLOBS, DEFAULT_SEARCH_EXCLUDES};
12
13fn is_excluded_path(path: &Path) -> bool {
15 for component in path.components() {
16 let name = component.as_os_str().to_string_lossy();
17 if DEFAULT_SEARCH_EXCLUDES.contains(&name.as_ref()) {
18 return true;
19 }
20 }
21 if let Some(file_name) = path.file_name() {
23 let name = file_name.to_string_lossy();
24 for glob_pat in DEFAULT_SEARCH_EXCLUDE_GLOBS {
25 if let Some(suffix) = glob_pat.strip_prefix('*')
27 && name.ends_with(suffix)
28 {
29 return true;
30 }
31 }
32 }
33 false
34}
35
36#[derive(Debug)]
38pub struct FileListTool;
39
40impl FileListTool {
41 const MAX_RESULTS: usize = 500;
43}
44
45#[async_trait::async_trait]
46impl BaseTool for FileListTool {
47 fn name(&self) -> &str {
48 "list_files"
49 }
50
51 fn description(&self) -> &str {
52 "List files matching a glob pattern. Returns file paths sorted by modification time."
53 }
54
55 fn parameter_schema(&self) -> serde_json::Value {
56 serde_json::json!({
57 "type": "object",
58 "properties": {
59 "pattern": {
60 "type": "string",
61 "description": "Glob pattern to match files relative to `path`. Use **/* for all files, **/*.ext for files by extension. IMPORTANT: ** alone matches directories, not files — always use **/* or **/*.ext to match files."
62 },
63 "path": {
64 "type": "string",
65 "description": "Base directory to search in. To list files in a subdirectory, set this to the subdirectory path instead of including it in the pattern. Defaults to working directory."
66 },
67 "max_depth": {
68 "type": "number",
69 "description": "Maximum directory depth to recurse into (0 = base dir only)"
70 },
71 "ignore": {
72 "type": "array",
73 "items": { "type": "string" },
74 "description": "Additional glob patterns to exclude (e.g., [\"*.log\", \"temp/\"])"
75 }
76 },
77 "required": ["pattern"]
78 })
79 }
80
81 async fn execute(
82 &self,
83 args: HashMap<String, serde_json::Value>,
84 ctx: &ToolContext,
85 ) -> ToolResult {
86 let pattern = match args.get("pattern").and_then(|v| v.as_str()) {
87 Some(p) => p,
88 None => return ToolResult::fail("pattern is required"),
89 };
90
91 let base_dir = args
92 .get("path")
93 .and_then(|v| v.as_str())
94 .map(|p| resolve_dir_path(p, &ctx.working_dir))
95 .unwrap_or_else(|| ctx.working_dir.clone());
96
97 let max_depth = args
98 .get("max_depth")
99 .and_then(|v| v.as_u64())
100 .map(|v| v as usize);
101
102 let custom_ignores: Vec<String> = args
104 .get("ignore")
105 .and_then(|v| v.as_array())
106 .map(|arr| {
107 arr.iter()
108 .filter_map(|v| v.as_str().map(|s| s.to_string()))
109 .collect()
110 })
111 .unwrap_or_default();
112
113 if let Err(msg) = validate_path_access(&base_dir, &ctx.working_dir) {
114 return ToolResult::fail(msg);
115 }
116
117 if !base_dir.exists() {
118 let available = list_available_dirs(&ctx.working_dir);
119 return ToolResult::fail(format!(
120 "Directory not found: {}\n\nAvailable directories in working dir ({}):\n{}",
121 base_dir.display(),
122 ctx.working_dir.display(),
123 available
124 ));
125 }
126
127 let full_pattern = base_dir.join(pattern);
129 let full_pattern_str = full_pattern.to_string_lossy();
130
131 let glob_opts = glob::MatchOptions {
132 case_sensitive: true,
133 require_literal_separator: false,
134 require_literal_leading_dot: false,
135 };
136
137 let entries = match glob::glob_with(&full_pattern_str, glob_opts) {
138 Ok(paths) => paths,
139 Err(e) => return ToolResult::fail(format!("Invalid glob pattern: {e}")),
140 };
141
142 let mut files: Vec<(std::path::PathBuf, std::time::SystemTime)> = Vec::new();
143
144 for entry in entries {
145 match entry {
146 Ok(path) => {
147 if path.is_file() {
148 if let Ok(rel) = path.strip_prefix(&base_dir)
150 && is_excluded_path(rel)
151 {
152 continue;
153 }
154 if !custom_ignores.is_empty()
156 && let Ok(rel) = path.strip_prefix(&base_dir)
157 {
158 let rel_str = rel.to_string_lossy();
159 let matched = custom_ignores.iter().any(|pat| {
160 if let Some(dir) = pat.strip_suffix('/') {
162 rel_str.starts_with(dir)
163 || rel_str.contains(&format!("/{dir}/"))
164 } else if let Ok(glob) = glob::Pattern::new(pat) {
165 glob.matches(&rel_str)
166 } else {
167 rel_str.contains(pat.as_str())
168 }
169 });
170 if matched {
171 continue;
172 }
173 }
174 if let Some(depth) = max_depth
176 && let Ok(rel) = path.strip_prefix(&base_dir)
177 {
178 let rel_depth = rel.components().count().saturating_sub(1);
180 if rel_depth > depth {
181 continue;
182 }
183 }
184 let mtime = path
185 .metadata()
186 .and_then(|m| m.modified())
187 .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
188 files.push((path, mtime));
189 }
190 }
191 Err(e) => {
192 tracing::debug!("Glob entry error: {}", e);
193 }
194 }
195 }
196
197 files.sort_by(|a, b| b.1.cmp(&a.1));
199
200 let total = files.len();
201 let truncated = total > Self::MAX_RESULTS;
202 let files = &files[..total.min(Self::MAX_RESULTS)];
203
204 if files.is_empty() {
205 let first_component = pattern.split('/').next().unwrap_or("");
207 let candidate = base_dir.join(first_component);
208 if !first_component.is_empty() && !first_component.contains('*') && !candidate.exists()
209 {
210 let available = list_available_dirs(&base_dir);
211 return ToolResult::ok(format!(
212 "No files found matching '{pattern}' in {}\n\
213 Note: directory '{first_component}/' does not exist.\n\
214 Available directories:\n{available}",
215 base_dir.display()
216 ));
217 }
218 let hint = if pattern.ends_with("**") && !pattern.ends_with("**/*") {
219 "\nHint: '**' alone matches directories, not files. Try '**/*' or '**/*.ext' instead."
220 } else {
221 ""
222 };
223 return ToolResult::ok(format!(
224 "No files found matching '{pattern}' in {}{}",
225 base_dir.display(),
226 hint
227 ));
228 }
229
230 let mut output = String::new();
231 for (path, _) in files {
232 let display = path.strip_prefix(&base_dir).unwrap_or(path).display();
234 output.push_str(&format!("{display}\n"));
235 }
236
237 if truncated {
238 output.push_str(&format!(
239 "\n... and {} more files (showing first {})\n",
240 total - Self::MAX_RESULTS,
241 Self::MAX_RESULTS
242 ));
243 }
244
245 let mut metadata = HashMap::new();
246 metadata.insert("total_files".into(), serde_json::json!(total));
247 metadata.insert("truncated".into(), serde_json::json!(truncated));
248
249 ToolResult::ok_with_metadata(output, metadata)
250 }
251}
252
253#[cfg(test)]
254#[path = "file_list_tests.rs"]
255mod tests;