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.
132///
133/// Every glob matches against the member's whole path; one without a `/` also
134/// matches its file name, so `-g access.log` finds `logs/access.log` as it
135/// would in ripgrep. Strictly a superset of whole-path matching, which was all
136/// there was before 0.7.0, so no glob selects fewer members than it used to.
137pub struct Globs {
138 paths: globset::GlobSet,
139 names: globset::GlobSet,
140 /// Position on the command line of each glob in `names`.
141 name_at: Vec<usize>,
142 rules: Rules,
143}
144
145impl Globs {
146 pub fn is_match(&self, candidate: impl AsRef<Path>) -> bool {
147 let candidate = candidate.as_ref();
148 let by_name = candidate.file_name().map(|n| self.names.matches(n)).unwrap_or_default();
149 let last = self.paths.matches(candidate).into_iter()
150 .chain(by_name.into_iter().map(|i| self.name_at[i]))
151 .max();
152 self.rules.keep(last)
153 }
154}
155
156pub fn build_globs(pats: &[String]) -> anyhow::Result<Option<Globs>> {
157 if pats.is_empty() {
158 return Ok(None);
159 }
160 let mut paths = globset::GlobSetBuilder::new();
161 let mut names = globset::GlobSetBuilder::new();
162 let (mut name_at, mut negated) = (Vec::new(), Vec::new());
163 for (i, p) in pats.iter().enumerate() {
164 let (neg, glob) = parse_rule(p);
165 let g = globset::Glob::new(glob)?;
166 if !glob.contains('/') {
167 names.add(g.clone());
168 name_at.push(i);
169 }
170 paths.add(g);
171 negated.push(neg);
172 }
173 let any_positive = negated.contains(&false);
174 Ok(Some(Globs {
175 paths: paths.build()?,
176 names: names.build()?,
177 name_at,
178 rules: Rules { negated, any_positive },
179 }))
180}
181
182/// `--archive-glob`: which files a directory walk keeps.
183///
184/// Split by shape, the way `.gitignore` reads a pattern: one without a `/`
185/// matches the file name at any depth (`2026-08-*.tgz`), one with a `/`
186/// matches the path relative to the directory that was named (`host1/*.tgz`).
187/// The two kinds stay one ordered list for [`Rules`], so the last match wins
188/// across them: `!2026-09-*` then `web1/*` keeps web1's September.
189pub struct ArchiveGlobs {
190 names: globset::GlobSet,
191 paths: globset::GlobSet,
192 /// Position on the command line of each glob in `names` and `paths`.
193 name_at: Vec<usize>,
194 path_at: Vec<usize>,
195 rules: Rules,
196}
197
198impl ArchiveGlobs {
199 fn is_match(&self, root: &Path, file: &Path) -> bool {
200 let by_name = file.file_name().map(|n| self.names.matches(n)).unwrap_or_default();
201 let by_path = file.strip_prefix(root).map(|r| self.paths.matches(r)).unwrap_or_default();
202 let last = by_name.into_iter().map(|i| self.name_at[i])
203 .chain(by_path.into_iter().map(|i| self.path_at[i]))
204 .max();
205 self.rules.keep(last)
206 }
207}
208
209pub fn build_archive_globs(pats: &[String]) -> anyhow::Result<Option<ArchiveGlobs>> {
210 if pats.is_empty() {
211 return Ok(None);
212 }
213 let mut names = globset::GlobSetBuilder::new();
214 let mut paths = globset::GlobSetBuilder::new();
215 let (mut name_at, mut path_at, mut negated) = (Vec::new(), Vec::new(), Vec::new());
216 for (i, p) in pats.iter().enumerate() {
217 let (neg, glob) = parse_rule(p);
218 let g = globset::Glob::new(glob)?;
219 if glob.contains('/') {
220 paths.add(g);
221 path_at.push(i);
222 } else {
223 names.add(g);
224 name_at.push(i);
225 }
226 negated.push(neg);
227 }
228 let any_positive = negated.contains(&false);
229 Ok(Some(ArchiveGlobs {
230 names: names.build()?,
231 paths: paths.build()?,
232 name_at,
233 path_at,
234 rules: Rules { negated, any_positive },
235 }))
236}
237
238/// `standard_filters(false)`: a log directory is not a source tree, so
239/// `.gitignore`/hidden-file rules must not silently drop archives.
240///
241/// `follow_links(true)`: `/var/log` is full of symlinked and rotated-archive
242/// symlinks, so `file_type()` must report the target's type rather than
243/// `symlink`, or every symlinked log would be silently skipped by the
244/// `is_file()` filter below. A broken symlink then surfaces as an `Err` from
245/// the walk (the same as naming it directly would), which the `Err` arm
246/// below records as an error rather than dropping it — a path's failure must
247/// be reported the same way whether it was discovered by the walk or named
248/// on the command line. `ignore` itself detects directory symlink cycles
249/// (reported as an `Err`, not a hang) and a mutual file-symlink pair hits the
250/// OS's own `ELOOP` first, also surfacing as an `Err` — see `tests/source.rs`
251/// and the fix-round-1 section of the task report for how this was verified.
252///
253/// Following links means a symlink and its target (or two hardlinks) can
254/// both be yielded here as distinct paths to the same file; `resolve`'s
255/// `push_unique` is what collapses those back down to one work item.
256fn walk(root: &Path, errors: &mut Vec<String>) -> Vec<PathBuf> {
257 let mut out = Vec::new();
258 for res in ignore::WalkBuilder::new(root)
259 .standard_filters(false)
260 .follow_links(true)
261 .build()
262 {
263 match res {
264 Ok(e) => {
265 if e.file_type().is_some_and(|t| t.is_file()) {
266 out.push(e.into_path());
267 }
268 }
269 Err(e) => errors.push(format!("{}: {e}", root.display())),
270 }
271 }
272 out
273}
274
275