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}