mcp_servers/coding/tools/find/
mod.rs1use 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 pub pattern: String,
14 pub path: Option<String>,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
19#[serde(rename_all = "camelCase")]
20pub struct FindOutput {
21 pub matches: Vec<String>,
23 pub count: usize,
25 pub search_path: String,
27 #[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 if !Path::new(search_path).exists() {
38 return Err(FindError::PathNotFound(search_path.to_string()));
39 }
40
41 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 let mut walker_builder = WalkBuilder::new(search_path);
51 walker_builder.hidden(false).git_ignore(true).follow_links(false);
52
53 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 if let Some(file_type) = entry.file_type()
69 && !file_type.is_file()
70 {
71 return ignore::WalkState::Continue;
72 }
73
74 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 drop(matching_files_clone);
88 drop(glob_pattern_clone);
89
90 let mut matches = matching_files.lock().map_err(|_| FindError::LockFailed)?.clone();
92
93 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 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 assert!(result.count >= 4); 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 let args = FindInput { pattern: "**/*.rs".to_string(), path: None };
172
173 let result = find_files_by_name(args).await;
174 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 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}