Skip to main content

browser_automation_cli/
find_paths.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! One-shot filesystem path discovery (`find-paths`, fd-like UX; no Chrome).
3//!
4//! # Workload
5//!
6//! **I/O-bound** directory walk. Parallelism via `ignore::WalkBuilder::threads`
7//! sized to [`crate::concurrency::walk_threads`] (respects `--max-concurrency`;
8//! never unbounded spawn). Multi-root walks use Rayon `flat_map` + collect when
9//! N>1 (**PAR-95:** no `Mutex` on the fan-out path — same pattern as `sg`).
10//! Filtering is cheap per entry.
11
12use std::path::{Path, PathBuf};
13
14use ignore::WalkBuilder;
15use rayon::prelude::*;
16use regex::RegexBuilder;
17use serde_json::{json, Value};
18
19use crate::error::{CliError, ErrorKind};
20
21/// Options for `find-paths`.
22#[derive(Debug, Clone)]
23pub struct FindPathsOpts {
24    /// Regex pattern on file name (empty = match all).
25    pub pattern: String,
26    /// Root paths to walk.
27    pub roots: Vec<PathBuf>,
28    /// Extension filter without dot (e.g. `rs`).
29    pub extension: Option<String>,
30    /// Include hidden entries.
31    pub hidden: bool,
32    /// Do not respect .gitignore/.ignore.
33    pub no_ignore: bool,
34    /// Max walk depth.
35    pub max_depth: Option<usize>,
36    /// Entry type: `f` file, `d` dir, empty = both.
37    pub entry_type: Option<String>,
38    /// Max results (0 = unlimited).
39    pub limit: usize,
40    /// Optional glob pattern (fd-like; GAP-A011 / §5AE). Matched against full path.
41    pub glob: Option<String>,
42}
43
44impl Default for FindPathsOpts {
45    fn default() -> Self {
46        Self {
47            pattern: String::new(),
48            roots: vec![PathBuf::from(".")],
49            extension: None,
50            hidden: false,
51            no_ignore: false,
52            max_depth: None,
53            entry_type: None,
54            limit: 10_000,
55            glob: None,
56        }
57    }
58}
59
60/// Walk roots and return matching paths (one-shot).
61pub fn find_paths(opts: &FindPathsOpts) -> Result<Value, CliError> {
62    crate::concurrency::install_rayon_pool_once();
63    let roots = if opts.roots.is_empty() {
64        vec![PathBuf::from(".")]
65    } else {
66        opts.roots.clone()
67    };
68    let re = if opts.pattern.is_empty() {
69        None
70    } else {
71        Some(
72            RegexBuilder::new(&opts.pattern)
73                .case_insensitive(true)
74                .build()
75                .map_err(|e| {
76                    CliError::new(ErrorKind::Usage, format!("invalid find-paths pattern: {e}"))
77                })?,
78        )
79    };
80    let walk_threads = crate::concurrency::walk_threads();
81    // PAR-95: each root collects into a local Vec; multi-root uses flat_map+collect
82    // (no Mutex on the parallel fan-out path).
83    let walk_one = |root: &PathBuf| -> Vec<String> {
84        let mut builder = WalkBuilder::new(root);
85        builder.hidden(!opts.hidden);
86        builder.git_ignore(!opts.no_ignore);
87        builder.git_global(!opts.no_ignore);
88        builder.git_exclude(!opts.no_ignore);
89        builder.ignore(!opts.no_ignore);
90        if let Some(d) = opts.max_depth {
91            builder.max_depth(Some(d));
92        }
93        builder.threads(walk_threads);
94        let mut local = Vec::new();
95        for entry in builder.build() {
96            if opts.limit > 0 && local.len() >= opts.limit {
97                break;
98            }
99            let entry = match entry {
100                Ok(e) => e,
101                Err(_) => continue,
102            };
103            let ft = entry.file_type();
104            let is_file = ft.map(|t| t.is_file()).unwrap_or(false);
105            let is_dir = ft.map(|t| t.is_dir()).unwrap_or(false);
106            if let Some(ref t) = opts.entry_type {
107                match t.as_str() {
108                    "f" | "file" if !is_file => continue,
109                    "d" | "dir" | "directory" if !is_dir => continue,
110                    _ => {}
111                }
112            }
113            let path = entry.path();
114            let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
115            if let Some(ref ext) = opts.extension {
116                let e = path.extension().and_then(|x| x.to_str()).unwrap_or("");
117                if !e.eq_ignore_ascii_case(ext.trim_start_matches('.')) {
118                    continue;
119                }
120            }
121            if let Some(ref re) = re {
122                if !re.is_match(name) && !re.is_match(&path.to_string_lossy()) {
123                    continue;
124                }
125            }
126            if let Some(ref g) = opts.glob {
127                if !glob_match(g, path) {
128                    continue;
129                }
130            }
131            local.push(path.display().to_string());
132        }
133        local
134    };
135    let mut paths: Vec<String> = if roots.len() <= 1 {
136        roots.iter().flat_map(walk_one).collect()
137    } else {
138        roots.par_iter().flat_map(walk_one).collect()
139    };
140    if opts.limit > 0 && paths.len() > opts.limit {
141        paths.truncate(opts.limit);
142    }
143    Ok(json!({
144        "count": paths.len(),
145        "paths": paths,
146        "pattern": opts.pattern,
147        "glob": opts.glob,
148        "engine": "ignore",
149        "chrome": false,
150        "walk_threads": walk_threads,
151        "roots_parallel": roots.len() > 1,
152        "concurrency": crate::concurrency::effective_limit(),
153    }))
154}
155
156/// Match a path against a shell-style glob (GAP-A011). Uses `globset` when available via
157/// a lightweight converter for `*`, `?`, and `**`.
158fn glob_match(pattern: &str, path: &Path) -> bool {
159    let path_s = path.to_string_lossy();
160    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
161    // Prefer full-path match; also try basename for patterns without `/`.
162    if glob_match_str(pattern, &path_s) {
163        return true;
164    }
165    if !pattern.contains('/') {
166        return glob_match_str(pattern, name);
167    }
168    false
169}
170
171fn glob_match_str(pattern: &str, text: &str) -> bool {
172    // Convert shell glob to a simple regex (no full globset dep for MVP).
173    let mut re = String::from("(?i)^");
174    let mut chars = pattern.chars().peekable();
175    while let Some(c) = chars.next() {
176        match c {
177            '*' => {
178                if chars.peek() == Some(&'*') {
179                    chars.next();
180                    // `**` → match across path separators
181                    re.push_str(".*");
182                } else {
183                    re.push_str("[^/]*");
184                }
185            }
186            '?' => re.push_str("[^/]"),
187            '.' | '+' | '(' | ')' | '|' | '^' | '$' | '{' | '}' | '[' | ']' | '\\' => {
188                re.push('\\');
189                re.push(c);
190            }
191            other => re.push(other),
192        }
193    }
194    re.push('$');
195    RegexBuilder::new(&re)
196        .case_insensitive(true)
197        .build()
198        .map(|r| r.is_match(text))
199        .unwrap_or(false)
200}
201
202/// Normalize roots from CLI paths.
203pub fn roots_from(paths: &[String]) -> Vec<PathBuf> {
204    if paths.is_empty() {
205        vec![PathBuf::from(".")]
206    } else {
207        paths.iter().map(|p| Path::new(p).to_path_buf()).collect()
208    }
209}