Skip to main content

fsearch/
searcher.rs

1// File: src\searcher.rs
2// Author: Hadi Cahyadi <cumulus13@gmail.com>
3// Date: 2026-05-11
4// Description:
5// License: MIT
6
7//! Core file-search logic — exposed as a library API.
8//!
9//! Two search engines:
10//! * [`fast_find`]      — walkdir + rayon (parallel, default)
11//! * [`recursive_find`] — manual DFS (deterministic order)
12//!
13//! Both accept **a slice of base directories** so callers can search
14//! multiple roots in a single call, with results de-duplicated by path.
15
16use crate::binary::is_binary;
17use crate::config::{split_csv, Config};
18use crate::error::{FsearchError, FsearchResult};
19use glob::Pattern;
20use rayon::prelude::*;
21use std::collections::HashSet;
22use std::fs;
23use std::io::{BufRead, BufReader};
24use std::path::{Path, PathBuf};
25use std::sync::atomic::{AtomicBool, Ordering};
26use std::sync::{Arc, Mutex};
27use walkdir::WalkDir;
28
29// ── Public types ──────────────────────────────────────────────────────────────
30
31/// A single matched line inside a file: `(1-based line number, line text)`.
32pub type LineMatch = (usize, String);
33
34/// One result item returned by every search function.
35#[derive(Debug, Clone)]
36pub enum SearchMatch {
37    /// Filename-only match.
38    Path(PathBuf),
39    /// Content match with the lines that contain the pattern.
40    Content {
41        path: PathBuf,
42        lines: Vec<LineMatch>,
43    },
44}
45
46impl SearchMatch {
47    /// The matched path regardless of variant.
48    pub fn path(&self) -> &Path {
49        match self {
50            Self::Path(p) => p,
51            Self::Content { path, .. } => path,
52        }
53    }
54
55    /// Returns `true` when this is a content (in-file) match.
56    pub fn is_content(&self) -> bool {
57        matches!(self, Self::Content { .. })
58    }
59}
60
61// ── Search options ────────────────────────────────────────────────────────────
62
63/// All parameters that control a single search operation.
64///
65/// Use [`SearchOptions::builder`] for a fluent construction API, or
66/// construct directly for multi-path searches via [`SearchOptions::base_dirs`].
67#[derive(Debug, Clone)]
68pub struct SearchOptions {
69    /// One or more directories to search. At least one must be provided.
70    /// When multiple directories are given, results from all roots are merged
71    /// and de-duplicated.
72    pub base_dirs: Vec<PathBuf>,
73
74    /// Pattern to match (supports `*` / `?` wildcards, or plain substring).
75    pub pattern: String,
76
77    /// Maximum recursion depth per directory (0 = only that directory itself).
78    pub max_depth: u32,
79
80    /// Include directory entries in filename-search results.
81    pub include_dirs: bool,
82
83    /// Match case-insensitively.
84    pub case_insensitive: bool,
85
86    /// Search for `pattern` inside file contents instead of matching names.
87    pub search_in_files: bool,
88
89    /// Only include files that match these glob patterns (empty = all).
90    pub include_patterns: Vec<String>,
91
92    /// Directory names to skip entirely during traversal.
93    pub exclude_dirs: Vec<String>,
94
95    /// Lines longer than this are skipped during content search.
96    pub max_line_length: usize,
97
98    /// Bytes read to probe for binary content.
99    pub binary_check_bytes: usize,
100
101    /// Cap the total number of results returned (0 = unlimited).
102    pub max_results: usize,
103}
104
105impl SearchOptions {
106    /// Construct from a [`Config`] with explicit directories and a pattern.
107    pub fn from_config(cfg: &Config, base_dirs: Vec<PathBuf>, pattern: String) -> Self {
108        Self {
109            base_dirs,
110            pattern,
111            max_depth: cfg.default_depth,
112            include_dirs: cfg.include_dirs,
113            case_insensitive: cfg.case_insensitive,
114            search_in_files: false,
115            include_patterns: split_csv(&cfg.default_include),
116            exclude_dirs: cfg.excluded_dirs(),
117            max_line_length: cfg.max_line_length,
118            binary_check_bytes: cfg.binary_check_bytes,
119            max_results: cfg.max_results,
120        }
121    }
122
123    /// Fluent builder — start with a pattern.
124    pub fn builder(pattern: impl Into<String>) -> SearchOptionsBuilder {
125        SearchOptionsBuilder::new(pattern.into())
126    }
127}
128
129/// Fluent builder for [`SearchOptions`].
130pub struct SearchOptionsBuilder(SearchOptions);
131
132impl SearchOptionsBuilder {
133    fn new(pattern: String) -> Self {
134        Self(SearchOptions {
135            base_dirs: vec![PathBuf::from(".")],
136            pattern,
137            max_depth: 1,
138            include_dirs: true,
139            case_insensitive: true,
140            search_in_files: false,
141            include_patterns: vec![],
142            exclude_dirs: vec![
143                ".git".into(),
144                "node_modules".into(),
145                "target".into(),
146                ".svn".into(),
147                "__pycache__".into(),
148                ".hg".into(),
149                ".cache".into(),
150            ],
151            max_line_length: 10_000,
152            binary_check_bytes: 1024,
153            max_results: 0,
154        })
155    }
156
157    /// Set a single search root (replaces any previously set dirs).
158    pub fn base_dir(mut self, p: impl Into<PathBuf>) -> Self {
159        self.0.base_dirs = vec![p.into()];
160        self
161    }
162
163    /// Set multiple search roots (replaces any previously set dirs).
164    pub fn base_dirs(mut self, dirs: Vec<impl Into<PathBuf>>) -> Self {
165        self.0.base_dirs = dirs.into_iter().map(Into::into).collect();
166        self
167    }
168
169    /// Add one more search root to the existing list.
170    pub fn add_dir(mut self, p: impl Into<PathBuf>) -> Self {
171        self.0.base_dirs.push(p.into());
172        self
173    }
174
175    pub fn max_depth(mut self, d: u32) -> Self {
176        self.0.max_depth = d;
177        self
178    }
179    pub fn include_dirs(mut self, v: bool) -> Self {
180        self.0.include_dirs = v;
181        self
182    }
183    pub fn case_insensitive(mut self, v: bool) -> Self {
184        self.0.case_insensitive = v;
185        self
186    }
187    pub fn search_in_files(mut self, v: bool) -> Self {
188        self.0.search_in_files = v;
189        self
190    }
191    pub fn include_patterns(mut self, p: Vec<String>) -> Self {
192        self.0.include_patterns = p;
193        self
194    }
195    pub fn exclude_dirs(mut self, d: Vec<String>) -> Self {
196        self.0.exclude_dirs = d;
197        self
198    }
199    pub fn max_results(mut self, n: usize) -> Self {
200        self.0.max_results = n;
201        self
202    }
203    pub fn build(self) -> SearchOptions {
204        self.0
205    }
206}
207
208// ── Pattern helpers ───────────────────────────────────────────────────────────
209
210/// Parse a comma-separated pattern string into a `Vec<String>`.
211pub fn parse_patterns(raw: &str, case_insensitive: bool) -> Vec<String> {
212    raw.split(',')
213        .map(|s| s.trim().to_string())
214        .filter(|s| !s.is_empty())
215        .map(|s| {
216            if case_insensitive {
217                s.to_lowercase()
218            } else {
219                s
220            }
221        })
222        .collect()
223}
224
225fn matches_include(name: &str, patterns: &[String], ci: bool) -> bool {
226    if patterns.is_empty() {
227        return true;
228    }
229    let cmp = if ci {
230        name.to_lowercase()
231    } else {
232        name.to_string()
233    };
234    patterns.iter().any(|p| {
235        Pattern::new(p)
236            .map(|pat| pat.matches(&cmp))
237            .unwrap_or(false)
238    })
239}
240
241fn is_excluded_dir(name: &str, excludes: &[String]) -> bool {
242    excludes
243        .iter()
244        .any(|ex| Pattern::new(ex).map(|p| p.matches(name)).unwrap_or(false) || ex == name)
245}
246
247fn name_matches(entry_name: &str, pattern: &str, ci: bool) -> bool {
248    let (name, pat) = if ci {
249        (entry_name.to_lowercase(), pattern.to_lowercase())
250    } else {
251        (entry_name.to_string(), pattern.to_string())
252    };
253    if pat.contains('*') || pat.contains('?') {
254        Pattern::new(&pat)
255            .map(|p| p.matches(&name))
256            .unwrap_or(false)
257    } else {
258        name.contains(&pat)
259    }
260}
261
262// ── Content search ────────────────────────────────────────────────────────────
263
264fn search_in_file(
265    path: &Path,
266    pattern: &str,
267    ci: bool,
268    max_line: usize,
269    check_bytes: usize,
270) -> Vec<LineMatch> {
271    if is_binary(path, check_bytes) {
272        return vec![];
273    }
274    let file = match fs::File::open(path) {
275        Ok(f) => f,
276        Err(_) => return vec![],
277    };
278    let pat = if ci {
279        pattern.to_lowercase()
280    } else {
281        pattern.to_string()
282    };
283    BufReader::new(file)
284        .lines()
285        .enumerate()
286        .filter_map(|(i, lr)| {
287            let line = lr.ok()?;
288            if line.len() > max_line {
289                return None;
290            }
291            let cmp = if ci {
292                line.to_lowercase()
293            } else {
294                line.clone()
295            };
296            if cmp.contains(&pat) {
297                Some((i + 1, line))
298            } else {
299                None
300            }
301        })
302        .collect()
303}
304
305// ── Validation ────────────────────────────────────────────────────────────────
306
307fn validate_dirs(dirs: &[PathBuf]) -> FsearchResult<()> {
308    if dirs.is_empty() {
309        return Err(FsearchError::DirectoryNotFound(
310            "(no directories specified)".into(),
311        ));
312    }
313    for d in dirs {
314        if !d.exists() {
315            return Err(FsearchError::DirectoryNotFound(d.display().to_string()));
316        }
317        if !d.is_dir() {
318            return Err(FsearchError::NotADirectory(d.display().to_string()));
319        }
320    }
321    Ok(())
322}
323
324// ── Method 1 — walkdir + rayon (parallel, multi-root) ────────────────────────
325
326/// Search using `walkdir` + `rayon` across **one or more directories**.
327///
328/// All roots are walked in parallel; results are merged and de-duplicated
329/// by canonical path before being returned.
330pub fn fast_find(
331    opts: &SearchOptions,
332    interrupted: Arc<AtomicBool>,
333) -> FsearchResult<Vec<SearchMatch>> {
334    validate_dirs(&opts.base_dirs)?;
335
336    // Seen-set is shared across all root walks to deduplicate results when
337    // multiple given dirs are subdirectories of each other.
338    let seen: Arc<Mutex<HashSet<PathBuf>>> = Arc::new(Mutex::new(HashSet::new()));
339
340    let mut all_results: Vec<SearchMatch> = opts
341        .base_dirs
342        .par_iter()
343        .flat_map(|base| {
344            if interrupted.load(Ordering::Relaxed) {
345                return vec![];
346            }
347            let entries: Vec<_> = WalkDir::new(base)
348                .max_depth(opts.max_depth as usize + 1)
349                .follow_links(false)
350                .into_iter()
351                .filter_entry(|e| {
352                    if e.file_type().is_dir() && e.depth() > 0 {
353                        let name = e.file_name().to_string_lossy().to_string();
354                        if is_excluded_dir(&name, &opts.exclude_dirs) {
355                            return false;
356                        }
357                    }
358                    true
359                })
360                .filter_map(|e| e.ok())
361                .filter(|e| e.depth() > 0)
362                .collect();
363
364            entries
365                .into_par_iter()
366                .filter_map(|entry| {
367                    if interrupted.load(Ordering::Relaxed) {
368                        return None;
369                    }
370
371                    let is_dir = entry.file_type().is_dir();
372                    let name = entry.file_name().to_string_lossy().to_string();
373                    let path = entry.path().to_path_buf();
374
375                    // Deduplication across roots
376                    {
377                        let mut guard = seen.lock().unwrap();
378                        if !guard.insert(path.clone()) {
379                            return None;
380                        }
381                    }
382
383                    if !is_dir
384                        && !matches_include(&name, &opts.include_patterns, opts.case_insensitive)
385                    {
386                        return None;
387                    }
388
389                    if opts.search_in_files {
390                        if is_dir {
391                            return None;
392                        }
393                        let lines = search_in_file(
394                            &path,
395                            &opts.pattern,
396                            opts.case_insensitive,
397                            opts.max_line_length,
398                            opts.binary_check_bytes,
399                        );
400                        if lines.is_empty() {
401                            None
402                        } else {
403                            Some(SearchMatch::Content { path, lines })
404                        }
405                    } else {
406                        if is_dir && !opts.include_dirs {
407                            return None;
408                        }
409                        if name_matches(&name, &opts.pattern, opts.case_insensitive) {
410                            Some(SearchMatch::Path(path))
411                        } else {
412                            None
413                        }
414                    }
415                })
416                .collect::<Vec<_>>()
417        })
418        .collect();
419
420    // Stable-sort by path for deterministic output across roots
421    all_results.sort_unstable_by(|a, b| a.path().cmp(b.path()));
422
423    Ok(cap(all_results, opts.max_results))
424}
425
426// ── Method 2 — manual recursive (deterministic, multi-root) ──────────────────
427
428/// Search using a manual DFS across **one or more directories**.
429///
430/// Roots are processed sequentially; results are sorted by path and
431/// de-duplicated before returning.
432pub fn recursive_find(
433    opts: &SearchOptions,
434    interrupted: Arc<AtomicBool>,
435) -> FsearchResult<Vec<SearchMatch>> {
436    validate_dirs(&opts.base_dirs)?;
437
438    let mut seen: HashSet<PathBuf> = HashSet::new();
439    let mut matches: Vec<SearchMatch> = Vec::new();
440
441    for base in &opts.base_dirs {
442        if interrupted.load(Ordering::Relaxed) {
443            break;
444        }
445        walk_dir(base, opts, 0, &mut matches, &mut seen, &interrupted);
446    }
447
448    matches.sort_unstable_by(|a, b| a.path().cmp(b.path()));
449    Ok(cap(matches, opts.max_results))
450}
451
452fn walk_dir(
453    dir: &Path,
454    opts: &SearchOptions,
455    depth: u32,
456    matches: &mut Vec<SearchMatch>,
457    seen: &mut HashSet<PathBuf>,
458    interrupted: &AtomicBool,
459) {
460    if depth > opts.max_depth || interrupted.load(Ordering::Relaxed) {
461        return;
462    }
463    let entries = match fs::read_dir(dir) {
464        Ok(e) => e,
465        Err(_) => return,
466    };
467
468    for entry in entries.flatten() {
469        if interrupted.load(Ordering::Relaxed) {
470            break;
471        }
472
473        let path = entry.path();
474        let file_type = match entry.file_type() {
475            Ok(ft) => ft,
476            Err(_) => continue,
477        };
478        let name = entry.file_name().to_string_lossy().to_string();
479
480        // Deduplicate
481        if !seen.insert(path.clone()) {
482            continue;
483        }
484
485        if file_type.is_dir() {
486            if is_excluded_dir(&name, &opts.exclude_dirs) {
487                continue;
488            }
489            if !opts.search_in_files
490                && opts.include_dirs
491                && name_matches(&name, &opts.pattern, opts.case_insensitive)
492            {
493                matches.push(SearchMatch::Path(path.clone()));
494            }
495            walk_dir(&path, opts, depth + 1, matches, seen, interrupted);
496        } else if file_type.is_file() {
497            if !matches_include(&name, &opts.include_patterns, opts.case_insensitive) {
498                continue;
499            }
500            if opts.search_in_files {
501                let lines = search_in_file(
502                    &path,
503                    &opts.pattern,
504                    opts.case_insensitive,
505                    opts.max_line_length,
506                    opts.binary_check_bytes,
507                );
508                if !lines.is_empty() {
509                    matches.push(SearchMatch::Content { path, lines });
510                }
511            } else if name_matches(&name, &opts.pattern, opts.case_insensitive) {
512                matches.push(SearchMatch::Path(path));
513            }
514        }
515    }
516}
517
518// ── Helpers ───────────────────────────────────────────────────────────────────
519
520fn cap(mut v: Vec<SearchMatch>, limit: usize) -> Vec<SearchMatch> {
521    if limit > 0 && v.len() > limit {
522        v.truncate(limit);
523    }
524    v
525}