Skip to main content

trg/
source.rs

1//! Positional arguments become an ordered list of archives to search.
2//! Order is preserved because these archives are days, so argument order is
3//! chronological order.
4
5use std::collections::HashSet;
6use std::path::{Path, PathBuf};
7
8#[cfg(unix)]
9use std::os::unix::fs::MetadataExt;
10
11/// Identity used to de-duplicate resolved work items: on Unix, `(dev, ino)`,
12/// which collapses both a symlink-and-its-target pair *and* hardlinks to the
13/// same file down to a single search. `canonicalize()` would catch the
14/// symlink case but not hardlinks -- and hardlinked rotated logs are a real
15/// pattern this project has already been bitten by (GNU tar's inode-based
16/// dedup of a hardlinked copy once produced a 696 KB archive that claimed to
17/// hold 577 MB). Non-Unix targets have no `dev`/`ino`, so they fall back to a
18/// canonicalized path -- macOS and Linux are the supported targets, so that
19/// fallback only needs to keep the code compiling elsewhere, not be as
20/// thorough.
21#[cfg(unix)]
22type Identity = (u64, u64);
23#[cfg(not(unix))]
24type Identity = PathBuf;
25
26/// `None` means identity couldn't be determined (metadata read failed after
27/// the fact, e.g. a TOCTOU removal). Callers must treat that as "assume
28/// unique" -- better to search a file twice than to silently drop it.
29#[cfg(unix)]
30fn identity(p: &Path) -> Option<Identity> {
31    std::fs::metadata(p).ok().map(|md| (md.dev(), md.ino()))
32}
33
34#[cfg(not(unix))]
35fn identity(p: &Path) -> Option<Identity> {
36    std::fs::canonicalize(p).ok()
37}
38
39/// Push `p` onto `items` unless something already pushed with the same file
40/// identity got there first. Dedup is by first-occurrence in resolution
41/// order, across the whole run -- not just within one directory walk -- so
42/// `trg pat a.log a.log` and `trg pat dir/` (where `dir/` holds a file and a
43/// symlink to it) both search the underlying file exactly once.
44fn push_unique(items: &mut Vec<PathBuf>, seen: &mut HashSet<Identity>, p: PathBuf) {
45    match identity(&p) {
46        Some(id) if !seen.insert(id) => {} // already seen: drop this occurrence
47        _ => items.push(p),
48    }
49}
50
51pub fn resolve(paths: &[PathBuf]) -> (Vec<PathBuf>, Vec<String>) {
52    resolve_with(paths, None)
53}
54
55/// `resolve`, keeping only the walked files that `archive_globs` selects.
56///
57/// Files named on the command line are never filtered, as with `rg -g`: naming
58/// a file is a stronger statement than any pattern.
59///
60/// A glob that selects nothing anywhere in the walk is an error, not an empty
61/// search: the run would otherwise exit 1, the claim that everything asked for
62/// was read and nothing matched, and a typo like `2026-8-*` would read as
63/// clean logs. It is judged across the whole run, so one directory without a
64/// match is normal when another has them.
65pub fn resolve_with(
66    paths: &[PathBuf],
67    archive_globs: Option<&ArchiveGlobs>,
68) -> (Vec<PathBuf>, Vec<String>) {
69    let mut items = Vec::new();
70    let mut errors = Vec::new();
71    let mut seen = HashSet::new();
72    let mut walked_any = false;
73    let mut kept_any = false;
74
75    for p in paths {
76        match std::fs::metadata(p) {
77            Err(e) => errors.push(format!("{}: {e}", p.display())),
78            Ok(md) if md.is_dir() => {
79                walked_any = true;
80                let mut found = walk(p, &mut errors);
81                if let Some(g) = archive_globs {
82                    found.retain(|f| g.is_match(p, f));
83                }
84                kept_any |= !found.is_empty();
85                found.sort();
86                for f in found {
87                    push_unique(&mut items, &mut seen, f);
88                }
89            }
90            Ok(_) => push_unique(&mut items, &mut seen, p.clone()),
91        }
92    }
93
94    if archive_globs.is_some() && walked_any && !kept_any {
95        errors.push("--archive-glob matched no files in the directories searched".to_string());
96    }
97
98    (items, errors)
99}
100
101/// `--archive-glob`: which files a directory walk keeps.
102///
103/// Split by shape, the way `.gitignore` reads a pattern: one without a `/`
104/// matches the file name at any depth (`2026-08-*.tgz`), one with a `/`
105/// matches the path relative to the directory that was named (`host1/*.tgz`).
106pub struct ArchiveGlobs {
107    names: globset::GlobSet,
108    paths: globset::GlobSet,
109}
110
111impl ArchiveGlobs {
112    fn is_match(&self, root: &Path, file: &Path) -> bool {
113        file.file_name().is_some_and(|n| self.names.is_match(n))
114            || file.strip_prefix(root).is_ok_and(|rel| self.paths.is_match(rel))
115    }
116}
117
118pub fn build_archive_globs(pats: &[String]) -> anyhow::Result<Option<ArchiveGlobs>> {
119    if pats.is_empty() {
120        return Ok(None);
121    }
122    let mut names = globset::GlobSetBuilder::new();
123    let mut paths = globset::GlobSetBuilder::new();
124    for p in pats {
125        let g = globset::Glob::new(p)?;
126        if p.contains('/') { paths.add(g) } else { names.add(g) };
127    }
128    Ok(Some(ArchiveGlobs { names: names.build()?, paths: paths.build()? }))
129}
130
131/// `standard_filters(false)`: a log directory is not a source tree, so
132/// `.gitignore`/hidden-file rules must not silently drop archives.
133///
134/// `follow_links(true)`: `/var/log` is full of symlinked and rotated-archive
135/// symlinks, so `file_type()` must report the target's type rather than
136/// `symlink`, or every symlinked log would be silently skipped by the
137/// `is_file()` filter below. A broken symlink then surfaces as an `Err` from
138/// the walk (the same as naming it directly would), which the `Err` arm
139/// below records as an error rather than dropping it — a path's failure must
140/// be reported the same way whether it was discovered by the walk or named
141/// on the command line. `ignore` itself detects directory symlink cycles
142/// (reported as an `Err`, not a hang) and a mutual file-symlink pair hits the
143/// OS's own `ELOOP` first, also surfacing as an `Err` — see `tests/source.rs`
144/// and the fix-round-1 section of the task report for how this was verified.
145///
146/// Following links means a symlink and its target (or two hardlinks) can
147/// both be yielded here as distinct paths to the same file; `resolve`'s
148/// `push_unique` is what collapses those back down to one work item.
149fn walk(root: &Path, errors: &mut Vec<String>) -> Vec<PathBuf> {
150    let mut out = Vec::new();
151    for res in ignore::WalkBuilder::new(root)
152        .standard_filters(false)
153        .follow_links(true)
154        .build()
155    {
156        match res {
157            Ok(e) => {
158                if e.file_type().is_some_and(|t| t.is_file()) {
159                    out.push(e.into_path());
160                }
161            }
162            Err(e) => errors.push(format!("{}: {e}", root.display())),
163        }
164    }
165    out
166}
167
168pub fn build_globs(pats: &[String]) -> anyhow::Result<Option<globset::GlobSet>> {
169    if pats.is_empty() {
170        return Ok(None);
171    }
172    let mut b = globset::GlobSetBuilder::new();
173    for p in pats {
174        b.add(globset::Glob::new(p)?);
175    }
176    Ok(Some(b.build()?))
177}