Skip to main content

opendev_tools_impl/
file_list.rs

1//! List files tool — glob-based file listing.
2
3use 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
13/// Check if a path should be excluded based on default exclusion patterns.
14fn 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    // Check file glob patterns (e.g., *.min.js)
22    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            // Patterns are like "*.min.js" — check suffix after first '*'
26            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/// Tool for listing files using glob patterns.
37#[derive(Debug)]
38pub struct FileListTool;
39
40impl FileListTool {
41    /// Maximum number of files to return.
42    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        // Parse custom ignore patterns.
103        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        // Build full glob pattern
128        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                        // Filter out excluded directories and file patterns
149                        if let Ok(rel) = path.strip_prefix(&base_dir)
150                            && is_excluded_path(rel)
151                        {
152                            continue;
153                        }
154                        // Apply custom ignore patterns.
155                        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                                // Support directory patterns (ending with /) and glob patterns.
161                                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                        // Apply max_depth filter: count components relative to base_dir
175                        if let Some(depth) = max_depth
176                            && let Ok(rel) = path.strip_prefix(&base_dir)
177                        {
178                            // Depth is number of parent directories (components - 1 for the file itself)
179                            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        // Sort by modification time (most recent first)
198        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            // Check if pattern references a non-existent directory
206            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            // Try to make path relative to base_dir
233            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;