Skip to main content

mcp_servers/coding/tools/find/
mod.rs

1use glob::Pattern;
2use ignore::WalkBuilder;
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use std::path::Path;
6
7use crate::coding::error::FindError;
8use mcp_utils::display_meta::{ToolDisplayMeta, ToolResultMeta};
9
10#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
11pub struct FindInput {
12    /// The glob pattern to match files against
13    pub pattern: String,
14    /// The directory to search in (defaults to cwd)
15    pub path: Option<String>,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
19#[serde(rename_all = "camelCase")]
20pub struct FindOutput {
21    /// Array of matching file paths
22    pub matches: Vec<String>,
23    /// Number of matches found
24    pub count: usize,
25    /// Search directory used
26    pub search_path: String,
27    /// Display metadata for human-friendly rendering
28    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
29    #[schemars(skip)]
30    pub meta: Option<ToolResultMeta>,
31}
32
33pub async fn find_files_by_name(args: FindInput) -> Result<FindOutput, FindError> {
34    let search_path = args.path.as_deref().unwrap_or(".");
35
36    // Validate path exists
37    if !Path::new(search_path).exists() {
38        return Err(FindError::PathNotFound(search_path.to_string()));
39    }
40
41    // Compile glob pattern
42    let glob_pattern = match Pattern::new(&args.pattern) {
43        Ok(pattern) => pattern,
44        Err(e) => {
45            return Err(FindError::InvalidGlobPattern { pattern: args.pattern, reason: e.to_string() });
46        }
47    };
48
49    // Build walker with configuration
50    let mut walker_builder = WalkBuilder::new(search_path);
51    walker_builder.hidden(false).git_ignore(true).follow_links(false);
52
53    // Use parallel walker for better performance
54    let walker = walker_builder.build_parallel();
55    let matching_files = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
56    let matching_files_clone = matching_files.clone();
57    let glob_pattern_clone = std::sync::Arc::new(glob_pattern);
58
59    walker.run(|| {
60        let matching_files = matching_files_clone.clone();
61        let glob_pattern = glob_pattern_clone.clone();
62
63        Box::new(move |result| {
64            if let Ok(entry) = result {
65                let path = entry.path();
66
67                // Only process files (not directories)
68                if let Some(file_type) = entry.file_type()
69                    && !file_type.is_file()
70                {
71                    return ignore::WalkState::Continue;
72                }
73
74                // Check glob pattern matching against the full path
75                let path_str = path.to_string_lossy();
76                if glob_pattern.matches(&path_str)
77                    && let Ok(mut files) = matching_files.lock()
78                {
79                    files.push(path_str.to_string());
80                }
81            }
82            ignore::WalkState::Continue
83        })
84    });
85
86    // Extract results - wait for all threads to complete first
87    drop(matching_files_clone);
88    drop(glob_pattern_clone);
89
90    // Extract results using the remaining Arc reference
91    let mut matches = matching_files.lock().map_err(|_| FindError::LockFailed)?.clone();
92
93    // Sort results by path
94    matches.sort();
95
96    let count = matches.len();
97
98    let display_meta = ToolDisplayMeta::new("Find files", format!("'{}' ({count} files)", args.pattern));
99
100    Ok(FindOutput { matches, count, search_path: search_path.to_string(), meta: Some(display_meta.into()) })
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use std::fs::{self, File};
107    use std::path::Path;
108    use tempfile::TempDir;
109
110    fn create_test_files(temp_dir: &Path) -> Result<(), std::io::Error> {
111        File::create(temp_dir.join("test.rs"))?;
112        File::create(temp_dir.join("main.rs"))?;
113        File::create(temp_dir.join("lib.rs"))?;
114        File::create(temp_dir.join("example.txt"))?;
115        File::create(temp_dir.join("README.md"))?;
116
117        // Create subdirectory with files
118        let sub_dir = temp_dir.join("subdir");
119        fs::create_dir(&sub_dir)?;
120        File::create(sub_dir.join("nested.rs"))?;
121        File::create(sub_dir.join("other.txt"))?;
122
123        Ok(())
124    }
125
126    #[tokio::test]
127    async fn test_exact_pattern_match() {
128        let temp_dir = TempDir::new().unwrap();
129        create_test_files(temp_dir.path()).unwrap();
130
131        let args =
132            FindInput { pattern: "**/test.rs".to_string(), path: Some(temp_dir.path().to_string_lossy().to_string()) };
133
134        let result = find_files_by_name(args).await.unwrap();
135
136        assert_eq!(result.count, 1);
137        assert!(result.matches[0].ends_with("test.rs"));
138    }
139
140    #[tokio::test]
141    async fn test_glob_wildcard_pattern() {
142        let temp_dir = TempDir::new().unwrap();
143        create_test_files(temp_dir.path()).unwrap();
144
145        let args =
146            FindInput { pattern: "**/*.rs".to_string(), path: Some(temp_dir.path().to_string_lossy().to_string()) };
147
148        let result = find_files_by_name(args).await.unwrap();
149
150        // Should find all .rs files including nested ones
151        assert!(result.count >= 4); // test.rs, main.rs, lib.rs, nested.rs
152        assert!(
153            result
154                .matches
155                .iter()
156                .all(|p| { std::path::Path::new(p).extension().is_some_and(|ext| ext.eq_ignore_ascii_case("rs")) })
157        );
158    }
159
160    #[tokio::test]
161    async fn test_validation_error_invalid_path() {
162        let args = FindInput { pattern: "**/*.rs".to_string(), path: Some("/nonexistent/path".to_string()) };
163
164        let result = find_files_by_name(args).await;
165        assert!(matches!(result, Err(FindError::PathNotFound(_))));
166    }
167
168    #[tokio::test]
169    async fn test_default_path() {
170        // Test that path defaults to current directory
171        let args = FindInput { pattern: "**/*.rs".to_string(), path: None };
172
173        let result = find_files_by_name(args).await;
174        // Should not error, even if no matches found
175        assert!(result.is_ok());
176    }
177
178    #[tokio::test]
179    async fn test_results_are_sorted() {
180        let temp_dir = TempDir::new().unwrap();
181        create_test_files(temp_dir.path()).unwrap();
182
183        let args =
184            FindInput { pattern: "**/*.rs".to_string(), path: Some(temp_dir.path().to_string_lossy().to_string()) };
185
186        let result = find_files_by_name(args).await.unwrap();
187
188        // Verify results are sorted
189        let sorted: Vec<String> = {
190            let mut v = result.matches.clone();
191            v.sort();
192            v
193        };
194        assert_eq!(result.matches, sorted);
195    }
196}