1use std::ffi::OsString;
5use std::path::PathBuf;
6
7use crate::{print, search};
8
9pub const HELP: &str = "\
10trg — search inside compressed log archives
11(.tgz, .tar.zst, .tar.xz, .tar.bz2, .tar.lz4, .tar.Z, .zip)
12
13USAGE:
14 trg [OPTIONS] PATTERN PATH...
15 trg [OPTIONS] -e PATTERN... PATH...
16 trg [OPTIONS] -T PATH...
17
18At least one PATH is required. trg never searches the current directory by
19default: a silent walk of $PWD is not a safe guess for a tool whose exit 1
20is a claim that the logs are clean.
21
22MATCHING (same meaning as ripgrep):
23 -e, --regexp PAT add a pattern; repeatable
24 -i, --ignore-case case-insensitive
25 -S, --smart-case case-insensitive unless the pattern has uppercase
26 -w, --word-regexp match whole words only
27 -F, --fixed-strings treat patterns as literals
28 -v, --invert-match show non-matching lines
29 -a, --text search members that look binary
30 -m, --max-count NUM stop after NUM matches per member
31 --crlf treat CRLF as the line terminator
32
33OUTPUT (same meaning as ripgrep):
34 -n, --line-number show line numbers (default)
35 -N, --no-line-number hide line numbers
36 -A, --after NUM lines of trailing context
37 -B, --before NUM lines of leading context
38 -C, --context NUM lines of context on both sides
39 -o, --only-matching print only the matching part
40 -c, --count print a count per member
41 -l, --files-with-matches
42 print archive:member paths only
43 -q, --quiet print nothing; exit code only
44 --json JSON Lines output
45 --color WHEN auto | always | never (auto: only on a tty)
46 --escape WHEN show control bytes from logs and member names as
47 text (\\x1b) instead of sending them to the
48 terminal: auto | always | never (auto: on a tty,
49 per stream; piped output stays byte-exact)
50
51 If several output modes are given, the most suppressive wins: -q, then
52 -l, then -c, then --json.
53
54ARCHIVES:
55 -g, --glob PAT filter MEMBERS inside archives; repeatable. Without
56 a `/` it also matches the member's file name. A
57 leading ! excludes; the last matching glob wins
58 --archive-glob PAT
59 when walking a directory, search only the archives
60 matching PAT; repeatable. Without a `/` it matches
61 the file name at any depth, with one the path below
62 the directory named. Files named explicitly are
63 always searched. A leading ! excludes, as for -g.
64 Matching nothing is exit 2
65 -T, --list-members list member paths without searching. Takes no
66 PATTERN at all: every positional is a PATH, so
67 `trg -T *.tgz` lists all of them
68 --archive-sep C separator in archive:member:line (default ':')
69 --no-sort allow output in completion order
70
71RESOURCES:
72 -j, --jobs NUM archives searched concurrently (default 1)
73 --turbo -j <ncpus> --nice 0; I/O stays idle and the cache
74 is still dropped
75 --nice NUM scheduling priority (default 10)
76 --no-nice do not lower priority
77 --no-drop-cache keep archive pages in the page cache
78 --load-limit N run one job at a time while the 1-min load average
79 from other processes exceeds N, and add jobs back
80 as it falls. Rechecked every 5 s
81
82 --examples worked examples of every option; try
83 `trg --examples | less`
84 -h, --help this message
85 -V, --version version
86
87EXIT CODES:
88 0 matches found, everything read
89 1 no matches, everything read
90 2 something went unread — never trust a 1 you did not get
91
92NOTES (documented, and surprising the first time):
93 -T lists every member, including ones a search skips as binary.
94 Two directories symlinking the same file search it once (identity dedup).
95 An intact archive behind a corrupt gzip trailer is a 0; truncation is
96 still caught and is a 2.
97 With -j above 1, results wait so they come out in order. Once more than
98 64 MB is waiting, trg writes it out of order, with a note on stderr.
99 Memory can still peak well above 64 MB: archives being searched are not
100 counted.
101 On a broken pipe (`| head`) trg stops early and exits 0, leaving later
102 archives unread.
103 Compression is detected from content, never from the filename, so there is
104 no -z flag. gzip, zstd, xz, bzip2 and lz4 are recognised, each unwrapped
105 one level, so a logrotate'd access.log.1.gz, .zst, .xz, .bz2 or .lz4
106 inside the archive is searched as text rather than skipped as binary.
107 .Z (compress) is read too, but only as a whole file: a .Z member
108 inside an archive is reported unread, a 2.
109 A zip's members are searched like a tar's, compressed ones included. A
110 zip or tar inside another archive is searched as bytes, not opened.
111 .Z has no length and no checksum, so a truncated .Z reads as a shorter
112 file with no error. A .Z with no match is still a 1, with a note on
113 stderr that the file could not vouch for its own end.
114 --inflate and --inflate-budget are accepted and ignored since 0.7.0, with
115 a note on stderr: trg always streams.
116";
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum ColorArg { Auto, Always, Never }
120
121pub struct Args {
122 pub patterns: Vec<String>,
123 pub paths: Vec<PathBuf>,
124 pub globs: Vec<String>,
125 pub archive_globs: Vec<String>,
126 pub jobs: usize,
127 pub nice: Option<i32>,
128 pub drop_cache: bool,
129 pub deprecated: Vec<&'static str>,
132 pub load_limit: Option<f64>,
133 pub archive_sep: char,
134 pub sort: bool,
135 pub list_members: bool,
136 pub count: bool,
137 pub files_with_matches: bool,
138 pub quiet: bool,
139 pub json: bool,
140 pub color: ColorArg,
141 pub escape: ColorArg,
142 pub search: search::SearchOpts,
143 pub print: print::PrintOpts,
144}
145
146impl Default for Args {
147 fn default() -> Self {
148 Self {
149 patterns: Vec::new(),
150 paths: Vec::new(),
151 globs: Vec::new(),
152 archive_globs: Vec::new(),
153 jobs: 1,
154 nice: Some(10),
155 drop_cache: true,
156 deprecated: Vec::new(),
157 load_limit: None,
158 archive_sep: ':',
159 sort: true,
160 list_members: false,
161 count: false,
162 files_with_matches: false,
163 quiet: false,
164 json: false,
165 color: ColorArg::Auto,
166 escape: ColorArg::Auto,
167 search: search::SearchOpts::default(),
168 print: print::PrintOpts::default(),
169 }
170 }
171}
172
173fn parse_size(s: &str) -> anyhow::Result<u64> {
175 let s = s.trim();
176 let (num, mult) = match s.chars().last() {
177 Some('K') | Some('k') => (&s[..s.len() - 1], 1024),
178 Some('M') | Some('m') => (&s[..s.len() - 1], 1024 * 1024),
179 Some('G') | Some('g') => (&s[..s.len() - 1], 1024 * 1024 * 1024),
180 _ => (s, 1),
181 };
182 let n: u64 = num.parse()?;
183 n.checked_mul(mult)
184 .ok_or_else(|| anyhow::anyhow!("--inflate-budget value too large: {s}"))
185}
186
187fn deprecate(a: &mut Args, flag: &'static str) {
188 if !a.deprecated.contains(&flag) {
189 a.deprecated.push(flag);
190 }
191}
192
193pub fn parse() -> anyhow::Result<Args> {
194 parse_from(std::env::args_os())
195}
196
197pub fn parse_from<I>(argv: I) -> anyhow::Result<Args>
198where
199 I: IntoIterator<Item = OsString>,
200{
201 use lexopt::prelude::*;
202
203 let mut a = Args::default();
204 let mut explicit_jobs = false;
205 let mut explicit_nice = false;
206 let mut ctx_both: Option<usize> = None;
207 let mut positionals: Vec<OsString> = Vec::new();
211 let mut explicit_patterns = false;
212
213 let mut p = lexopt::Parser::from_iter(argv);
214 while let Some(arg) = p.next()? {
215 match arg {
216 Short('e') | Long("regexp") => {
217 a.patterns.push(p.value()?.string()?);
218 explicit_patterns = true;
219 }
220 Short('i') | Long("ignore-case") => a.search.case_insensitive = true,
221 Short('S') | Long("smart-case") => a.search.smart_case = true,
222 Short('w') | Long("word-regexp") => a.search.word = true,
223 Short('F') | Long("fixed-strings") => a.search.fixed = true,
224 Short('v') | Long("invert-match") => a.search.invert = true,
225 Short('a') | Long("text") => a.search.text = true,
226 Short('m') | Long("max-count") => {
227 a.search.max_count = Some(p.value()?.parse()?)
228 }
229 Long("crlf") => a.search.crlf = true,
230
231 Short('n') | Long("line-number") => a.search.line_numbers = true,
232 Short('N') | Long("no-line-number") => a.search.line_numbers = false,
233 Short('A') | Long("after") | Long("after-context") => {
234 a.search.after = p.value()?.parse()?
235 }
236 Short('B') | Long("before") | Long("before-context") => {
237 a.search.before = p.value()?.parse()?
238 }
239 Short('C') | Long("context") => ctx_both = Some(p.value()?.parse()?),
240 Short('o') | Long("only-matching") => a.print.only_matching = true,
241 Short('c') | Long("count") => a.count = true,
242 Short('l') | Long("files-with-matches") => a.files_with_matches = true,
243 Short('q') | Long("quiet") => a.quiet = true,
244 Long("json") => a.json = true,
245 Long("color") => {
246 a.color = match p.value()?.string()?.as_str() {
247 "auto" => ColorArg::Auto,
248 "always" => ColorArg::Always,
249 "never" => ColorArg::Never,
250 other => anyhow::bail!("--color expects auto|always|never, got '{other}'"),
251 }
252 }
253 Long("escape") => {
254 a.escape = match p.value()?.string()?.as_str() {
255 "auto" => ColorArg::Auto,
256 "always" => ColorArg::Always,
257 "never" => ColorArg::Never,
258 other => anyhow::bail!("--escape expects auto|always|never, got '{other}'"),
259 }
260 }
261
262 Short('g') | Long("glob") => a.globs.push(p.value()?.string()?),
263 Long("archive-glob") => a.archive_globs.push(p.value()?.string()?),
264 Short('T') | Long("list-members") => a.list_members = true,
265 Long("archive-sep") => {
266 let v = p.value()?.string()?;
267 let mut it = v.chars();
268 match (it.next(), it.next()) {
269 (Some(c), None) => a.archive_sep = c,
270 _ => anyhow::bail!("--archive-sep expects exactly one character"),
271 }
272 }
273 Long("no-sort") => a.sort = false,
274
275 Short('j') | Long("jobs") => {
276 a.jobs = p.value()?.parse()?;
277 explicit_jobs = true;
278 }
279 Long("turbo") => {
280 if !explicit_jobs { a.jobs = num_cpus::get(); }
281 if !explicit_nice { a.nice = Some(0); }
282 }
283 Long("nice") => {
284 a.nice = Some(p.value()?.parse()?);
285 explicit_nice = true;
286 }
287 Long("no-nice") => {
288 a.nice = None;
289 explicit_nice = true;
290 }
291 Long("no-drop-cache") => a.drop_cache = false,
292 Long("inflate") => {
297 match p.value()?.string()?.as_str() {
298 "auto" | "stream" | "buffer" => {}
299 other => anyhow::bail!("--inflate expects auto|stream|buffer, got '{other}'"),
300 }
301 deprecate(&mut a, "--inflate");
302 }
303 Long("inflate-budget") => {
304 parse_size(&p.value()?.string()?)?;
305 deprecate(&mut a, "--inflate-budget");
306 }
307 Long("load-limit") => a.load_limit = Some(p.value()?.parse()?),
308
309 Short('h') | Long("help") => {
310 print!("{HELP}");
311 std::process::exit(0);
312 }
313 Long("examples") => {
314 print!("{}", crate::examples::render());
315 std::process::exit(0);
316 }
317 Short('V') | Long("version") => {
318 println!("trg {}", env!("CARGO_PKG_VERSION"));
319 std::process::exit(0);
320 }
321
322 Value(v) => positionals.push(v),
323 other => return Err(other.unexpected().into()),
324 }
325 }
326
327 if let Some(n) = ctx_both {
330 a.search.before = n;
331 a.search.after = n;
332 }
333 a.print.mode = if a.quiet {
338 print::Mode::Quiet
339 } else if a.files_with_matches {
340 print::Mode::FilesWithMatches
341 } else if a.count {
342 print::Mode::Count
343 } else if a.json {
344 print::Mode::Json
345 } else {
346 print::Mode::Standard
347 };
348
349 let mut rest = positionals.into_iter();
363 if !a.list_members
364 && !explicit_patterns
365 && let Some(first) = rest.next()
366 {
367 a.patterns.push(first.string()?);
368 }
369 a.paths.extend(rest.map(PathBuf::from));
370
371 if a.patterns.is_empty() && !a.list_members {
373 anyhow::bail!("no pattern given\n\n{HELP}");
374 }
375 if a.paths.is_empty() {
383 anyhow::bail!("no path given: name at least one archive or directory\n\n{HELP}");
384 }
385 Ok(a)
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391
392 fn p(args: &[&str]) -> Args {
393 let argv: Vec<std::ffi::OsString> =
394 std::iter::once("trg".into()).chain(args.iter().map(|s| s.into())).collect();
395 parse_from(argv).unwrap()
396 }
397
398 #[test]
399 fn defaults_match_the_spec() {
400 let a = p(&["pat", "f.tgz"]);
401 assert_eq!(a.jobs, 1);
402 assert_eq!(a.nice, Some(10));
403 assert!(a.drop_cache);
404 assert!(a.deprecated.is_empty());
405 assert_eq!(a.archive_sep, ':');
406 assert!(a.sort);
407 assert_eq!(a.search.heap_limit, 16 * 1024 * 1024);
408 assert!(a.load_limit.is_none());
409 }
410
411 #[test]
412 fn first_positional_is_the_pattern_rest_are_paths() {
413 let a = p(&["NEEDLE", "a.tgz", "b.tgz"]);
414 assert_eq!(a.patterns, vec!["NEEDLE"]);
415 assert_eq!(a.paths.len(), 2);
416 }
417
418 #[test]
419 fn dash_e_patterns_do_not_consume_the_first_positional() {
420 let a = p(&["-e", "one", "-e", "two", "a.tgz"]);
421 assert_eq!(a.patterns, vec!["one", "two"]);
422 assert_eq!(a.paths.len(), 1);
423 }
424
425 #[test]
426 fn turbo_raises_jobs_and_clears_nice() {
427 let a = p(&["--turbo", "pat", "f.tgz"]);
428 assert_eq!(a.jobs, num_cpus::get());
429 assert_eq!(a.nice, Some(0));
430 assert!(a.drop_cache, "cache dropping stays on under --turbo");
431 }
432
433 #[test]
434 fn explicit_j_after_turbo_wins() {
435 let a = p(&["--turbo", "-j", "2", "pat", "f.tgz"]);
436 assert_eq!(a.jobs, 2);
437 }
438
439 #[test]
440 fn no_nice_disables_priority_lowering() {
441 let a = p(&["--no-nice", "pat", "f.tgz"]);
442 assert_eq!(a.nice, None);
443 }
444
445 #[test]
446 fn context_flags_match_rg_semantics() {
447 let a = p(&["-C", "3", "pat", "f.tgz"]);
448 assert_eq!((a.search.before, a.search.after), (3, 3));
449 let a = p(&["-A", "2", "-B", "1", "pat", "f.tgz"]);
450 assert_eq!((a.search.before, a.search.after), (1, 2));
451 }
452
453 #[test]
454 fn n_capital_disables_line_numbers() {
455 assert!(p(&["pat", "f.tgz"]).search.line_numbers);
456 assert!(!p(&["-N", "pat", "f.tgz"]).search.line_numbers);
457 }
458
459 #[test]
460 fn size_suffixes_parse() {
461 assert_eq!(parse_size("512K").unwrap(), 512 * 1024);
462 assert_eq!(parse_size("2M").unwrap(), 2 * 1024 * 1024);
463 assert_eq!(parse_size("1G").unwrap(), 1024 * 1024 * 1024);
464 assert_eq!(parse_size("1024").unwrap(), 1024);
465 assert_eq!(p(&["--inflate-budget", "1G", "p", "f"]).deprecated, ["--inflate-budget"]);
466 }
467
468 #[test]
469 fn globs_accumulate() {
470 let a = p(&["-g", "*.access.log", "-g", "*.error.log", "pat", "f.tgz"]);
471 assert_eq!(a.globs.len(), 2);
472 }
473
474 #[test]
475 fn archive_sep_is_configurable() {
476 assert_eq!(p(&["--archive-sep", "!", "pat", "f.tgz"]).archive_sep, '!');
477 }
478
479 #[test]
480 fn unknown_flag_is_an_error_not_a_pattern() {
481 let argv: Vec<std::ffi::OsString> =
482 ["trg", "--nonsense", "pat", "f.tgz"].iter().map(|s| s.into()).collect();
483 assert!(parse_from(argv).is_err());
484 }
485
486 #[test]
487 fn inflate_mode_rejects_garbage() {
488 let argv: Vec<std::ffi::OsString> =
489 ["trg", "--inflate", "sideways", "p", "f"].iter().map(|s| s.into()).collect();
490 assert!(parse_from(argv).is_err());
491 }
492
493 #[test]
494 fn crlf_flag_sets_the_search_opt_and_defaults_to_false() {
495 assert!(!p(&["pat", "f.tgz"]).search.crlf);
496 assert!(p(&["--crlf", "pat", "f.tgz"]).search.crlf);
497 }
498
499 #[test]
500 fn inflate_budget_overflow_is_an_error_not_a_wrapped_value() {
501 let argv: Vec<std::ffi::OsString> =
502 ["trg", "--inflate-budget", "18446744073709551615G", "p", "f"]
503 .iter().map(|s| s.into()).collect();
504 assert!(parse_from(argv).is_err());
505 }
506
507 #[test]
508 fn output_mode_precedence_is_most_suppressive_wins() {
509 assert_eq!(p(&["pat", "f"]).print.mode, print::Mode::Standard);
510 assert_eq!(p(&["-c", "pat", "f"]).print.mode, print::Mode::Count);
511 assert_eq!(p(&["-l", "pat", "f"]).print.mode, print::Mode::FilesWithMatches);
512 assert_eq!(p(&["-q", "pat", "f"]).print.mode, print::Mode::Quiet);
513 assert_eq!(p(&["--json", "pat", "f"]).print.mode, print::Mode::Json);
514 assert_eq!(p(&["-c", "-l", "pat", "f"]).print.mode, print::Mode::FilesWithMatches);
515 assert_eq!(p(&["-c", "-l", "-q", "pat", "f"]).print.mode, print::Mode::Quiet);
516 assert_eq!(p(&["--json", "-c", "pat", "f"]).print.mode, print::Mode::Count);
517 }
518
519 #[test]
520 fn color_parses_all_three_and_rejects_the_rest() {
521 assert_eq!(p(&["pat", "f"]).color, ColorArg::Auto);
522 assert_eq!(p(&["--color", "always", "pat", "f"]).color, ColorArg::Always);
523 assert_eq!(p(&["--color", "never", "pat", "f"]).color, ColorArg::Never);
524 let argv: Vec<std::ffi::OsString> =
525 ["trg", "--color", "nonsense", "pat", "f"].iter().map(|s| s.into()).collect();
526 assert!(parse_from(argv).is_err());
527 }
528
529 #[test]
534 fn dash_t_with_one_positional_takes_it_as_a_path_not_a_pattern() {
535 let a = p(&["-T", "archive.tgz"]);
536 assert!(a.patterns.is_empty(), "-T needs no pattern, got {:?}", a.patterns);
537 assert_eq!(a.paths, vec![PathBuf::from("archive.tgz")]);
538 }
539
540 #[test]
544 fn dash_t_takes_every_positional_as_a_path() {
545 let a = p(&["-T", "a.tgz", "b.tgz", "c.tgz"]);
546 assert!(a.patterns.is_empty(), "-T takes no pattern, got {:?}", a.patterns);
547 assert_eq!(
548 a.paths,
549 vec![PathBuf::from("a.tgz"), PathBuf::from("b.tgz"), PathBuf::from("c.tgz")]
550 );
551 }
552
553 #[test]
556 fn dash_t_after_the_positionals_parses_the_same() {
557 let a = p(&["a.tgz", "b.tgz", "-T"]);
558 assert!(a.patterns.is_empty(), "got {:?}", a.patterns);
559 assert_eq!(a.paths, vec![PathBuf::from("a.tgz"), PathBuf::from("b.tgz")]);
560 }
561
562 #[test]
566 fn an_explicit_e_makes_every_positional_a_path_whatever_the_order() {
567 let a = p(&["a.tgz", "-e", "pat", "b.tgz"]);
568 assert_eq!(a.patterns, vec!["pat"]);
569 assert_eq!(a.paths, vec![PathBuf::from("a.tgz"), PathBuf::from("b.tgz")]);
570 }
571
572 #[test]
575 fn dash_t_with_an_explicit_e_pattern_keeps_both() {
576 let a = p(&["-T", "-e", "pat", "archive.tgz"]);
577 assert_eq!(a.patterns, vec!["pat"]);
578 assert_eq!(a.paths, vec![PathBuf::from("archive.tgz")]);
579 }
580
581 fn err(args: &[&str]) -> String {
582 let argv: Vec<std::ffi::OsString> =
583 std::iter::once("trg".into()).chain(args.iter().map(|s| s.into())).collect();
584 match parse_from(argv) {
585 Ok(_) => panic!("expected a usage error, got a parse"),
586 Err(e) => format!("{e:#}"),
587 }
588 }
589
590 #[test]
593 fn a_missing_pattern_is_still_an_error_without_dash_t() {
594 assert!(err(&[]).contains("no pattern given"));
595 assert!(err(&["-e", "pat"]).contains("no path given"), "pattern present, path missing");
596 }
597
598 #[test]
602 fn a_missing_path_is_a_usage_error_not_an_empty_clean_run() {
603 assert!(err(&["needle"]).contains("no path given"), "pattern with no path");
604 assert!(err(&["-T"]).contains("no path given"), "-T with no path");
605 assert!(err(&["-q", "needle"]).contains("no path given"), "-q must not hide it");
606 }
607
608 #[test]
612 fn one_positional_is_the_pattern_and_leaves_no_path() {
613 assert!(err(&["archive.tgz"]).contains("no path given"));
614 }
615
616 #[test]
617 fn inflate_budget_accepts_u64_max_with_no_suffix() {
618 assert_eq!(parse_size(&u64::MAX.to_string()).unwrap(), u64::MAX);
619 }
620
621 #[test]
622 fn inflate_budget_accepts_a_large_value_with_a_suffix_just_under_the_boundary() {
623 let n = u64::MAX / (1024 * 1024 * 1024);
625 assert_eq!(parse_size(&format!("{n}G")).unwrap(), n * 1024 * 1024 * 1024);
626 }
627}