agcodex_core/code_tools/
fd_find.rs

1//! Native fd-find integration using ignore::WalkBuilder for AGCodex.
2//!
3//! This module provides high-performance file discovery with:
4//! - Parallel directory traversal via rayon
5//! - Advanced filtering (glob, regex, type, size, depth)
6//! - .gitignore respect by default
7//! - Cancellation support for long-running searches
8//! - Memory-efficient streaming results
9
10use super::CodeTool;
11use super::ToolError;
12use ignore::WalkBuilder;
13use ignore::WalkState;
14// Rayon prelude removed - not currently used
15use regex_lite::Regex;
16use std::path::Path;
17use std::path::PathBuf;
18use std::sync::Arc;
19use std::sync::Mutex;
20use std::sync::atomic::AtomicBool;
21use std::sync::atomic::AtomicUsize;
22use std::sync::atomic::Ordering;
23use std::time::Duration;
24use std::time::SystemTime;
25use wildmatch::WildMatch;
26
27/// High-performance fd-find replacement using ignore crate
28#[derive(Debug, Clone, Default)]
29pub struct FdFind {
30    /// Default maximum search depth (prevents runaway searches)
31    pub default_max_depth: Option<usize>,
32    /// Default thread count (None = auto-detect)
33    pub default_threads: Option<usize>,
34}
35
36/// Comprehensive search query for file discovery
37#[derive(Debug, Clone)]
38pub struct FdQuery {
39    /// Base directory to search from
40    pub base_dir: PathBuf,
41    /// Glob patterns to match (e.g., "*.rs", "**/*.js")
42    pub glob_patterns: Vec<String>,
43    /// Regex pattern for advanced matching
44    pub regex_pattern: Option<String>,
45    /// File type filter
46    pub file_type: FileTypeFilter,
47    /// Size constraints
48    pub size_filter: Option<SizeFilter>,
49    /// Maximum search depth
50    pub max_depth: Option<usize>,
51    /// Include hidden files/directories
52    pub include_hidden: bool,
53    /// Follow symbolic links
54    pub follow_links: bool,
55    /// Case sensitive matching
56    pub case_sensitive: bool,
57    /// Maximum number of results (0 = unlimited)
58    pub max_results: usize,
59    /// Search timeout
60    pub timeout: Option<Duration>,
61    /// Number of parallel threads (None = auto-detect)
62    pub threads: Option<usize>,
63}
64
65/// File type filtering options
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum FileTypeFilter {
68    /// All files and directories
69    All,
70    /// Files only
71    FilesOnly,
72    /// Directories only
73    DirectoriesOnly,
74    /// Executable files only
75    ExecutableOnly,
76    /// Symbolic links only
77    SymlinksOnly,
78    /// Empty files/directories
79    EmptyOnly,
80}
81
82/// File size filtering
83#[derive(Debug, Clone)]
84pub struct SizeFilter {
85    pub min_size: Option<u64>,
86    pub max_size: Option<u64>,
87}
88
89/// Search result with metadata
90#[derive(Debug, Clone)]
91pub struct FdResult {
92    /// Full path to the found item
93    pub path: PathBuf,
94    /// File type
95    pub file_type: FdFileType,
96    /// File size in bytes (if applicable)
97    pub size: Option<u64>,
98    /// Last modified time
99    pub modified: Option<SystemTime>,
100    /// Whether the file is executable
101    pub executable: bool,
102}
103
104/// File type enumeration
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub enum FdFileType {
107    File,
108    Directory,
109    Symlink,
110    Other,
111}
112
113/// Internal search state for cancellation and progress tracking
114#[derive(Debug)]
115struct SearchState {
116    /// Results collector (thread-safe)
117    results: Arc<Mutex<Vec<FdResult>>>,
118    /// Cancellation flag
119    cancelled: Arc<AtomicBool>,
120    /// Number of items processed
121    processed: Arc<AtomicUsize>,
122    /// Maximum results limit
123    max_results: usize,
124    /// Search start time for timeout
125    start_time: SystemTime,
126    /// Search timeout
127    timeout: Option<Duration>,
128}
129
130/// Compiled search filters for performance
131#[derive(Debug)]
132struct CompiledFilters {
133    /// Compiled glob matchers
134    glob_matchers: Vec<WildMatch>,
135    /// Compiled regex
136    regex: Option<Regex>,
137    /// Size filter
138    size_filter: Option<SizeFilter>,
139    /// File type filter
140    file_type: FileTypeFilter,
141}
142
143impl Default for FdQuery {
144    fn default() -> Self {
145        Self {
146            base_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
147            glob_patterns: Vec::new(),
148            regex_pattern: None,
149            file_type: FileTypeFilter::All,
150            size_filter: None,
151            max_depth: None,
152            include_hidden: false,
153            follow_links: false,
154            case_sensitive: true,
155            max_results: 0,                         // unlimited
156            timeout: Some(Duration::from_secs(30)), // 30 second default timeout
157            threads: None,                          // auto-detect
158        }
159    }
160}
161
162impl FdFind {
163    /// Create a new FdFind instance with default settings
164    pub const fn new() -> Self {
165        Self {
166            default_max_depth: Some(32), // Reasonable default to prevent runaway
167            default_threads: None,       // Auto-detect based on CPU cores
168        }
169    }
170
171    /// Create FdFind with custom defaults
172    pub const fn with_defaults(max_depth: Option<usize>, threads: Option<usize>) -> Self {
173        Self {
174            default_max_depth: max_depth,
175            default_threads: threads,
176        }
177    }
178
179    /// Helper: Find files by extension
180    pub fn find_files_by_extension<P: AsRef<Path>>(
181        &self,
182        base_dir: P,
183        extensions: &[&str],
184    ) -> Result<Vec<FdResult>, ToolError> {
185        let patterns: Vec<String> = extensions
186            .iter()
187            .map(|ext| format!("**/*.{}", ext.trim_start_matches('.')))
188            .collect();
189
190        let query = FdQuery {
191            base_dir: base_dir.as_ref().to_path_buf(),
192            glob_patterns: patterns,
193            file_type: FileTypeFilter::FilesOnly,
194            ..Default::default()
195        };
196
197        self.search(query)
198    }
199
200    /// Helper: Find directories by name pattern
201    pub fn find_directories_by_name<P: AsRef<Path>>(
202        &self,
203        base_dir: P,
204        name_pattern: &str,
205    ) -> Result<Vec<FdResult>, ToolError> {
206        let query = FdQuery {
207            base_dir: base_dir.as_ref().to_path_buf(),
208            glob_patterns: vec![format!("**/{}", name_pattern)],
209            file_type: FileTypeFilter::DirectoriesOnly,
210            ..Default::default()
211        };
212
213        self.search(query)
214    }
215
216    /// Helper: Find files modified since a given time
217    pub fn find_modified_since<P: AsRef<Path>>(
218        &self,
219        base_dir: P,
220        since: SystemTime,
221    ) -> Result<Vec<FdResult>, ToolError> {
222        let query = FdQuery {
223            base_dir: base_dir.as_ref().to_path_buf(),
224            file_type: FileTypeFilter::FilesOnly,
225            ..Default::default()
226        };
227
228        let results = self.search(query)?;
229        Ok(results
230            .into_iter()
231            .filter(|r| r.modified.map(|modified| modified > since).unwrap_or(false))
232            .collect())
233    }
234
235    /// Helper: Find files by content type (based on extension)
236    pub fn find_by_content_type<P: AsRef<Path>>(
237        &self,
238        base_dir: P,
239        content_type: ContentType,
240    ) -> Result<Vec<FdResult>, ToolError> {
241        let extensions = match content_type {
242            ContentType::Source => {
243                vec![
244                    "rs", "py", "js", "ts", "jsx", "tsx", "go", "java", "c", "cpp", "cc", "cxx",
245                    "h", "hpp", "cs", "php", "rb", "scala", "kt", "swift", "zig", "odin",
246                ]
247            }
248            ContentType::Config => {
249                vec![
250                    "toml", "yaml", "yml", "json", "xml", "ini", "conf", "config", "env",
251                ]
252            }
253            ContentType::Documentation => {
254                vec!["md", "rst", "txt", "adoc", "org", "tex", "html"]
255            }
256            ContentType::Build => {
257                vec![
258                    "Makefile",
259                    "makefile",
260                    "Dockerfile",
261                    "dockerfile",
262                    "BUILD",
263                    "build",
264                    "cmake",
265                    "meson",
266                    "ninja",
267                    "gradle",
268                    "pom",
269                ]
270            }
271        };
272
273        self.find_files_by_extension(base_dir, &extensions)
274    }
275
276    /// Compile search filters for performance
277    fn compile_filters(&self, query: &FdQuery) -> Result<CompiledFilters, ToolError> {
278        // Compile glob patterns
279        let mut glob_matchers = Vec::new();
280        for pattern in &query.glob_patterns {
281            let matcher = if query.case_sensitive {
282                WildMatch::new(pattern)
283            } else {
284                WildMatch::new(&pattern.to_lowercase())
285            };
286            glob_matchers.push(matcher);
287        }
288
289        // Compile regex if provided
290        let regex = if let Some(ref pattern) = query.regex_pattern {
291            Some(
292                regex_lite::RegexBuilder::new(pattern)
293                    .case_insensitive(!query.case_sensitive)
294                    .build()
295                    .map_err(|e| ToolError::InvalidQuery(format!("Invalid regex: {}", e)))?,
296            )
297        } else {
298            None
299        };
300
301        Ok(CompiledFilters {
302            glob_matchers,
303            regex,
304            size_filter: query.size_filter.clone(),
305            file_type: query.file_type.clone(),
306        })
307    }
308
309    /// Check if a path matches the compiled filters
310    fn matches_filters(
311        &self,
312        path: &Path,
313        metadata: &std::fs::Metadata,
314        filters: &CompiledFilters,
315        case_sensitive: bool,
316    ) -> bool {
317        // File type filtering
318        match filters.file_type {
319            FileTypeFilter::FilesOnly => {
320                if !metadata.is_file() {
321                    return false;
322                }
323            }
324            FileTypeFilter::DirectoriesOnly => {
325                if !metadata.is_dir() {
326                    return false;
327                }
328            }
329            FileTypeFilter::SymlinksOnly => {
330                if !metadata.file_type().is_symlink() {
331                    return false;
332                }
333            }
334            FileTypeFilter::ExecutableOnly => {
335                #[cfg(unix)]
336                {
337                    use std::os::unix::fs::PermissionsExt;
338                    if metadata.permissions().mode() & 0o111 == 0 {
339                        return false;
340                    }
341                }
342                #[cfg(not(unix))]
343                {
344                    // On non-Unix systems, check file extension
345                    if let Some(ext) = path.extension() {
346                        let ext_str = ext.to_string_lossy().to_lowercase();
347                        if !["exe", "bat", "cmd", "com"].contains(&ext_str.as_str()) {
348                            return false;
349                        }
350                    } else {
351                        return false;
352                    }
353                }
354            }
355            FileTypeFilter::EmptyOnly => {
356                if metadata.is_file() && metadata.len() > 0 {
357                    return false;
358                }
359                if metadata.is_dir() {
360                    // Check if directory is empty (expensive, but required for this filter)
361                    if let Ok(mut entries) = std::fs::read_dir(path)
362                        && entries.next().is_some()
363                    {
364                        return false;
365                    }
366                }
367            }
368            FileTypeFilter::All => {} // No filtering
369        }
370
371        // Size filtering
372        if let Some(ref size_filter) = filters.size_filter {
373            let file_size = metadata.len();
374            if let Some(min_size) = size_filter.min_size
375                && file_size < min_size
376            {
377                return false;
378            }
379            if let Some(max_size) = size_filter.max_size
380                && file_size > max_size
381            {
382                return false;
383            }
384        }
385
386        // Glob pattern matching
387        if !filters.glob_matchers.is_empty() {
388            let path_str = path.to_string_lossy();
389            let test_str = if case_sensitive {
390                path_str.as_ref()
391            } else {
392                &path_str.to_lowercase()
393            };
394
395            let matches_glob = filters
396                .glob_matchers
397                .iter()
398                .any(|matcher| matcher.matches(test_str));
399
400            if !matches_glob {
401                return false;
402            }
403        }
404
405        // Regex pattern matching
406        if let Some(ref regex) = filters.regex {
407            let path_str = path.to_string_lossy();
408            if !regex.is_match(&path_str) {
409                return false;
410            }
411        }
412
413        true
414    }
415
416    /// Convert std::fs::Metadata to FdResult
417    fn create_result(&self, path: PathBuf, metadata: std::fs::Metadata) -> FdResult {
418        let file_type = if metadata.is_file() {
419            FdFileType::File
420        } else if metadata.is_dir() {
421            FdFileType::Directory
422        } else if metadata.file_type().is_symlink() {
423            FdFileType::Symlink
424        } else {
425            FdFileType::Other
426        };
427
428        let size = if metadata.is_file() {
429            Some(metadata.len())
430        } else {
431            None
432        };
433
434        let modified = metadata.modified().ok();
435
436        let executable = {
437            #[cfg(unix)]
438            {
439                use std::os::unix::fs::PermissionsExt;
440                metadata.permissions().mode() & 0o111 != 0
441            }
442            #[cfg(not(unix))]
443            {
444                if let Some(ext) = path.extension() {
445                    let ext_str = ext.to_string_lossy().to_lowercase();
446                    ["exe", "bat", "cmd", "com"].contains(&ext_str.as_str())
447                } else {
448                    false
449                }
450            }
451        };
452
453        FdResult {
454            path,
455            file_type,
456            size,
457            modified,
458            executable,
459        }
460    }
461
462    /// Internal async search implementation
463    fn search_internal(&self, mut query: FdQuery) -> Result<Vec<FdResult>, ToolError> {
464        // Apply defaults
465        if query.max_depth.is_none() {
466            query.max_depth = self.default_max_depth;
467        }
468        if query.threads.is_none() {
469            query.threads = self.default_threads;
470        }
471
472        // Validate base directory
473        if !query.base_dir.exists() {
474            return Err(ToolError::InvalidQuery(format!(
475                "Base directory does not exist: {}",
476                query.base_dir.display()
477            )));
478        }
479
480        // Compile filters
481        let filters = self.compile_filters(&query)?;
482
483        // Set up search state
484        let search_state = SearchState {
485            results: Arc::new(Mutex::new(Vec::new())),
486            cancelled: Arc::new(AtomicBool::new(false)),
487            processed: Arc::new(AtomicUsize::new(0)),
488            max_results: query.max_results,
489            start_time: SystemTime::now(),
490            timeout: query.timeout,
491        };
492
493        // Configure WalkBuilder
494        let mut builder = WalkBuilder::new(&query.base_dir);
495        builder
496            .hidden(!query.include_hidden)
497            .follow_links(query.follow_links)
498            .git_ignore(true) // Respect .gitignore by default
499            .git_exclude(true)
500            .git_global(true);
501
502        if let Some(max_depth) = query.max_depth {
503            builder.max_depth(Some(max_depth));
504        }
505
506        // Configure parallelism
507        let thread_count = query.threads.unwrap_or_else(|| {
508            std::thread::available_parallelism()
509                .map(|n| n.get())
510                .unwrap_or(4)
511        });
512        builder.threads(thread_count);
513
514        // Clone state for the walker closure
515        let state_clone = search_state.clone();
516        let filters = Arc::new(filters);
517        let case_sensitive = query.case_sensitive;
518
519        // Execute parallel walk
520        builder.build_parallel().run(|| {
521            let state = state_clone.clone();
522            let filters = filters.clone();
523            let fd_find = self.clone();
524
525            Box::new(move |result| {
526                // Check for cancellation
527                if state.cancelled.load(Ordering::Relaxed) {
528                    return WalkState::Quit;
529                }
530
531                // Check timeout
532                if let Some(timeout) = state.timeout
533                    && state.start_time.elapsed().unwrap_or(Duration::ZERO) > timeout
534                {
535                    state.cancelled.store(true, Ordering::Relaxed);
536                    return WalkState::Quit;
537                }
538
539                match result {
540                    Ok(entry) => {
541                        let path = entry.path();
542
543                        // Get metadata
544                        let metadata = match entry.metadata() {
545                            Ok(meta) => meta,
546                            Err(_) => return WalkState::Continue,
547                        };
548
549                        // Apply filters
550                        if fd_find.matches_filters(path, &metadata, &filters, case_sensitive) {
551                            let result = fd_find.create_result(path.to_path_buf(), metadata);
552
553                            // Add to results (thread-safe)
554                            {
555                                let mut results = state.results.lock().unwrap();
556                                results.push(result);
557
558                                // Check max results limit
559                                if state.max_results > 0 && results.len() >= state.max_results {
560                                    state.cancelled.store(true, Ordering::Relaxed);
561                                    return WalkState::Quit;
562                                }
563                            }
564                        }
565
566                        // Update processed count
567                        state.processed.fetch_add(1, Ordering::Relaxed);
568                        WalkState::Continue
569                    }
570                    Err(_) => WalkState::Continue, // Skip errors, continue walking
571                }
572            })
573        });
574
575        // Extract final results
576        // Use clone and lock instead of try_unwrap to avoid Arc reference issues
577        let mut results = search_state.results.lock().unwrap().clone();
578
579        // Enforce max_results limit (in case parallel threads added extra results)
580        if query.max_results > 0 && results.len() > query.max_results {
581            results.truncate(query.max_results);
582        }
583
584        Ok(results)
585    }
586}
587
588/// Content type categories for easier searching
589#[derive(Debug, Clone, PartialEq, Eq)]
590pub enum ContentType {
591    /// Source code files
592    Source,
593    /// Configuration files
594    Config,
595    /// Documentation files
596    Documentation,
597    /// Build system files
598    Build,
599}
600
601/// Implement SearchState Clone for the closure
602impl Clone for SearchState {
603    fn clone(&self) -> Self {
604        Self {
605            results: self.results.clone(),
606            cancelled: self.cancelled.clone(),
607            processed: self.processed.clone(),
608            max_results: self.max_results,
609            start_time: self.start_time,
610            timeout: self.timeout,
611        }
612    }
613}
614
615/// CodeTool implementation for FdFind
616impl CodeTool for FdFind {
617    type Query = FdQuery;
618    type Output = Vec<FdResult>;
619
620    fn search(&self, query: Self::Query) -> Result<Self::Output, ToolError> {
621        self.search_internal(query)
622    }
623}
624
625/// Legacy compatibility: Convert FdResult to String paths
626impl FdFind {
627    /// Search and return only file paths (for legacy compatibility)
628    pub fn search_paths(&self, query: FdQuery) -> Result<Vec<String>, ToolError> {
629        let results = self.search(query)?;
630        Ok(results
631            .into_iter()
632            .map(|r| r.path.to_string_lossy().to_string())
633            .collect())
634    }
635}
636
637/// Builder pattern for FdQuery construction
638impl FdQuery {
639    /// Create a new query for the given base directory
640    pub fn new<P: AsRef<Path>>(base_dir: P) -> Self {
641        Self {
642            base_dir: base_dir.as_ref().to_path_buf(),
643            ..Default::default()
644        }
645    }
646
647    /// Add a glob pattern
648    pub fn glob(mut self, pattern: &str) -> Self {
649        self.glob_patterns.push(pattern.to_string());
650        self
651    }
652
653    /// Add multiple glob patterns
654    pub fn globs(mut self, patterns: &[&str]) -> Self {
655        self.glob_patterns
656            .extend(patterns.iter().map(|s| (*s).to_string()));
657        self
658    }
659
660    /// Set regex pattern
661    pub fn regex(mut self, pattern: &str) -> Self {
662        self.regex_pattern = Some(pattern.to_string());
663        self
664    }
665
666    /// Set file type filter
667    pub const fn file_type(mut self, file_type: FileTypeFilter) -> Self {
668        self.file_type = file_type;
669        self
670    }
671
672    /// Set size filter
673    pub const fn size_range(mut self, min: Option<u64>, max: Option<u64>) -> Self {
674        self.size_filter = Some(SizeFilter {
675            min_size: min,
676            max_size: max,
677        });
678        self
679    }
680
681    /// Set maximum search depth
682    pub const fn max_depth(mut self, depth: usize) -> Self {
683        self.max_depth = Some(depth);
684        self
685    }
686
687    /// Include hidden files
688    pub const fn include_hidden(mut self, include: bool) -> Self {
689        self.include_hidden = include;
690        self
691    }
692
693    /// Follow symbolic links
694    pub const fn follow_links(mut self, follow: bool) -> Self {
695        self.follow_links = follow;
696        self
697    }
698
699    /// Set case sensitivity
700    pub const fn case_sensitive(mut self, sensitive: bool) -> Self {
701        self.case_sensitive = sensitive;
702        self
703    }
704
705    /// Limit maximum results
706    pub const fn max_results(mut self, max: usize) -> Self {
707        self.max_results = max;
708        self
709    }
710
711    /// Set search timeout
712    pub const fn timeout(mut self, timeout: Duration) -> Self {
713        self.timeout = Some(timeout);
714        self
715    }
716
717    /// Set number of threads
718    pub const fn threads(mut self, threads: usize) -> Self {
719        self.threads = Some(threads);
720        self
721    }
722}
723
724#[cfg(test)]
725mod tests {
726    use super::*;
727    use std::fs;
728    use tempfile::TempDir;
729
730    fn create_test_structure() -> TempDir {
731        let temp_dir = TempDir::new().unwrap();
732        let base = temp_dir.path();
733
734        // Create test directory structure
735        fs::create_dir_all(base.join("src")).unwrap();
736        fs::create_dir_all(base.join("target/debug")).unwrap();
737        fs::create_dir_all(base.join(".git")).unwrap();
738        fs::create_dir_all(base.join("docs")).unwrap();
739
740        // Create test files
741        fs::write(base.join("src/main.rs"), "fn main() {}").unwrap();
742        fs::write(base.join("src/lib.rs"), "pub mod test;").unwrap();
743        fs::write(base.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
744        fs::write(base.join("README.md"), "# Test Project").unwrap();
745        fs::write(base.join(".gitignore"), "target/").unwrap();
746        fs::write(base.join("target/debug/test"), "binary").unwrap();
747        fs::write(base.join("docs/guide.md"), "# Guide").unwrap();
748
749        // Create hidden file
750        fs::write(base.join(".hidden"), "hidden content").unwrap();
751
752        temp_dir
753    }
754
755    #[test]
756    fn test_basic_search() {
757        let temp_dir = create_test_structure();
758        let fd_find = FdFind::new();
759
760        let query = FdQuery::new(temp_dir.path()).file_type(FileTypeFilter::FilesOnly);
761
762        let results = fd_find.search(query).unwrap();
763        assert!(!results.is_empty());
764
765        // Should find regular files but not the target directory (due to .gitignore)
766        let paths: Vec<_> = results.iter().map(|r| &r.path).collect();
767        assert!(paths.iter().any(|p| p.file_name().unwrap() == "main.rs"));
768        assert!(paths.iter().any(|p| p.file_name().unwrap() == "Cargo.toml"));
769    }
770
771    #[test]
772    fn test_glob_pattern_search() {
773        let temp_dir = create_test_structure();
774        let fd_find = FdFind::new();
775
776        let query = FdQuery::new(temp_dir.path())
777            .globs(&["*.rs", "*.toml"])
778            .file_type(FileTypeFilter::FilesOnly);
779
780        let results = fd_find.search(query).unwrap();
781
782        for result in &results {
783            let filename = result.path.file_name().unwrap().to_string_lossy();
784            assert!(filename.ends_with(".rs") || filename.ends_with(".toml"));
785        }
786    }
787
788    #[test]
789    fn test_find_files_by_extension() {
790        let temp_dir = create_test_structure();
791        let fd_find = FdFind::new();
792
793        let results = fd_find
794            .find_files_by_extension(temp_dir.path(), &["rs", "md"])
795            .unwrap();
796
797        assert!(!results.is_empty());
798        for result in &results {
799            let ext = result.path.extension().unwrap().to_string_lossy();
800            assert!(ext == "rs" || ext == "md");
801        }
802    }
803
804    #[test]
805    fn test_find_directories_by_name() {
806        let temp_dir = create_test_structure();
807        let fd_find = FdFind::new();
808
809        let results = fd_find
810            .find_directories_by_name(temp_dir.path(), "src")
811            .unwrap();
812
813        assert_eq!(results.len(), 1);
814        assert_eq!(results[0].file_type, FdFileType::Directory);
815        assert_eq!(results[0].path.file_name().unwrap(), "src");
816    }
817
818    #[test]
819    fn test_regex_search() {
820        let temp_dir = create_test_structure();
821        let fd_find = FdFind::new();
822
823        let query = FdQuery::new(temp_dir.path())
824            .regex(r".*\.(rs|toml)$")
825            .file_type(FileTypeFilter::FilesOnly);
826
827        let results = fd_find.search(query).unwrap();
828
829        for result in &results {
830            let filename = result.path.to_string_lossy();
831            assert!(filename.ends_with(".rs") || filename.ends_with(".toml"));
832        }
833    }
834
835    #[test]
836    fn test_max_results_limit() {
837        let temp_dir = create_test_structure();
838        let fd_find = FdFind::new();
839
840        let query = FdQuery::new(temp_dir.path())
841            .file_type(FileTypeFilter::All)
842            .max_results(3);
843
844        let results = fd_find.search(query).unwrap();
845        assert!(results.len() <= 3);
846    }
847
848    #[test]
849    fn test_hidden_files() {
850        let temp_dir = create_test_structure();
851        let fd_find = FdFind::new();
852
853        // Search without hidden files
854        let query_no_hidden = FdQuery::new(temp_dir.path())
855            .file_type(FileTypeFilter::FilesOnly)
856            .include_hidden(false);
857
858        let results_no_hidden = fd_find.search(query_no_hidden).unwrap();
859        let hidden_found = results_no_hidden
860            .iter()
861            .any(|r| r.path.file_name().unwrap() == ".hidden");
862        assert!(!hidden_found);
863
864        // Search with hidden files
865        let query_with_hidden = FdQuery::new(temp_dir.path())
866            .file_type(FileTypeFilter::FilesOnly)
867            .include_hidden(true);
868
869        let results_with_hidden = fd_find.search(query_with_hidden).unwrap();
870        let hidden_found = results_with_hidden
871            .iter()
872            .any(|r| r.path.file_name().unwrap() == ".hidden");
873        assert!(hidden_found);
874    }
875
876    #[test]
877    fn test_depth_limiting() {
878        let temp_dir = create_test_structure();
879        let fd_find = FdFind::new();
880
881        let query = FdQuery::new(temp_dir.path())
882            .file_type(FileTypeFilter::All)
883            .max_depth(1); // Only immediate children
884
885        let results = fd_find.search(query).unwrap();
886
887        // Should not find files in subdirectories
888        let deep_file_found = results
889            .iter()
890            .any(|r| r.path.file_name().unwrap() == "main.rs");
891        assert!(!deep_file_found);
892    }
893
894    #[test]
895    fn test_size_filtering() {
896        let temp_dir = create_test_structure();
897        let fd_find = FdFind::new();
898
899        // Create a large file for testing
900        let large_content = "x".repeat(1024); // 1KB
901        fs::write(temp_dir.path().join("large.txt"), &large_content).unwrap();
902
903        let query = FdQuery::new(temp_dir.path())
904            .file_type(FileTypeFilter::FilesOnly)
905            .size_range(Some(500), Some(2000)); // 500B to 2KB
906
907        let results = fd_find.search(query).unwrap();
908
909        // Should find the large file
910        let large_found = results
911            .iter()
912            .any(|r| r.path.file_name().unwrap() == "large.txt");
913        assert!(large_found);
914
915        // Verify size information
916        let large_result = results
917            .iter()
918            .find(|r| r.path.file_name().unwrap() == "large.txt")
919            .unwrap();
920        assert!(large_result.size.unwrap() >= 500);
921    }
922
923    #[test]
924    fn test_content_type_search() {
925        let temp_dir = create_test_structure();
926        let fd_find = FdFind::new();
927
928        let results = fd_find
929            .find_by_content_type(temp_dir.path(), ContentType::Source)
930            .unwrap();
931
932        // Should find .rs files
933        let rust_found = results.iter().any(|r| r.path.extension().unwrap() == "rs");
934        assert!(rust_found);
935    }
936
937    #[test]
938    fn test_case_sensitivity() {
939        let temp_dir = create_test_structure();
940
941        // Create files with different cases
942        fs::write(temp_dir.path().join("Test.TXT"), "content").unwrap();
943        fs::write(temp_dir.path().join("test.txt"), "content").unwrap();
944
945        let fd_find = FdFind::new();
946
947        // Case sensitive search
948        let query_sensitive = FdQuery::new(temp_dir.path())
949            .glob("*.TXT")
950            .case_sensitive(true);
951
952        let results_sensitive = fd_find.search(query_sensitive).unwrap();
953        assert_eq!(results_sensitive.len(), 1);
954        assert_eq!(results_sensitive[0].path.file_name().unwrap(), "Test.TXT");
955
956        // Case insensitive search
957        let query_insensitive = FdQuery::new(temp_dir.path())
958            .glob("*.txt")
959            .case_sensitive(false);
960
961        let results_insensitive = fd_find.search(query_insensitive).unwrap();
962        assert_eq!(results_insensitive.len(), 2);
963    }
964
965    #[test]
966    fn test_builder_pattern() {
967        let temp_dir = create_test_structure();
968        let fd_find = FdFind::new();
969
970        let query = FdQuery::new(temp_dir.path())
971            .globs(&["*.rs", "*.toml"])
972            .file_type(FileTypeFilter::FilesOnly)
973            .max_depth(5)
974            .include_hidden(false)
975            .case_sensitive(true)
976            .max_results(10)
977            .timeout(Duration::from_secs(5));
978
979        let results = fd_find.search(query).unwrap();
980        assert!(!results.is_empty());
981    }
982
983    #[test]
984    fn test_error_handling() {
985        let fd_find = FdFind::new();
986
987        // Test non-existent directory
988        let query = FdQuery::new("/non/existent/path");
989        let result = fd_find.search(query);
990        assert!(result.is_err());
991
992        // Test invalid regex
993        let temp_dir = create_test_structure();
994        let query = FdQuery::new(temp_dir.path()).regex("[invalid regex");
995        let result = fd_find.search(query);
996        assert!(result.is_err());
997    }
998}