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/// How an ordered list of globs decides, the way ripgrep and `.gitignore`
102/// read one: a glob starting with `!` excludes, and when several match, the
103/// last one given wins. If any glob is not an exclusion, a candidate matching
104/// none of them is left out — the list is a whitelist with exceptions.
105/// Otherwise it is a blacklist, and a candidate matching none is kept.
106struct Rules {
107    negated: Vec<bool>,
108    any_positive: bool,
109}
110
111impl Rules {
112    /// `last` is the highest index, in command-line order, of the globs that
113    /// matched.
114    fn keep(&self, last: Option<usize>) -> bool {
115        match last {
116            Some(i) => !self.negated[i],
117            None => !self.any_positive,
118        }
119    }
120}
121
122/// Split a leading `!` off. `\!` still means a literal `!`: globset reads the
123/// backslash as an escape.
124fn parse_rule(p: &str) -> (bool, &str) {
125    match p.strip_prefix('!') {
126        Some(rest) => (true, rest),
127        None => (false, p),
128    }
129}
130
131/// `-g`: which members are searched.
132pub struct Globs {
133    set: globset::GlobSet,
134    rules: Rules,
135}
136
137impl Globs {
138    pub fn is_match(&self, candidate: impl AsRef<Path>) -> bool {
139        self.rules.keep(self.set.matches(candidate.as_ref()).into_iter().max())
140    }
141}
142
143pub fn build_globs(pats: &[String]) -> anyhow::Result<Option<Globs>> {
144    if pats.is_empty() {
145        return Ok(None);
146    }
147    let mut b = globset::GlobSetBuilder::new();
148    let mut negated = Vec::new();
149    for p in pats {
150        let (neg, glob) = parse_rule(p);
151        b.add(globset::Glob::new(glob)?);
152        negated.push(neg);
153    }
154    let any_positive = negated.contains(&false);
155    Ok(Some(Globs { set: b.build()?, rules: Rules { negated, any_positive } }))
156}
157
158/// `--archive-glob`: which files a directory walk keeps.
159///
160/// Split by shape, the way `.gitignore` reads a pattern: one without a `/`
161/// matches the file name at any depth (`2026-08-*.tgz`), one with a `/`
162/// matches the path relative to the directory that was named (`host1/*.tgz`).
163/// The two kinds stay one ordered list for [`Rules`], so the last match wins
164/// across them: `!2026-09-*` then `web1/*` keeps web1's September.
165pub struct ArchiveGlobs {
166    names: globset::GlobSet,
167    paths: globset::GlobSet,
168    /// Position on the command line of each glob in `names` and `paths`.
169    name_at: Vec<usize>,
170    path_at: Vec<usize>,
171    rules: Rules,
172}
173
174impl ArchiveGlobs {
175    fn is_match(&self, root: &Path, file: &Path) -> bool {
176        let by_name = file.file_name().map(|n| self.names.matches(n)).unwrap_or_default();
177        let by_path = file.strip_prefix(root).map(|r| self.paths.matches(r)).unwrap_or_default();
178        let last = by_name.into_iter().map(|i| self.name_at[i])
179            .chain(by_path.into_iter().map(|i| self.path_at[i]))
180            .max();
181        self.rules.keep(last)
182    }
183}
184
185pub fn build_archive_globs(pats: &[String]) -> anyhow::Result<Option<ArchiveGlobs>> {
186    if pats.is_empty() {
187        return Ok(None);
188    }
189    let mut names = globset::GlobSetBuilder::new();
190    let mut paths = globset::GlobSetBuilder::new();
191    let (mut name_at, mut path_at, mut negated) = (Vec::new(), Vec::new(), Vec::new());
192    for (i, p) in pats.iter().enumerate() {
193        let (neg, glob) = parse_rule(p);
194        let g = globset::Glob::new(glob)?;
195        if glob.contains('/') {
196            paths.add(g);
197            path_at.push(i);
198        } else {
199            names.add(g);
200            name_at.push(i);
201        }
202        negated.push(neg);
203    }
204    let any_positive = negated.contains(&false);
205    Ok(Some(ArchiveGlobs {
206        names: names.build()?,
207        paths: paths.build()?,
208        name_at,
209        path_at,
210        rules: Rules { negated, any_positive },
211    }))
212}
213
214/// `standard_filters(false)`: a log directory is not a source tree, so
215/// `.gitignore`/hidden-file rules must not silently drop archives.
216///
217/// `follow_links(true)`: `/var/log` is full of symlinked and rotated-archive
218/// symlinks, so `file_type()` must report the target's type rather than
219/// `symlink`, or every symlinked log would be silently skipped by the
220/// `is_file()` filter below. A broken symlink then surfaces as an `Err` from
221/// the walk (the same as naming it directly would), which the `Err` arm
222/// below records as an error rather than dropping it — a path's failure must
223/// be reported the same way whether it was discovered by the walk or named
224/// on the command line. `ignore` itself detects directory symlink cycles
225/// (reported as an `Err`, not a hang) and a mutual file-symlink pair hits the
226/// OS's own `ELOOP` first, also surfacing as an `Err` — see `tests/source.rs`
227/// and the fix-round-1 section of the task report for how this was verified.
228///
229/// Following links means a symlink and its target (or two hardlinks) can
230/// both be yielded here as distinct paths to the same file; `resolve`'s
231/// `push_unique` is what collapses those back down to one work item.
232fn walk(root: &Path, errors: &mut Vec<String>) -> Vec<PathBuf> {
233    let mut out = Vec::new();
234    for res in ignore::WalkBuilder::new(root)
235        .standard_filters(false)
236        .follow_links(true)
237        .build()
238    {
239        match res {
240            Ok(e) => {
241                if e.file_type().is_some_and(|t| t.is_file()) {
242                    out.push(e.into_path());
243                }
244            }
245            Err(e) => errors.push(format!("{}: {e}", root.display())),
246        }
247    }
248    out
249}
250
251