Skip to main content

browser_automation_cli/
find_paths.rs

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