Skip to main content

cosh_tools/find/
glob.rs

1//! Glob-based filesystem discovery tool.
2//!
3//! Searches one or more roots for entries matching a glob pattern. Results can
4//! be filtered by filesystem kind, sorted by modification time, and bounded by
5//! a result count or a timeout.
6//!
7//! # Output contract
8//!
9//! - A timeout is not an error: the partial matches found up to that point are
10//!   returned with `timed_out: true` and an explanatory `note`. An empty
11//!   `timed_out` result is an INCOMPLETE scan, never "no files found".
12//! - Zero matches (no timeout) are marked `useless: true` with a no-match
13//!   `note` — the result carries no new information.
14//! - When `max_results` caps the output, `limit_reached: true` tells the
15//!   caller more entries may exist.
16//! - Multi-target calls skip missing targets and report them in
17//!   `missing_paths`; the call only fails when every target is missing.
18//! - `on_match` streams each match as the scan finds it (live TUI feedback).
19
20// Pattern and path-listing helpers for the find tool.
21//
22// Interpret a single user-supplied target (glob, directory, or file) and
23// derive the search root, the effective glob, and whether the scan should
24// recurse. Mirrors the semantics of the `@oh-my-pi` reference tool:
25//
26// - `*.rs` — bare glob: search the tree rooted at the target recursively
27//   (`**/*.rs`).
28// - `src/*.rs` — glob with an explicit base directory: scoped to `src`,
29//   single level (`src/*.rs`, NOT `src/sub/*.rs`).
30// - `src/**/*.rs` — already-recursive glob: unchanged.
31// - `src` — directory literal: list everything under it recursively.
32// - `foo.rs` — file literal (no glob chars and a file exists): the path
33//   itself is returned as the single match.
34
35use std::collections::BTreeMap;
36use std::path::{Path, PathBuf};
37use std::sync::Arc;
38
39use cosh_sdk::find::{FileType, GlobMatch, GlobOptions, glob as sdk_glob};
40
41use super::types::{Glob, GlobEntry, GlobOutput};
42
43/// Detect glob-syntax characters in a string.
44#[must_use]
45pub fn has_glob_path_chars(s: &str) -> bool {
46    s.chars().any(|c| matches!(c, '*' | '?' | '[' | '{'))
47}
48
49/// A single path input parsed into a search root + effective glob.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct ParsedFindPattern {
52    /// Directory to walk (`"."` when the input was a bare glob).
53    pub base_path: PathBuf,
54    /// Glob pattern to match against paths relative to `base_path`.
55    pub glob_pattern: String,
56    /// Whether the input itself contained glob characters.
57    pub has_glob: bool,
58    /// Whether the scan should recurse (derived from the pattern shape, not a
59    /// user flag): bare globs and `**/` patterns recur; `dir/*` does not.
60    pub recursive: bool,
61}
62
63/// Parse a single path input into its recursion-aware search root and glob.
64///
65/// A bare glob (`*.ts`) gets `**/` prepended (recursive). A glob that already
66/// carries a base directory (`src/*.ts`) stays scoped to that directory
67/// (shallow): the walker must not silently recurse into `src/sub/nested.ts`.
68/// A directory literal (`src`) lists everything under it; a file literal
69/// (`foo.rs`) also sets `has_glob: false` so the caller can return the file
70/// directly.
71#[must_use]
72pub fn parse_find_pattern(input: &str) -> ParsedFindPattern {
73    let normalized = input.replace('\\', "/");
74    let normalized = normalized.trim().trim_end_matches('/');
75
76    // Handle empty/whitespace-only input by treating it as current directory
77    if normalized.is_empty() {
78        return ParsedFindPattern {
79            base_path: PathBuf::from("."),
80            glob_pattern: String::new(),
81            has_glob: false,
82            recursive: false,
83        };
84    }
85
86    match normalized
87        .char_indices()
88        .find(|(_, c)| matches!(c, '*' | '?' | '[' | '{'))
89    {
90        Some((idx, _)) => {
91            let base = normalized[..idx].trim_end_matches('/');
92            let glob_part = &normalized[idx..];
93            let base_path = if base.is_empty() {
94                PathBuf::from(".")
95            } else {
96                PathBuf::from(base)
97            };
98            // A bare glob (no base dir) recurses unless it already starts with
99            // `**`. A glob with an explicit base dir stays shallow — UNLESS the
100            // glob itself is already recursive (`src/**/*.rs`).
101            let recursive = base.is_empty() || glob_part.starts_with("**");
102            let glob_pattern = if recursive && !glob_part.starts_with("**") {
103                format!("**/{glob_part}")
104            } else {
105                glob_part.to_string()
106            };
107            ParsedFindPattern {
108                base_path,
109                glob_pattern,
110                has_glob: true,
111                recursive,
112            }
113        }
114        None => ParsedFindPattern {
115            base_path: PathBuf::from(normalized),
116            glob_pattern: String::new(),
117            has_glob: false,
118            recursive: false,
119        },
120    }
121}
122
123/// Resolve a possibly-relative path fragment against the CWD.
124#[must_use]
125pub fn resolve_to_cwd(part: &str, cwd: &Path) -> PathBuf {
126    let p = Path::new(part);
127    if p.is_absolute() {
128        p.to_path_buf()
129    } else {
130        cwd.join(p)
131    }
132}
133
134/// Format `path` relative to `cwd` when it lives under it, keeping the path
135/// absolute otherwise (so externally-scoped matches stay unambiguous).
136///
137/// When `trailing_slash` is set, directories are emitted with a trailing `/`
138/// (the convention the TUI renderer uses to distinguish files from dirs).
139#[must_use]
140pub fn format_path_relative_to_cwd(path: &str, cwd: &Path, trailing_slash: bool) -> String {
141    let mut out = resolve_to_cwd(path, cwd)
142        .strip_prefix(cwd)
143        .map(|rel| rel.to_string_lossy().replace('\\', "/"))
144        .unwrap_or_else(|_| path.to_string().replace('\\', "/"));
145    if trailing_slash && !out.ends_with('/') {
146        out.push('/');
147    }
148    out
149}
150
151// Output formatting for glob results: `flat`, `grouped`, and `tree`.
152//
153// These shape how the matched paths are presented to the model (and re-used
154// by the TUI for its expanded view). All formats produce stable, sorted
155// output so the same search always renders identically.
156
157/// Output layout for a glob result set.
158#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
159pub enum PathFormat {
160    /// Every match on its own line, no grouping.
161    #[default]
162    Flat,
163    /// Grouped by parent directory with a header line per directory.
164    Grouped,
165    /// Indented tree by path depth.
166    Tree,
167}
168
169impl PathFormat {
170    /// Parse a user-supplied format string. Accepts the aliases the schema
171    /// documents (`"flat"`, `"grouped"`, `"tree"`).
172    ///
173    /// # Errors
174    /// Returns an error for any other value so the schema stays honest about
175    /// supported formats.
176    pub fn parse_format(s: &str) -> Result<Self, String> {
177        match s.trim().to_ascii_lowercase().as_str() {
178            "flat" => Ok(Self::Flat),
179            "grouped" => Ok(Self::Grouped),
180            "tree" => Ok(Self::Tree),
181            other => Err(format!(
182                "invalid format `{other}`; expected \"flat\", \"grouped\", or \"tree\""
183            )),
184        }
185    }
186}
187
188/// Render `paths` in the requested format.
189///
190/// Input paths are assumed to be forward-slash asset-relative (as produced by
191/// the glob tool). Grouping uses the parent directory of each path; matches at
192/// the root of the search scope use `"."` as the header.
193pub fn format_paths(paths: &[String], format: PathFormat) -> String {
194    match format {
195        PathFormat::Flat => paths.join("\n"),
196        PathFormat::Grouped => format_grouped(paths),
197        PathFormat::Tree => format_tree(paths),
198    }
199}
200
201/// Group paths by parent directory, emitting a header line per group.
202///
203/// Groups are ordered by directory; entries within a group are sorted so the
204/// output is deterministic regardless of the (parallel) walk order.
205fn format_grouped(paths: &[String]) -> String {
206    let mut groups: BTreeMap<String, Vec<&String>> = BTreeMap::new();
207    for p in paths {
208        groups.entry(parent_dir(p).to_string()).or_default().push(p);
209    }
210    let mut out = Vec::new();
211    for (dir, mut entries) in groups {
212        out.push(format!("{dir}/"));
213        entries.sort_unstable();
214        for e in entries {
215            let rel = e.strip_prefix(&format!("{dir}/")).unwrap_or(e).to_string();
216            out.push(format!("  {rel}"));
217        }
218    }
219    out.join("\n")
220}
221
222/// Render a depth-indented tree, printing each directory once at its own
223/// depth and collapsing children beneath it.
224fn format_tree(paths: &[String]) -> String {
225    // Collect a node set: every component prefix of every path, so an inner
226    // directory shows up even when only its children matched.
227    let mut nodes: BTreeMap<String, u16> = BTreeMap::new();
228    for p in paths {
229        let comps: Vec<&str> = p.split('/').filter(|c| !c.is_empty()).collect();
230        for (depth, _name) in comps.iter().enumerate() {
231            let key = comps[..=depth].join("/");
232            nodes.entry(key).or_insert(depth as u16);
233        }
234    }
235    if nodes.is_empty() {
236        return String::new();
237    }
238    // A node is a directory when some other node extends it (`a/` -> `a/b`).
239    // Iteration is depth-first pre-order: parents always precede children, and
240    // siblings are lexicographically sorted.
241    let mut out: Vec<String> = Vec::new();
242    for (node, depth) in &nodes {
243        let indent = "  ".repeat(*depth as usize);
244        let name = node.rsplit('/').next().unwrap_or(node);
245        let is_dir = nodes
246            .keys()
247            .any(|other| other != node && other.starts_with(&format!("{node}/")));
248        if is_dir {
249            out.push(format!("{indent}{name}/"));
250        } else {
251            out.push(format!("{indent}{name}"));
252        }
253    }
254    out.join("\n")
255}
256
257/// Parent directory of a path, using `/` separators. `"file.rs"` -> `"."`.
258pub(super) fn parent_dir(path: &str) -> &str {
259    match path.rfind('/') {
260        Some(idx) if idx > 0 => &path[..idx],
261        Some(_) => ".",
262        None => ".",
263    }
264}
265
266/// Default timeout for a glob search (milliseconds). Prevents a scan over a
267/// huge tree from blocking the agent loop indefinitely. On expiry the partial
268/// results are returned with `timed_out: true` instead of a blind error.
269pub const DEFAULT_GLOB_TIMEOUT_MS: u32 = 5000;
270
271/// Default result cap for `find_glob`, matching the reference tool (oh-my-pi).
272pub(crate) const DEFAULT_GLOB_LIMIT: u32 = 200;
273/// Hard ceiling for `find_glob` results. The caller's `limit` can only LOWER
274/// the cap; a larger value is clamped down to this ceiling, never honored.
275pub(crate) const MAX_GLOB_LIMIT: u32 = 200;
276
277/// Callback invoked for every match as the scan finds it. Runs on walker
278/// worker threads, so it must be cheap and `Sync`.
279pub type GlobMatchCallback = dyn Fn(&GlobMatch) + Send + Sync;
280
281/// A resolved search root plus the effective glob applied within it.
282///
283/// Built by the tool layer from a single user `path`/`paths` entry, which may
284/// itself be a glob, a directory literal, or a file literal. The parser lives
285/// in this module; `Find::glob_full` converts raw entries into specs.
286#[derive(Debug, Clone, PartialEq, Eq)]
287pub struct GlobTargetSpec {
288    /// Absolute resolved directory (or literal file) to search.
289    pub base_path: PathBuf,
290    /// Glob to match within `base_path`. Empty for a literal file/dir entry
291    /// whose whole content is the result (in which case the effective glob is
292    /// a full-tree `**` match).
293    pub pattern: String,
294    /// Whether the user's entry carried glob characters. A `has_glob: false`
295    /// base that is an existing file short-circuits the walk: the file itself
296    /// is returned as the single entry.
297    pub has_glob: bool,
298}
299
300/// Find filesystem entries matching a glob pattern.
301///
302/// Searches `path` for entries matching `pattern`. Results can be
303/// filtered by filesystem kind, sorted by modification time, and bounded by a
304/// result count or a timeout.
305///
306/// # Errors
307/// Returns an error when the search path does not exist or is not a directory,
308/// the glob pattern is invalid, an unknown `file_type` string is given, or the
309/// operation is cancelled by a timeout before any work could be salvaged.
310pub fn glob(glob: &Glob, pattern: &str, path: &str) -> Result<GlobOutput, String> {
311    glob_with(glob, pattern, &[path.to_string()], None)
312}
313
314/// Like [`glob`], but searches one or more `targets` in a single call.
315///
316/// Each target is walked as its own root (per-path roots keep each scan
317/// bounded to exactly what was asked). Results are merged with dedup and,
318/// when `sort_by_mtime` is set, re-ranked by mtime so the top-N is correct
319/// across all roots. Missing targets are skipped and reported in
320/// `missing_paths`; the call only fails when EVERY target is missing.
321///
322/// `on_match` streams each match as it is found, with paths rebased to the
323/// targets' common scope so multi-root output stays unique.
324///
325/// # Errors
326/// Returns an error when every target is missing, a target is not a
327/// directory, the glob pattern is invalid, an unknown `file_type` string is
328/// given, or the operation is cancelled by a timeout before any work could be
329/// salvaged.
330pub fn glob_with(
331    glob: &Glob,
332    pattern: &str,
333    targets: &[String],
334    on_match: Option<Arc<GlobMatchCallback>>,
335) -> Result<GlobOutput, String> {
336    // Normalize each pattern through the same parser the tool uses so the
337    // SDK `recursive: false` behavior is consistent: bare globs become
338    // `**/…`, scoped globs are folded into the target's base path.
339    let parsed = parse_find_pattern(pattern);
340    let specs: Vec<GlobTargetSpec> = targets
341        .iter()
342        .map(|t| {
343            let base = PathBuf::from(t);
344            let base = if parsed.base_path == Path::new(".") {
345                base
346            } else {
347                base.join(&parsed.base_path)
348            };
349            GlobTargetSpec {
350                base_path: base,
351                pattern: parsed.glob_pattern.clone(),
352                has_glob: parsed.has_glob,
353            }
354        })
355        .collect();
356    glob_targets_with(glob, &specs, on_match, None)
357}
358
359/// Search from per-target [`GlobTargetSpec`]s.
360///
361/// `cwd`, when provided, rebases output paths to be CWD-relative (feature:
362/// consistent, predictable paths) and records the working directory on the
363/// output so the TUI can build absolute OSC 8 file hyperlinks. When it is
364/// `None`, paths are rebased to the targets' common ancestor (the legacy
365/// behaviour of [`glob_with`]).
366pub fn glob_targets_with(
367    glob: &Glob,
368    specs: &[GlobTargetSpec],
369    on_match: Option<Arc<GlobMatchCallback>>,
370    cwd: Option<&Path>,
371) -> Result<GlobOutput, String> {
372    if specs.is_empty() {
373        return Err("no search targets provided".to_string());
374    }
375    let file_type = glob.file_type.as_deref().map(parse_file_type).transpose()?;
376    let format = glob
377        .format
378        .as_deref()
379        .map(PathFormat::parse_format)
380        .transpose()?
381        .unwrap_or_default();
382    // A sane default timeout: on expiry the partials are returned with
383    // `timed_out` instead of a blind error.
384    let timeout_ms = glob.timeout_ms.or(Some(DEFAULT_GLOB_TIMEOUT_MS));
385
386    // Tolerate missing targets: skip them, fail only when all are missing.
387    let mut missing_paths: Vec<String> = Vec::new();
388    let mut valid: Vec<&GlobTargetSpec> = Vec::new();
389    for target in specs {
390        if target.base_path.exists() {
391            valid.push(target);
392        } else {
393            missing_paths.push(target.base_path.to_string_lossy().to_string());
394        }
395    }
396    if valid.is_empty() {
397        return Err(format!("Path not found: {}", missing_paths.join(", ")));
398    }
399
400    let abs_roots: Vec<PathBuf> = valid.iter().map(|t| t.base_path.clone()).collect();
401    let ancestor = if valid.len() > 1 {
402        super::common_ancestor(&abs_roots)
403    } else {
404        None
405    };
406    // Scope label: CWD-relative when a CWD is available (feature: predictable
407    // relative paths), otherwise the common ancestor or the single target.
408    let scope_buf = ancestor
409        .clone()
410        .unwrap_or_else(|| valid[0].base_path.clone());
411    let scope = cwd
412        .map(|cwd| format_path_relative_to_cwd(&scope_buf.to_string_lossy(), cwd, true))
413        .unwrap_or_else(|| scope_buf.to_string_lossy().replace('\\', "/"));
414
415    // Fetch one extra result per target so a cap hit is detectable: getting
416    // more than `max_results` proves the cap cut the list.
417    let fetch_max = glob.max_results.map(|m| m.saturating_add(1));
418    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
419    let mut merged: Vec<GlobEntry> = Vec::new();
420    let mut timed_out = false;
421    let mut limit_reached = false;
422
423    // A literal file target (no glob chars AND an existing file) short-circuits
424    // the walk: the file is returned directly. Cheap metadata read, done inline.
425    // Walk targets are scanned in parallel (one scoped thread per target) since
426    // each walk is independent I/O-bound work; results are merged back in target
427    // order afterwards so dedupe and ordering stay deterministic.
428    let mut walk_targets: Vec<&GlobTargetSpec> = Vec::new();
429    for target in &valid {
430        if !target.has_glob && target.base_path.is_file() {
431            make_literal_file_entry(
432                &target.base_path,
433                cwd,
434                ancestor.as_deref(),
435                &mut seen,
436                &mut merged,
437            );
438        } else {
439            walk_targets.push(target);
440        }
441    }
442
443    // Struct-ok tuples per target: (entries, raw count before dedupe, timed_out,
444    // errored with message). Scoping keeps the borrows of `valid`, `cwd`, and
445    // `on_match` alive for the whole scan; each thread gets owned captures.
446    struct TargetScan {
447        entries: Vec<GlobEntry>,
448        entry_count: u32,
449        timed_out: bool,
450    }
451
452    let scans: Vec<Result<TargetScan, String>> = std::thread::scope(|scope| {
453        walk_targets
454            .iter()
455            .copied()
456            .map(|target| {
457                let base_str = target.base_path.to_string_lossy().to_string();
458                let wrapped: Option<Arc<GlobMatchCallback>> = on_match.as_ref().map(|cb| {
459                    let cb = cb.clone();
460                    let base_owned = base_str.clone();
461                    let ancestor = ancestor.clone();
462                    let cb_arc: Arc<GlobMatchCallback> = Arc::new(move |m: &GlobMatch| {
463                        let rel = rebase_path(&base_owned, &m.path, ancestor.as_deref());
464                        cb(&GlobMatch {
465                            path: rel,
466                            file_type: m.file_type,
467                            mtime: m.mtime,
468                            size: m.size,
469                        });
470                    });
471                    cb_arc
472                });
473                let pattern = if target.pattern.is_empty() {
474                    "**".to_string()
475                } else {
476                    target.pattern.clone()
477                };
478                let path = base_str.clone();
479                let ancestor_owned = ancestor.clone();
480                scope.spawn(move || -> Result<TargetScan, String> {
481                    let sdk_result = sdk_glob(GlobOptions {
482                        pattern,
483                        path,
484                        file_type,
485                        // Recursion is already encoded in the pattern by the
486                        // tool's parser: bare globs arrive as `**/…`
487                        // (recursive), scoped globs as `dir/…` (shallow).
488                        // Disable the SDK's own `**/`-prepending so a scoped
489                        // `*.rs` does not silently escalate into `**/*.rs`.
490                        recursive: Some(false),
491                        hidden: glob.hidden,
492                        max_results: fetch_max,
493                        gitignore: glob.gitignore,
494                        sort_by_mtime: Some(glob.sort_by_mtime.unwrap_or(true)),
495                        cache: None,
496                        include_node_modules: None,
497                        timeout_ms,
498                        on_match: wrapped,
499                    })?;
500                    let mut entries: Vec<GlobEntry> = Vec::new();
501                    for m in sdk_result.matches {
502                        let abs = Path::new(&base_str).join(&m.path);
503                        let display = if cwd.is_some() || ancestor_owned.is_some() {
504                            display_path(
505                                &abs,
506                                cwd,
507                                ancestor_owned.as_deref(),
508                                m.file_type == FileType::Dir,
509                            )
510                        } else {
511                            // Legacy single-target calls (no CWD) keep paths
512                            // relative to the searched root.
513                            with_trailing_slash(
514                                &m.path.replace('\\', "/"),
515                                m.file_type == FileType::Dir,
516                            )
517                        };
518                        entries.push(GlobEntry {
519                            path: display,
520                            file_type: file_type_str(m.file_type).to_owned(),
521                            mtime_ms: m.mtime,
522                            size_bytes: m.size,
523                        });
524                    }
525                    let entry_count = u32::try_from(entries.len()).unwrap_or(u32::MAX);
526                    Ok(TargetScan {
527                        entries,
528                        entry_count,
529                        timed_out: sdk_result.timed_out,
530                    })
531                })
532            })
533            .map(|handle| {
534                handle
535                    .join()
536                    .map_err(|_| "glob target scan panicked".to_string())
537                    .and_then(|inner| inner)
538            })
539            .collect()
540    });
541
542    for scan in scans {
543        let scan = scan?;
544        timed_out = timed_out || scan.timed_out;
545        for entry in scan.entries {
546            if seen.insert(entry.path.clone()) {
547                merged.push(entry);
548            }
549        }
550        // A capped target means more entries may exist than were returned.
551        if let Some(max) = glob.max_results
552            && scan.entry_count > max
553        {
554            limit_reached = true;
555        }
556    }
557
558    // Global re-rank when sorting: each target's results were individually
559    // capped, so the merged top-N must reflect the global mtime order. Sorting
560    // is the default: most recently modified files surface first.
561    if glob.sort_by_mtime.unwrap_or(true) {
562        merged.sort_by(|a, b| {
563            b.mtime_ms
564                .unwrap_or(0.0)
565                .total_cmp(&a.mtime_ms.unwrap_or(0.0))
566                .then_with(|| a.path.cmp(&b.path))
567        });
568    }
569    if let Some(max) = glob.max_results {
570        if merged.len() > max as usize {
571            limit_reached = true;
572        }
573        merged.truncate(max as usize);
574    }
575
576    let total = u32::try_from(merged.len().min(u32::MAX as usize)).unwrap_or(u32::MAX);
577
578    let (useless, mut note) = if merged.is_empty() && !timed_out {
579        (
580            Some(true),
581            Some("No files found matching pattern".to_string()),
582        )
583    } else if timed_out && merged.is_empty() {
584        (
585            None,
586            Some(
587                "Glob timed out before finding any matches — the scan is incomplete, NOT proof of absence. The walk is bounded by directory size, not pattern width; scope the search to a deeper directory (e.g. `sub/dir/*.ext` instead of `*.ext` at a huge root)."
588                    .to_string(),
589            ),
590        )
591    } else if timed_out {
592        (
593            None,
594            Some(
595                "Glob timed out; results are partial and incomplete — scope to a deeper directory instead of retrying blindly"
596                    .to_string(),
597            ),
598        )
599    } else if limit_reached {
600        (
601            None,
602            Some(
603                "Limit reached — more entries may exist; narrow the pattern or add file_type to reduce results"
604                    .to_string(),
605            ),
606        )
607    } else {
608        (None, None)
609    };
610
611    if !missing_paths.is_empty() {
612        let missing_note = format!("Skipped missing paths: {}", missing_paths.join(", "));
613        note = Some(match note {
614            Some(n) => format!("{n}\n{missing_note}"),
615            None => missing_note,
616        });
617    }
618
619    let path_list: Vec<String> = merged.iter().map(|m| m.path.clone()).collect();
620    let formatted = if path_list.is_empty() {
621        String::new()
622    } else {
623        format_paths(&path_list, format)
624    };
625
626    Ok(GlobOutput {
627        matches: merged,
628        total,
629        limit_reached: if limit_reached { Some(true) } else { None },
630        timed_out: if timed_out { Some(true) } else { None },
631        note,
632        useless,
633        missing_paths: if missing_paths.is_empty() {
634            None
635        } else {
636            Some(missing_paths)
637        },
638        formatted,
639        scope,
640        cwd: cwd.map(|p| p.to_string_lossy().to_string()),
641    })
642}
643
644/// Append a literal-file entry for `path` and dedupe against `seen`.
645fn make_literal_file_entry(
646    path: &PathBuf,
647    cwd: Option<&Path>,
648    ancestor: Option<&Path>,
649    seen: &mut std::collections::HashSet<String>,
650    merged: &mut Vec<GlobEntry>,
651) {
652    let display = display_path(path, cwd, ancestor, false);
653    if seen.insert(display.clone()) {
654        let meta = std::fs::metadata(path).ok();
655        merged.push(GlobEntry {
656            path: display,
657            file_type: "file".to_string(),
658            mtime_ms: meta
659                .as_ref()
660                .and_then(|m| m.modified().ok())
661                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
662                .map(|d| d.as_secs_f64() * 1000.0),
663            size_bytes: meta.map(|m| m.len() as f64),
664        });
665    }
666}
667
668/// Rebase a target-relative path onto `ancestor` or return it unchanged.
669fn rebase_path(target: &str, relative: &str, ancestor: Option<&Path>) -> String {
670    let Some(ancestor) = ancestor else {
671        return relative.to_string();
672    };
673    let abs = Path::new(target).join(relative);
674    abs.strip_prefix(ancestor)
675        .map(|p| p.to_string_lossy().replace('\\', "/"))
676        .unwrap_or_else(|_| relative.to_string())
677}
678
679/// Render a match path: CWD-relative when `cwd` is set and the path lives
680/// under it, else rebased to the common `ancestor`, else the raw path.
681fn display_path(
682    abs: &Path,
683    cwd: Option<&Path>,
684    ancestor: Option<&Path>,
685    trailing_slash: bool,
686) -> String {
687    if let Some(cwd) = cwd
688        && let Ok(rel) = abs.strip_prefix(cwd)
689    {
690        return with_trailing_slash(&rel.to_string_lossy().replace('\\', "/"), trailing_slash);
691    }
692    if let Some(ancestor) = ancestor
693        && let Ok(rel) = abs.strip_prefix(ancestor)
694    {
695        return with_trailing_slash(&rel.to_string_lossy().replace('\\', "/"), trailing_slash);
696    }
697    with_trailing_slash(&abs.to_string_lossy().replace('\\', "/"), trailing_slash)
698}
699
700fn with_trailing_slash(s: &str, trailing_slash: bool) -> String {
701    let mut s = s.to_owned();
702    if trailing_slash && !s.ends_with('/') {
703        s.push('/');
704    }
705    s
706}
707
708#[must_use]
709const fn file_type_str(ft: FileType) -> &'static str {
710    match ft {
711        FileType::File => "file",
712        FileType::Dir => "dir",
713        FileType::Symlink => "symlink",
714    }
715}
716
717fn parse_file_type(s: &str) -> Result<FileType, String> {
718    match s {
719        "file" => Ok(FileType::File),
720        "dir" => Ok(FileType::Dir),
721        "symlink" => Ok(FileType::Symlink),
722        other => Err(format!(
723            "invalid file_type `{other}`; expected \"file\", \"dir\", or \"symlink\""
724        )),
725    }
726}