use super::CodeTool;
use super::ToolError;
use ignore::WalkBuilder;
use ignore::WalkState;
use regex_lite::Regex;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use std::time::Duration;
use std::time::SystemTime;
use wildmatch::WildMatch;
#[derive(Debug, Clone, Default)]
pub struct FdFind {
pub default_max_depth: Option<usize>,
pub default_threads: Option<usize>,
}
#[derive(Debug, Clone)]
pub struct FdQuery {
pub base_dir: PathBuf,
pub glob_patterns: Vec<String>,
pub regex_pattern: Option<String>,
pub file_type: FileTypeFilter,
pub size_filter: Option<SizeFilter>,
pub max_depth: Option<usize>,
pub include_hidden: bool,
pub follow_links: bool,
pub case_sensitive: bool,
pub max_results: usize,
pub timeout: Option<Duration>,
pub threads: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileTypeFilter {
All,
FilesOnly,
DirectoriesOnly,
ExecutableOnly,
SymlinksOnly,
EmptyOnly,
}
#[derive(Debug, Clone)]
pub struct SizeFilter {
pub min_size: Option<u64>,
pub max_size: Option<u64>,
}
#[derive(Debug, Clone)]
pub struct FdResult {
pub path: PathBuf,
pub file_type: FdFileType,
pub size: Option<u64>,
pub modified: Option<SystemTime>,
pub executable: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FdFileType {
File,
Directory,
Symlink,
Other,
}
#[derive(Debug)]
struct SearchState {
results: Arc<Mutex<Vec<FdResult>>>,
cancelled: Arc<AtomicBool>,
processed: Arc<AtomicUsize>,
max_results: usize,
start_time: SystemTime,
timeout: Option<Duration>,
}
#[derive(Debug)]
struct CompiledFilters {
glob_matchers: Vec<WildMatch>,
regex: Option<Regex>,
size_filter: Option<SizeFilter>,
file_type: FileTypeFilter,
}
impl Default for FdQuery {
fn default() -> Self {
Self {
base_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
glob_patterns: Vec::new(),
regex_pattern: None,
file_type: FileTypeFilter::All,
size_filter: None,
max_depth: None,
include_hidden: false,
follow_links: false,
case_sensitive: true,
max_results: 0, timeout: Some(Duration::from_secs(30)), threads: None, }
}
}
impl FdFind {
pub const fn new() -> Self {
Self {
default_max_depth: Some(32), default_threads: None, }
}
pub const fn with_defaults(max_depth: Option<usize>, threads: Option<usize>) -> Self {
Self {
default_max_depth: max_depth,
default_threads: threads,
}
}
pub fn find_files_by_extension<P: AsRef<Path>>(
&self,
base_dir: P,
extensions: &[&str],
) -> Result<Vec<FdResult>, ToolError> {
let patterns: Vec<String> = extensions
.iter()
.map(|ext| format!("**/*.{}", ext.trim_start_matches('.')))
.collect();
let query = FdQuery {
base_dir: base_dir.as_ref().to_path_buf(),
glob_patterns: patterns,
file_type: FileTypeFilter::FilesOnly,
..Default::default()
};
self.search(query)
}
pub fn find_directories_by_name<P: AsRef<Path>>(
&self,
base_dir: P,
name_pattern: &str,
) -> Result<Vec<FdResult>, ToolError> {
let query = FdQuery {
base_dir: base_dir.as_ref().to_path_buf(),
glob_patterns: vec![format!("**/{}", name_pattern)],
file_type: FileTypeFilter::DirectoriesOnly,
..Default::default()
};
self.search(query)
}
pub fn find_modified_since<P: AsRef<Path>>(
&self,
base_dir: P,
since: SystemTime,
) -> Result<Vec<FdResult>, ToolError> {
let query = FdQuery {
base_dir: base_dir.as_ref().to_path_buf(),
file_type: FileTypeFilter::FilesOnly,
..Default::default()
};
let results = self.search(query)?;
Ok(results
.into_iter()
.filter(|r| r.modified.map(|modified| modified > since).unwrap_or(false))
.collect())
}
pub fn find_by_content_type<P: AsRef<Path>>(
&self,
base_dir: P,
content_type: ContentType,
) -> Result<Vec<FdResult>, ToolError> {
let extensions = match content_type {
ContentType::Source => {
vec![
"rs", "py", "js", "ts", "jsx", "tsx", "go", "java", "c", "cpp", "cc", "cxx",
"h", "hpp", "cs", "php", "rb", "scala", "kt", "swift", "zig", "odin",
]
}
ContentType::Config => {
vec![
"toml", "yaml", "yml", "json", "xml", "ini", "conf", "config", "env",
]
}
ContentType::Documentation => {
vec!["md", "rst", "txt", "adoc", "org", "tex", "html"]
}
ContentType::Build => {
vec![
"Makefile",
"makefile",
"Dockerfile",
"dockerfile",
"BUILD",
"build",
"cmake",
"meson",
"ninja",
"gradle",
"pom",
]
}
};
self.find_files_by_extension(base_dir, &extensions)
}
fn compile_filters(&self, query: &FdQuery) -> Result<CompiledFilters, ToolError> {
let mut glob_matchers = Vec::new();
for pattern in &query.glob_patterns {
let matcher = if query.case_sensitive {
WildMatch::new(pattern)
} else {
WildMatch::new(&pattern.to_lowercase())
};
glob_matchers.push(matcher);
}
let regex = if let Some(ref pattern) = query.regex_pattern {
Some(
regex_lite::RegexBuilder::new(pattern)
.case_insensitive(!query.case_sensitive)
.build()
.map_err(|e| ToolError::InvalidQuery(format!("Invalid regex: {}", e)))?,
)
} else {
None
};
Ok(CompiledFilters {
glob_matchers,
regex,
size_filter: query.size_filter.clone(),
file_type: query.file_type.clone(),
})
}
fn matches_filters(
&self,
path: &Path,
metadata: &std::fs::Metadata,
filters: &CompiledFilters,
case_sensitive: bool,
) -> bool {
match filters.file_type {
FileTypeFilter::FilesOnly => {
if !metadata.is_file() {
return false;
}
}
FileTypeFilter::DirectoriesOnly => {
if !metadata.is_dir() {
return false;
}
}
FileTypeFilter::SymlinksOnly => {
if !metadata.file_type().is_symlink() {
return false;
}
}
FileTypeFilter::ExecutableOnly => {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if metadata.permissions().mode() & 0o111 == 0 {
return false;
}
}
#[cfg(not(unix))]
{
if let Some(ext) = path.extension() {
let ext_str = ext.to_string_lossy().to_lowercase();
if !["exe", "bat", "cmd", "com"].contains(&ext_str.as_str()) {
return false;
}
} else {
return false;
}
}
}
FileTypeFilter::EmptyOnly => {
if metadata.is_file() && metadata.len() > 0 {
return false;
}
if metadata.is_dir() {
if let Ok(mut entries) = std::fs::read_dir(path)
&& entries.next().is_some()
{
return false;
}
}
}
FileTypeFilter::All => {} }
if let Some(ref size_filter) = filters.size_filter {
let file_size = metadata.len();
if let Some(min_size) = size_filter.min_size
&& file_size < min_size
{
return false;
}
if let Some(max_size) = size_filter.max_size
&& file_size > max_size
{
return false;
}
}
if !filters.glob_matchers.is_empty() {
let path_str = path.to_string_lossy();
let test_str = if case_sensitive {
path_str.as_ref()
} else {
&path_str.to_lowercase()
};
let matches_glob = filters
.glob_matchers
.iter()
.any(|matcher| matcher.matches(test_str));
if !matches_glob {
return false;
}
}
if let Some(ref regex) = filters.regex {
let path_str = path.to_string_lossy();
if !regex.is_match(&path_str) {
return false;
}
}
true
}
fn create_result(&self, path: PathBuf, metadata: std::fs::Metadata) -> FdResult {
let file_type = if metadata.is_file() {
FdFileType::File
} else if metadata.is_dir() {
FdFileType::Directory
} else if metadata.file_type().is_symlink() {
FdFileType::Symlink
} else {
FdFileType::Other
};
let size = if metadata.is_file() {
Some(metadata.len())
} else {
None
};
let modified = metadata.modified().ok();
let executable = {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
{
if let Some(ext) = path.extension() {
let ext_str = ext.to_string_lossy().to_lowercase();
["exe", "bat", "cmd", "com"].contains(&ext_str.as_str())
} else {
false
}
}
};
FdResult {
path,
file_type,
size,
modified,
executable,
}
}
fn search_internal(&self, mut query: FdQuery) -> Result<Vec<FdResult>, ToolError> {
if query.max_depth.is_none() {
query.max_depth = self.default_max_depth;
}
if query.threads.is_none() {
query.threads = self.default_threads;
}
if !query.base_dir.exists() {
return Err(ToolError::InvalidQuery(format!(
"Base directory does not exist: {}",
query.base_dir.display()
)));
}
let filters = self.compile_filters(&query)?;
let search_state = SearchState {
results: Arc::new(Mutex::new(Vec::new())),
cancelled: Arc::new(AtomicBool::new(false)),
processed: Arc::new(AtomicUsize::new(0)),
max_results: query.max_results,
start_time: SystemTime::now(),
timeout: query.timeout,
};
let mut builder = WalkBuilder::new(&query.base_dir);
builder
.hidden(!query.include_hidden)
.follow_links(query.follow_links)
.git_ignore(true) .git_exclude(true)
.git_global(true);
if let Some(max_depth) = query.max_depth {
builder.max_depth(Some(max_depth));
}
let thread_count = query.threads.unwrap_or_else(|| {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(4)
});
builder.threads(thread_count);
let state_clone = search_state.clone();
let filters = Arc::new(filters);
let case_sensitive = query.case_sensitive;
builder.build_parallel().run(|| {
let state = state_clone.clone();
let filters = filters.clone();
let fd_find = self.clone();
Box::new(move |result| {
if state.cancelled.load(Ordering::Relaxed) {
return WalkState::Quit;
}
if let Some(timeout) = state.timeout
&& state.start_time.elapsed().unwrap_or(Duration::ZERO) > timeout
{
state.cancelled.store(true, Ordering::Relaxed);
return WalkState::Quit;
}
match result {
Ok(entry) => {
let path = entry.path();
let metadata = match entry.metadata() {
Ok(meta) => meta,
Err(_) => return WalkState::Continue,
};
if fd_find.matches_filters(path, &metadata, &filters, case_sensitive) {
let result = fd_find.create_result(path.to_path_buf(), metadata);
{
let mut results = state.results.lock().unwrap();
results.push(result);
if state.max_results > 0 && results.len() >= state.max_results {
state.cancelled.store(true, Ordering::Relaxed);
return WalkState::Quit;
}
}
}
state.processed.fetch_add(1, Ordering::Relaxed);
WalkState::Continue
}
Err(_) => WalkState::Continue, }
})
});
let mut results = search_state.results.lock().unwrap().clone();
if query.max_results > 0 && results.len() > query.max_results {
results.truncate(query.max_results);
}
Ok(results)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ContentType {
Source,
Config,
Documentation,
Build,
}
impl Clone for SearchState {
fn clone(&self) -> Self {
Self {
results: self.results.clone(),
cancelled: self.cancelled.clone(),
processed: self.processed.clone(),
max_results: self.max_results,
start_time: self.start_time,
timeout: self.timeout,
}
}
}
impl CodeTool for FdFind {
type Query = FdQuery;
type Output = Vec<FdResult>;
fn search(&self, query: Self::Query) -> Result<Self::Output, ToolError> {
self.search_internal(query)
}
}
impl FdFind {
pub fn search_paths(&self, query: FdQuery) -> Result<Vec<String>, ToolError> {
let results = self.search(query)?;
Ok(results
.into_iter()
.map(|r| r.path.to_string_lossy().to_string())
.collect())
}
}
impl FdQuery {
pub fn new<P: AsRef<Path>>(base_dir: P) -> Self {
Self {
base_dir: base_dir.as_ref().to_path_buf(),
..Default::default()
}
}
pub fn glob(mut self, pattern: &str) -> Self {
self.glob_patterns.push(pattern.to_string());
self
}
pub fn globs(mut self, patterns: &[&str]) -> Self {
self.glob_patterns
.extend(patterns.iter().map(|s| (*s).to_string()));
self
}
pub fn regex(mut self, pattern: &str) -> Self {
self.regex_pattern = Some(pattern.to_string());
self
}
pub const fn file_type(mut self, file_type: FileTypeFilter) -> Self {
self.file_type = file_type;
self
}
pub const fn size_range(mut self, min: Option<u64>, max: Option<u64>) -> Self {
self.size_filter = Some(SizeFilter {
min_size: min,
max_size: max,
});
self
}
pub const fn max_depth(mut self, depth: usize) -> Self {
self.max_depth = Some(depth);
self
}
pub const fn include_hidden(mut self, include: bool) -> Self {
self.include_hidden = include;
self
}
pub const fn follow_links(mut self, follow: bool) -> Self {
self.follow_links = follow;
self
}
pub const fn case_sensitive(mut self, sensitive: bool) -> Self {
self.case_sensitive = sensitive;
self
}
pub const fn max_results(mut self, max: usize) -> Self {
self.max_results = max;
self
}
pub const fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
pub const fn threads(mut self, threads: usize) -> Self {
self.threads = Some(threads);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn create_test_structure() -> TempDir {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
fs::create_dir_all(base.join("src")).unwrap();
fs::create_dir_all(base.join("target/debug")).unwrap();
fs::create_dir_all(base.join(".git")).unwrap();
fs::create_dir_all(base.join("docs")).unwrap();
fs::write(base.join("src/main.rs"), "fn main() {}").unwrap();
fs::write(base.join("src/lib.rs"), "pub mod test;").unwrap();
fs::write(base.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
fs::write(base.join("README.md"), "# Test Project").unwrap();
fs::write(base.join(".gitignore"), "target/").unwrap();
fs::write(base.join("target/debug/test"), "binary").unwrap();
fs::write(base.join("docs/guide.md"), "# Guide").unwrap();
fs::write(base.join(".hidden"), "hidden content").unwrap();
temp_dir
}
#[test]
fn test_basic_search() {
let temp_dir = create_test_structure();
let fd_find = FdFind::new();
let query = FdQuery::new(temp_dir.path()).file_type(FileTypeFilter::FilesOnly);
let results = fd_find.search(query).unwrap();
assert!(!results.is_empty());
let paths: Vec<_> = results.iter().map(|r| &r.path).collect();
assert!(paths.iter().any(|p| p.file_name().unwrap() == "main.rs"));
assert!(paths.iter().any(|p| p.file_name().unwrap() == "Cargo.toml"));
}
#[test]
fn test_glob_pattern_search() {
let temp_dir = create_test_structure();
let fd_find = FdFind::new();
let query = FdQuery::new(temp_dir.path())
.globs(&["*.rs", "*.toml"])
.file_type(FileTypeFilter::FilesOnly);
let results = fd_find.search(query).unwrap();
for result in &results {
let filename = result.path.file_name().unwrap().to_string_lossy();
assert!(filename.ends_with(".rs") || filename.ends_with(".toml"));
}
}
#[test]
fn test_find_files_by_extension() {
let temp_dir = create_test_structure();
let fd_find = FdFind::new();
let results = fd_find
.find_files_by_extension(temp_dir.path(), &["rs", "md"])
.unwrap();
assert!(!results.is_empty());
for result in &results {
let ext = result.path.extension().unwrap().to_string_lossy();
assert!(ext == "rs" || ext == "md");
}
}
#[test]
fn test_find_directories_by_name() {
let temp_dir = create_test_structure();
let fd_find = FdFind::new();
let results = fd_find
.find_directories_by_name(temp_dir.path(), "src")
.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].file_type, FdFileType::Directory);
assert_eq!(results[0].path.file_name().unwrap(), "src");
}
#[test]
fn test_regex_search() {
let temp_dir = create_test_structure();
let fd_find = FdFind::new();
let query = FdQuery::new(temp_dir.path())
.regex(r".*\.(rs|toml)$")
.file_type(FileTypeFilter::FilesOnly);
let results = fd_find.search(query).unwrap();
for result in &results {
let filename = result.path.to_string_lossy();
assert!(filename.ends_with(".rs") || filename.ends_with(".toml"));
}
}
#[test]
fn test_max_results_limit() {
let temp_dir = create_test_structure();
let fd_find = FdFind::new();
let query = FdQuery::new(temp_dir.path())
.file_type(FileTypeFilter::All)
.max_results(3);
let results = fd_find.search(query).unwrap();
assert!(results.len() <= 3);
}
#[test]
fn test_hidden_files() {
let temp_dir = create_test_structure();
let fd_find = FdFind::new();
let query_no_hidden = FdQuery::new(temp_dir.path())
.file_type(FileTypeFilter::FilesOnly)
.include_hidden(false);
let results_no_hidden = fd_find.search(query_no_hidden).unwrap();
let hidden_found = results_no_hidden
.iter()
.any(|r| r.path.file_name().unwrap() == ".hidden");
assert!(!hidden_found);
let query_with_hidden = FdQuery::new(temp_dir.path())
.file_type(FileTypeFilter::FilesOnly)
.include_hidden(true);
let results_with_hidden = fd_find.search(query_with_hidden).unwrap();
let hidden_found = results_with_hidden
.iter()
.any(|r| r.path.file_name().unwrap() == ".hidden");
assert!(hidden_found);
}
#[test]
fn test_depth_limiting() {
let temp_dir = create_test_structure();
let fd_find = FdFind::new();
let query = FdQuery::new(temp_dir.path())
.file_type(FileTypeFilter::All)
.max_depth(1);
let results = fd_find.search(query).unwrap();
let deep_file_found = results
.iter()
.any(|r| r.path.file_name().unwrap() == "main.rs");
assert!(!deep_file_found);
}
#[test]
fn test_size_filtering() {
let temp_dir = create_test_structure();
let fd_find = FdFind::new();
let large_content = "x".repeat(1024); fs::write(temp_dir.path().join("large.txt"), &large_content).unwrap();
let query = FdQuery::new(temp_dir.path())
.file_type(FileTypeFilter::FilesOnly)
.size_range(Some(500), Some(2000));
let results = fd_find.search(query).unwrap();
let large_found = results
.iter()
.any(|r| r.path.file_name().unwrap() == "large.txt");
assert!(large_found);
let large_result = results
.iter()
.find(|r| r.path.file_name().unwrap() == "large.txt")
.unwrap();
assert!(large_result.size.unwrap() >= 500);
}
#[test]
fn test_content_type_search() {
let temp_dir = create_test_structure();
let fd_find = FdFind::new();
let results = fd_find
.find_by_content_type(temp_dir.path(), ContentType::Source)
.unwrap();
let rust_found = results.iter().any(|r| r.path.extension().unwrap() == "rs");
assert!(rust_found);
}
#[test]
fn test_case_sensitivity() {
let temp_dir = create_test_structure();
fs::write(temp_dir.path().join("Test.TXT"), "content").unwrap();
fs::write(temp_dir.path().join("test.txt"), "content").unwrap();
let fd_find = FdFind::new();
let query_sensitive = FdQuery::new(temp_dir.path())
.glob("*.TXT")
.case_sensitive(true);
let results_sensitive = fd_find.search(query_sensitive).unwrap();
assert_eq!(results_sensitive.len(), 1);
assert_eq!(results_sensitive[0].path.file_name().unwrap(), "Test.TXT");
let query_insensitive = FdQuery::new(temp_dir.path())
.glob("*.txt")
.case_sensitive(false);
let results_insensitive = fd_find.search(query_insensitive).unwrap();
assert_eq!(results_insensitive.len(), 2);
}
#[test]
fn test_builder_pattern() {
let temp_dir = create_test_structure();
let fd_find = FdFind::new();
let query = FdQuery::new(temp_dir.path())
.globs(&["*.rs", "*.toml"])
.file_type(FileTypeFilter::FilesOnly)
.max_depth(5)
.include_hidden(false)
.case_sensitive(true)
.max_results(10)
.timeout(Duration::from_secs(5));
let results = fd_find.search(query).unwrap();
assert!(!results.is_empty());
}
#[test]
fn test_error_handling() {
let fd_find = FdFind::new();
let query = FdQuery::new("/non/existent/path");
let result = fd_find.search(query);
assert!(result.is_err());
let temp_dir = create_test_structure();
let query = FdQuery::new(temp_dir.path()).regex("[invalid regex");
let result = fd_find.search(query);
assert!(result.is_err());
}
}