use glob::Pattern;
use ignore::WalkBuilder;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::path::Path;
use crate::coding::error::FindError;
use mcp_utils::display_meta::{ToolDisplayMeta, ToolResultMeta};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct FindInput {
pub pattern: String,
pub path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct FindOutput {
pub matches: Vec<String>,
pub count: usize,
pub search_path: String,
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
#[schemars(skip)]
pub meta: Option<ToolResultMeta>,
}
pub async fn find_files_by_name(args: FindInput) -> Result<FindOutput, FindError> {
let search_path = args.path.as_deref().unwrap_or(".");
if !Path::new(search_path).exists() {
return Err(FindError::PathNotFound(search_path.to_string()));
}
let glob_pattern = match Pattern::new(&args.pattern) {
Ok(pattern) => pattern,
Err(e) => {
return Err(FindError::InvalidGlobPattern {
pattern: args.pattern,
reason: e.to_string(),
});
}
};
let mut walker_builder = WalkBuilder::new(search_path);
walker_builder
.hidden(false)
.git_ignore(true)
.follow_links(false);
let walker = walker_builder.build_parallel();
let matching_files = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
let matching_files_clone = matching_files.clone();
let glob_pattern_clone = std::sync::Arc::new(glob_pattern);
walker.run(|| {
let matching_files = matching_files_clone.clone();
let glob_pattern = glob_pattern_clone.clone();
Box::new(move |result| {
if let Ok(entry) = result {
let path = entry.path();
if let Some(file_type) = entry.file_type()
&& !file_type.is_file()
{
return ignore::WalkState::Continue;
}
let path_str = path.to_string_lossy();
if glob_pattern.matches(&path_str)
&& let Ok(mut files) = matching_files.lock()
{
files.push(path_str.to_string());
}
}
ignore::WalkState::Continue
})
});
drop(matching_files_clone);
drop(glob_pattern_clone);
let mut matches = matching_files
.lock()
.map_err(|_| FindError::LockFailed)?
.clone();
matches.sort();
let count = matches.len();
let display_meta =
ToolDisplayMeta::new("Find files", format!("'{}' ({count} files)", args.pattern));
Ok(FindOutput {
matches,
count,
search_path: search_path.to_string(),
meta: Some(display_meta.into()),
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::{self, File};
use std::path::Path;
use tempfile::TempDir;
fn create_test_files(temp_dir: &Path) -> Result<(), std::io::Error> {
File::create(temp_dir.join("test.rs"))?;
File::create(temp_dir.join("main.rs"))?;
File::create(temp_dir.join("lib.rs"))?;
File::create(temp_dir.join("example.txt"))?;
File::create(temp_dir.join("README.md"))?;
let sub_dir = temp_dir.join("subdir");
fs::create_dir(&sub_dir)?;
File::create(sub_dir.join("nested.rs"))?;
File::create(sub_dir.join("other.txt"))?;
Ok(())
}
#[tokio::test]
async fn test_exact_pattern_match() {
let temp_dir = TempDir::new().unwrap();
create_test_files(temp_dir.path()).unwrap();
let args = FindInput {
pattern: "**/test.rs".to_string(),
path: Some(temp_dir.path().to_string_lossy().to_string()),
};
let result = find_files_by_name(args).await.unwrap();
assert_eq!(result.count, 1);
assert!(result.matches[0].ends_with("test.rs"));
}
#[tokio::test]
async fn test_glob_wildcard_pattern() {
let temp_dir = TempDir::new().unwrap();
create_test_files(temp_dir.path()).unwrap();
let args = FindInput {
pattern: "**/*.rs".to_string(),
path: Some(temp_dir.path().to_string_lossy().to_string()),
};
let result = find_files_by_name(args).await.unwrap();
assert!(result.count >= 4); assert!(result.matches.iter().all(|p| {
std::path::Path::new(p)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("rs"))
}));
}
#[tokio::test]
async fn test_validation_error_invalid_path() {
let args = FindInput {
pattern: "**/*.rs".to_string(),
path: Some("/nonexistent/path".to_string()),
};
let result = find_files_by_name(args).await;
assert!(matches!(result, Err(FindError::PathNotFound(_))));
}
#[tokio::test]
async fn test_default_path() {
let args = FindInput {
pattern: "**/*.rs".to_string(),
path: None,
};
let result = find_files_by_name(args).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_results_are_sorted() {
let temp_dir = TempDir::new().unwrap();
create_test_files(temp_dir.path()).unwrap();
let args = FindInput {
pattern: "**/*.rs".to_string(),
path: Some(temp_dir.path().to_string_lossy().to_string()),
};
let result = find_files_by_name(args).await.unwrap();
let sorted: Vec<String> = {
let mut v = result.matches.clone();
v.sort();
v
};
assert_eq!(result.matches, sorted);
}
}