use crate::coding::error::FindError;
use crate::coding::tools::glob_filter::{CaseSensitivity, PathGlobMatcher};
use ignore::{WalkBuilder, WalkState};
use mcp_utils::display_meta::{ToolDisplayMeta, ToolResultMeta};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::{
path::Path,
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
};
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct FindInput {
pub pattern: String,
pub path: Option<String>,
pub limit: Option<usize>,
#[serde(alias = "include_hidden")]
pub include_hidden: Option<bool>,
#[serde(alias = "case_insensitive")]
pub case_insensitive: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct FindOutput {
pub matches: Vec<String>,
pub count: usize,
pub truncated: bool,
pub search_path: String,
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
#[schemars(skip)]
pub meta: Option<ToolResultMeta>,
}
pub async fn find_files(args: FindInput) -> Result<FindOutput, FindError> {
let search_path = args.path.as_deref().unwrap_or(".");
let search_root = Path::new(search_path);
if !search_root.exists() {
return Err(FindError::PathNotFound(search_path.to_string()));
}
let path_matcher =
Arc::new(PathGlobMatcher::new(&args.pattern, CaseSensitivity::from_optional(args.case_insensitive))?);
let state = Arc::new(FindState::new(args.limit));
let mut walker_builder = WalkBuilder::new(search_root);
walker_builder.hidden(!args.include_hidden.unwrap_or(false)).git_ignore(true).follow_links(false);
walker_builder.build_parallel().run(|| {
let path_matcher = path_matcher.clone();
let state = state.clone();
Box::new(move |result| {
if state.limit_reached() {
return WalkState::Quit;
}
let Ok(entry) = result else {
return WalkState::Continue;
};
if !entry.file_type().is_some_and(|file_type| file_type.is_file()) {
return WalkState::Continue;
}
if path_matcher.matches(entry.path(), search_root) {
state.push(entry.path().to_string_lossy().to_string());
}
if state.limit_reached() { WalkState::Quit } else { WalkState::Continue }
})
});
let (matches, truncated) = state.results()?;
let count = matches.len();
let display_meta = ToolDisplayMeta::new(
"Find files",
if truncated {
format!("'{}' ({count}+ files)", args.pattern)
} else {
format!("'{}' ({count} files)", args.pattern)
},
);
Ok(FindOutput { matches, count, truncated, search_path: search_path.to_string(), meta: Some(display_meta.into()) })
}
struct FindState {
matches: Mutex<Vec<String>>,
limit: Option<usize>,
limit_reached: AtomicBool,
}
impl FindState {
fn new(limit: Option<usize>) -> Self {
Self { matches: Mutex::new(Vec::new()), limit, limit_reached: AtomicBool::new(false) }
}
fn push(&self, path: String) {
if self.limit_reached() {
return;
}
let Ok(mut matches) = self.matches.lock() else {
return;
};
let capacity = self.capacity();
if matches.len() < capacity {
matches.push(path);
}
if matches.len() >= capacity {
self.limit_reached.store(true, Ordering::Release);
}
}
fn limit_reached(&self) -> bool {
self.limit_reached.load(Ordering::Acquire)
}
fn results(&self) -> Result<(Vec<String>, bool), FindError> {
let mut matches = self.matches.lock().map(|matches| matches.clone()).map_err(|_| FindError::LockFailed)?;
matches.sort();
let truncated = self.limit.is_some_and(|limit| matches.len() > limit);
if let Some(limit) = self.limit {
matches.truncate(limit);
}
Ok((matches, truncated))
}
fn capacity(&self) -> usize {
self.limit.map_or(usize::MAX, |limit| limit.saturating_add(1))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::{self, File};
use tempfile::TempDir;
#[tokio::test]
async fn test_exact_pattern_match() {
let result = FindTest::new().with_file("test.rs").find("**/test.rs").await;
assert_eq!(result.count, 1);
assert!(result.matches[0].ends_with("test.rs"));
}
#[tokio::test]
async fn test_glob_wildcard_pattern() {
let result = FindTest::new()
.with_file("test.rs")
.with_file("main.rs")
.with_file("lib.rs")
.with_file("notes.txt")
.with_file("subdir/nested.rs")
.find("**/*.rs")
.await;
assert_eq!(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_bare_exact_filename_matches_any_depth() {
let result = FindTest::new().with_file("justfile").with_file("subdir/justfile").find("justfile").await;
assert_eq!(result.count, 2);
assert!(result.matches.iter().any(|p| p.ends_with("justfile")));
assert!(result.matches.iter().any(|p| p.ends_with("subdir/justfile")));
}
#[tokio::test]
async fn test_bare_prefix_glob_matches_basename() {
let result = FindTest::new().with_file("README.md").with_file("docs/README.adoc").find("README*").await;
assert_eq!(result.count, 2);
assert!(result.matches.iter().any(|p| p.ends_with("README.md")));
assert!(result.matches.iter().any(|p| p.ends_with("docs/README.adoc")));
}
#[tokio::test]
async fn test_bare_glob_matches_nested_files() {
let result = FindTest::new()
.with_file("tsconfig.base.json")
.with_file("packages/foo/tsconfig.json")
.find("tsconfig*.json")
.await;
assert_eq!(result.count, 2);
assert!(result.matches.iter().any(|p| p.ends_with("tsconfig.base.json")));
assert!(result.matches.iter().any(|p| p.ends_with("packages/foo/tsconfig.json")));
}
#[tokio::test]
async fn test_relative_path_pattern_matches_relative_to_search_root() {
let result =
FindTest::new().with_file("crates/example/src/lib.rs").with_file("lib.rs").find("crates/**/*.rs").await;
assert_eq!(result.count, 1);
assert!(result.matches[0].ends_with("crates/example/src/lib.rs"));
}
#[tokio::test]
async fn test_limit_truncates_results() {
let test = FindTest::new().with_file("one.rs").with_file("two.rs").with_file("three.rs");
let result =
test.find_with(FindInput { pattern: "*.rs".to_string(), limit: Some(2), ..FindInput::default() }).await;
assert_eq!(result.count, 2);
assert!(result.truncated);
}
#[tokio::test]
async fn test_limit_not_reached_returns_all_matches() {
let test = FindTest::new().with_file("one.rs").with_file("two.rs");
let result =
test.find_with(FindInput { pattern: "*.rs".to_string(), limit: Some(10), ..FindInput::default() }).await;
assert_eq!(result.count, 2);
assert!(!result.truncated);
}
#[tokio::test]
async fn test_zero_limit_detects_truncation_without_returning_matches() {
let test = FindTest::new().with_file("one.rs");
let result =
test.find_with(FindInput { pattern: "*.rs".to_string(), limit: Some(0), ..FindInput::default() }).await;
assert_eq!(result.count, 0);
assert!(result.truncated);
}
#[tokio::test]
async fn test_hidden_files_are_skipped_by_default() {
let result = FindTest::new().with_file(".aether/settings.json").find("settings.json").await;
assert_eq!(result.count, 0);
}
#[tokio::test]
async fn test_include_hidden_finds_hidden_files() {
let test = FindTest::new().with_file(".aether/settings.json");
let result = test
.find_with(FindInput {
pattern: "settings.json".to_string(),
include_hidden: Some(true),
..FindInput::default()
})
.await;
assert_eq!(result.count, 1);
assert!(result.matches[0].ends_with(".aether/settings.json"));
}
#[tokio::test]
async fn test_case_insensitive_matching() {
let test = FindTest::new().with_file("README.md");
let result = test
.find_with(FindInput {
pattern: "readme*".to_string(),
case_insensitive: Some(true),
..FindInput::default()
})
.await;
assert_eq!(result.count, 1);
assert!(result.matches[0].ends_with("README.md"));
}
#[tokio::test]
async fn test_validation_error_invalid_path() {
let args = FindInput {
pattern: "**/*.rs".to_string(),
path: Some("/nonexistent/path".to_string()),
..FindInput::default()
};
let result = find_files(args).await;
assert!(matches!(result, Err(FindError::PathNotFound(_))));
}
#[tokio::test]
async fn test_validation_error_empty_pattern() {
let result = FindTest::new().find_result(FindInput { pattern: String::new(), ..FindInput::default() }).await;
assert!(matches!(result, Err(FindError::Glob(_))));
}
#[tokio::test]
async fn test_default_path() {
let args = FindInput { pattern: "**/*.rs".to_string(), path: None, ..FindInput::default() };
let result = find_files(args).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_results_are_sorted() {
let result = FindTest::new().with_file("c.rs").with_file("a.rs").with_file("b.rs").find("**/*.rs").await;
let sorted: Vec<String> = {
let mut v = result.matches.clone();
v.sort();
v
};
assert_eq!(result.matches, sorted);
}
struct FindTest {
temp_dir: TempDir,
}
impl FindTest {
fn new() -> Self {
Self { temp_dir: TempDir::new().unwrap() }
}
fn with_file(self, path: &str) -> Self {
let file_path = self.temp_dir.path().join(path);
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent).unwrap();
}
File::create(file_path).unwrap();
self
}
async fn find(&self, pattern: &str) -> FindOutput {
self.find_with(FindInput { pattern: pattern.to_string(), ..FindInput::default() }).await
}
async fn find_with(&self, input: FindInput) -> FindOutput {
self.find_result(input).await.unwrap()
}
async fn find_result(&self, input: FindInput) -> Result<FindOutput, FindError> {
find_files(FindInput { path: Some(self.path_string()), ..input }).await
}
fn path_string(&self) -> String {
self.temp_dir.path().to_string_lossy().to_string()
}
}
}