1use std::ffi::OsString;
5use std::path::PathBuf;
6
7use crate::{print, search};
8
9pub const HELP: &str = "\
10trg — search inside compressed log archives (.tgz, .tar.zst, .tar.xz)
11
12USAGE:
13 trg [OPTIONS] PATTERN PATH...
14 trg [OPTIONS] -e PATTERN... PATH...
15 trg [OPTIONS] -T PATH...
16
17At least one PATH is required. trg never searches the current directory by
18default: a silent walk of $PWD is not a safe guess for a tool whose exit 1
19is a claim that the logs are clean.
20
21MATCHING (same meaning as ripgrep):
22 -e, --regexp PAT add a pattern; repeatable
23 -i, --ignore-case case-insensitive
24 -S, --smart-case case-insensitive unless the pattern has uppercase
25 -w, --word-regexp match whole words only
26 -F, --fixed-strings treat patterns as literals
27 -v, --invert-match show non-matching lines
28 -a, --text search members that look binary
29 -m, --max-count NUM stop after NUM matches per member
30 --crlf treat CRLF as the line terminator
31
32OUTPUT (same meaning as ripgrep):
33 -n, --line-number show line numbers (default)
34 -N, --no-line-number hide line numbers
35 -A, --after NUM lines of trailing context
36 -B, --before NUM lines of leading context
37 -C, --context NUM lines of context on both sides
38 -o, --only-matching print only the matching part
39 -c, --count print a count per member
40 -l, --files-with-matches
41 print member paths only
42 -q, --quiet print nothing; exit code only
43 --json JSON Lines output
44 --color WHEN auto | always | never (auto: only on a tty)
45 If several output modes are given, the most
46 suppressive wins: -q, then -l, then -c, then --json.
47
48ARCHIVES:
49 -g, --glob PAT filter MEMBERS inside archives; repeatable
50 -T, --list-members list member paths without searching. Takes no
51 PATTERN at all: every positional is a PATH, so
52 `trg -T *.tgz` lists all of them
53 --archive-sep C separator in archive:member:line (default ':')
54 --no-sort allow output in completion order
55
56RESOURCES:
57 -j, --jobs NUM archives searched concurrently (default 1)
58 --turbo -j <ncpus> --nice 0
59 --nice NUM scheduling priority (default 10)
60 --no-nice do not lower priority
61 --no-drop-cache keep archive pages in the page cache
62 --inflate MODE auto | stream | buffer (default auto)
63 --inflate-budget SIZE
64 whole-buffer inflate ceiling (default 64M)
65 --load-limit N clamp jobs if 1-min load average exceeds N
66
67 -h, --help this message
68 -V, --version version
69
70EXIT CODES:
71 0 matches found, everything read
72 1 no matches, everything read
73 2 something went unread — never trust a 1 you did not get
74
75NOTES (documented, and surprising the first time):
76 -T lists every member, including ones a search skips as binary.
77 Two directories symlinking the same file search it once (identity dedup).
78 An intact archive behind a corrupt gzip trailer is a 0; truncation is
79 still caught and is a 2.
80 --turbo's ordered output is bounded by neither --inflate-budget nor the
81 spill cap; peak RSS grows with the match volume held in order.
82 On a broken pipe (`| head`) trg stops early and exits 0, leaving later
83 archives unread.
84 Compression is detected from content, never from the filename, so there is
85 no -z flag. gzip, zstd and xz are recognised, each unwrapped one level, so a
86 logrotate'd access.log.1.gz, .zst or .xz inside the archive is searched
87 as text rather than skipped as binary.
88 --inflate=buffer is gzip-only; a zstd or xz archive always streams, which costs a
89 slower path and never a wrong answer.
90";
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum InflateMode { Auto, Stream, Buffer }
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum ColorArg { Auto, Always, Never }
97
98pub struct Args {
99 pub patterns: Vec<String>,
100 pub paths: Vec<PathBuf>,
101 pub globs: Vec<String>,
102 pub jobs: usize,
103 pub nice: Option<i32>,
104 pub drop_cache: bool,
105 pub inflate: InflateMode,
106 pub inflate_budget: u64,
107 pub load_limit: Option<f64>,
108 pub archive_sep: char,
109 pub sort: bool,
110 pub list_members: bool,
111 pub count: bool,
112 pub files_with_matches: bool,
113 pub quiet: bool,
114 pub json: bool,
115 pub color: ColorArg,
116 pub search: search::SearchOpts,
117 pub print: print::PrintOpts,
118}
119
120impl Default for Args {
121 fn default() -> Self {
122 Self {
123 patterns: Vec::new(),
124 paths: Vec::new(),
125 globs: Vec::new(),
126 jobs: 1,
127 nice: Some(10),
128 drop_cache: true,
129 inflate: InflateMode::Auto,
130 inflate_budget: 64 * 1024 * 1024,
131 load_limit: None,
132 archive_sep: ':',
133 sort: true,
134 list_members: false,
135 count: false,
136 files_with_matches: false,
137 quiet: false,
138 json: false,
139 color: ColorArg::Auto,
140 search: search::SearchOpts::default(),
141 print: print::PrintOpts::default(),
142 }
143 }
144}
145
146fn parse_size(s: &str) -> anyhow::Result<u64> {
148 let s = s.trim();
149 let (num, mult) = match s.chars().last() {
150 Some('K') | Some('k') => (&s[..s.len() - 1], 1024),
151 Some('M') | Some('m') => (&s[..s.len() - 1], 1024 * 1024),
152 Some('G') | Some('g') => (&s[..s.len() - 1], 1024 * 1024 * 1024),
153 _ => (s, 1),
154 };
155 let n: u64 = num.parse()?;
156 n.checked_mul(mult)
157 .ok_or_else(|| anyhow::anyhow!("--inflate-budget value too large: {s}"))
158}
159
160pub fn parse() -> anyhow::Result<Args> {
161 parse_from(std::env::args_os())
162}
163
164pub fn parse_from<I>(argv: I) -> anyhow::Result<Args>
165where
166 I: IntoIterator<Item = OsString>,
167{
168 use lexopt::prelude::*;
169
170 let mut a = Args::default();
171 let mut explicit_jobs = false;
172 let mut explicit_nice = false;
173 let mut ctx_both: Option<usize> = None;
174 let mut positionals: Vec<OsString> = Vec::new();
178 let mut explicit_patterns = false;
179
180 let mut p = lexopt::Parser::from_iter(argv);
181 while let Some(arg) = p.next()? {
182 match arg {
183 Short('e') | Long("regexp") => {
184 a.patterns.push(p.value()?.string()?);
185 explicit_patterns = true;
186 }
187 Short('i') | Long("ignore-case") => a.search.case_insensitive = true,
188 Short('S') | Long("smart-case") => a.search.smart_case = true,
189 Short('w') | Long("word-regexp") => a.search.word = true,
190 Short('F') | Long("fixed-strings") => a.search.fixed = true,
191 Short('v') | Long("invert-match") => a.search.invert = true,
192 Short('a') | Long("text") => a.search.text = true,
193 Short('m') | Long("max-count") => {
194 a.search.max_count = Some(p.value()?.parse()?)
195 }
196 Long("crlf") => a.search.crlf = true,
197
198 Short('n') | Long("line-number") => a.search.line_numbers = true,
199 Short('N') | Long("no-line-number") => a.search.line_numbers = false,
200 Short('A') | Long("after") | Long("after-context") => {
201 a.search.after = p.value()?.parse()?
202 }
203 Short('B') | Long("before") | Long("before-context") => {
204 a.search.before = p.value()?.parse()?
205 }
206 Short('C') | Long("context") => ctx_both = Some(p.value()?.parse()?),
207 Short('o') | Long("only-matching") => a.print.only_matching = true,
208 Short('c') | Long("count") => a.count = true,
209 Short('l') | Long("files-with-matches") => a.files_with_matches = true,
210 Short('q') | Long("quiet") => a.quiet = true,
211 Long("json") => a.json = true,
212 Long("color") => {
213 a.color = match p.value()?.string()?.as_str() {
214 "auto" => ColorArg::Auto,
215 "always" => ColorArg::Always,
216 "never" => ColorArg::Never,
217 other => anyhow::bail!("--color expects auto|always|never, got '{other}'"),
218 }
219 }
220
221 Short('g') | Long("glob") => a.globs.push(p.value()?.string()?),
222 Short('T') | Long("list-members") => a.list_members = true,
223 Long("archive-sep") => {
224 let v = p.value()?.string()?;
225 let mut it = v.chars();
226 match (it.next(), it.next()) {
227 (Some(c), None) => a.archive_sep = c,
228 _ => anyhow::bail!("--archive-sep expects exactly one character"),
229 }
230 }
231 Long("no-sort") => a.sort = false,
232
233 Short('j') | Long("jobs") => {
234 a.jobs = p.value()?.parse()?;
235 explicit_jobs = true;
236 }
237 Long("turbo") => {
238 if !explicit_jobs { a.jobs = num_cpus::get(); }
239 if !explicit_nice { a.nice = Some(0); }
240 }
241 Long("nice") => {
242 a.nice = Some(p.value()?.parse()?);
243 explicit_nice = true;
244 }
245 Long("no-nice") => {
246 a.nice = None;
247 explicit_nice = true;
248 }
249 Long("no-drop-cache") => a.drop_cache = false,
250 Long("inflate") => {
251 a.inflate = match p.value()?.string()?.as_str() {
252 "auto" => InflateMode::Auto,
253 "stream" => InflateMode::Stream,
254 "buffer" => InflateMode::Buffer,
255 other => anyhow::bail!("--inflate expects auto|stream|buffer, got '{other}'"),
256 }
257 }
258 Long("inflate-budget") => a.inflate_budget = parse_size(&p.value()?.string()?)?,
259 Long("load-limit") => a.load_limit = Some(p.value()?.parse()?),
260
261 Short('h') | Long("help") => {
262 print!("{HELP}");
263 std::process::exit(0);
264 }
265 Short('V') | Long("version") => {
266 println!("trg {}", env!("CARGO_PKG_VERSION"));
267 std::process::exit(0);
268 }
269
270 Value(v) => positionals.push(v),
271 other => return Err(other.unexpected().into()),
272 }
273 }
274
275 if let Some(n) = ctx_both {
278 a.search.before = n;
279 a.search.after = n;
280 }
281 a.print.mode = if a.quiet {
286 print::Mode::Quiet
287 } else if a.files_with_matches {
288 print::Mode::FilesWithMatches
289 } else if a.count {
290 print::Mode::Count
291 } else if a.json {
292 print::Mode::Json
293 } else {
294 print::Mode::Standard
295 };
296
297 let mut rest = positionals.into_iter();
311 if !a.list_members
312 && !explicit_patterns
313 && let Some(first) = rest.next()
314 {
315 a.patterns.push(first.string()?);
316 }
317 a.paths.extend(rest.map(PathBuf::from));
318
319 if a.patterns.is_empty() && !a.list_members {
321 anyhow::bail!("no pattern given\n\n{HELP}");
322 }
323 if a.paths.is_empty() {
331 anyhow::bail!("no path given: name at least one archive or directory\n\n{HELP}");
332 }
333 Ok(a)
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339
340 fn p(args: &[&str]) -> Args {
341 let argv: Vec<std::ffi::OsString> =
342 std::iter::once("trg".into()).chain(args.iter().map(|s| s.into())).collect();
343 parse_from(argv).unwrap()
344 }
345
346 #[test]
347 fn defaults_match_the_spec() {
348 let a = p(&["pat", "f.tgz"]);
349 assert_eq!(a.jobs, 1);
350 assert_eq!(a.nice, Some(10));
351 assert!(a.drop_cache);
352 assert!(matches!(a.inflate, InflateMode::Auto));
353 assert_eq!(a.inflate_budget, 64 * 1024 * 1024);
354 assert_eq!(a.archive_sep, ':');
355 assert!(a.sort);
356 assert_eq!(a.search.heap_limit, 16 * 1024 * 1024);
357 assert!(a.load_limit.is_none());
358 }
359
360 #[test]
361 fn first_positional_is_the_pattern_rest_are_paths() {
362 let a = p(&["NEEDLE", "a.tgz", "b.tgz"]);
363 assert_eq!(a.patterns, vec!["NEEDLE"]);
364 assert_eq!(a.paths.len(), 2);
365 }
366
367 #[test]
368 fn dash_e_patterns_do_not_consume_the_first_positional() {
369 let a = p(&["-e", "one", "-e", "two", "a.tgz"]);
370 assert_eq!(a.patterns, vec!["one", "two"]);
371 assert_eq!(a.paths.len(), 1);
372 }
373
374 #[test]
375 fn turbo_raises_jobs_and_clears_nice() {
376 let a = p(&["--turbo", "pat", "f.tgz"]);
377 assert_eq!(a.jobs, num_cpus::get());
378 assert_eq!(a.nice, Some(0));
379 assert!(a.drop_cache, "cache dropping stays on under --turbo");
380 }
381
382 #[test]
383 fn explicit_j_after_turbo_wins() {
384 let a = p(&["--turbo", "-j", "2", "pat", "f.tgz"]);
385 assert_eq!(a.jobs, 2);
386 }
387
388 #[test]
389 fn no_nice_disables_priority_lowering() {
390 let a = p(&["--no-nice", "pat", "f.tgz"]);
391 assert_eq!(a.nice, None);
392 }
393
394 #[test]
395 fn context_flags_match_rg_semantics() {
396 let a = p(&["-C", "3", "pat", "f.tgz"]);
397 assert_eq!((a.search.before, a.search.after), (3, 3));
398 let a = p(&["-A", "2", "-B", "1", "pat", "f.tgz"]);
399 assert_eq!((a.search.before, a.search.after), (1, 2));
400 }
401
402 #[test]
403 fn n_capital_disables_line_numbers() {
404 assert!(p(&["pat", "f.tgz"]).search.line_numbers);
405 assert!(!p(&["-N", "pat", "f.tgz"]).search.line_numbers);
406 }
407
408 #[test]
409 fn size_suffixes_parse() {
410 assert_eq!(p(&["--inflate-budget", "512K", "p", "f"]).inflate_budget, 512 * 1024);
411 assert_eq!(p(&["--inflate-budget", "2M", "p", "f"]).inflate_budget, 2 * 1024 * 1024);
412 assert_eq!(p(&["--inflate-budget", "1G", "p", "f"]).inflate_budget, 1024 * 1024 * 1024);
413 assert_eq!(p(&["--inflate-budget", "1024", "p", "f"]).inflate_budget, 1024);
414 }
415
416 #[test]
417 fn globs_accumulate() {
418 let a = p(&["-g", "*.access.log", "-g", "*.error.log", "pat", "f.tgz"]);
419 assert_eq!(a.globs.len(), 2);
420 }
421
422 #[test]
423 fn archive_sep_is_configurable() {
424 assert_eq!(p(&["--archive-sep", "!", "pat", "f.tgz"]).archive_sep, '!');
425 }
426
427 #[test]
428 fn unknown_flag_is_an_error_not_a_pattern() {
429 let argv: Vec<std::ffi::OsString> =
430 ["trg", "--nonsense", "pat", "f.tgz"].iter().map(|s| s.into()).collect();
431 assert!(parse_from(argv).is_err());
432 }
433
434 #[test]
435 fn inflate_mode_rejects_garbage() {
436 let argv: Vec<std::ffi::OsString> =
437 ["trg", "--inflate", "sideways", "p", "f"].iter().map(|s| s.into()).collect();
438 assert!(parse_from(argv).is_err());
439 }
440
441 #[test]
442 fn crlf_flag_sets_the_search_opt_and_defaults_to_false() {
443 assert!(!p(&["pat", "f.tgz"]).search.crlf);
444 assert!(p(&["--crlf", "pat", "f.tgz"]).search.crlf);
445 }
446
447 #[test]
448 fn inflate_budget_overflow_is_an_error_not_a_wrapped_value() {
449 let argv: Vec<std::ffi::OsString> =
450 ["trg", "--inflate-budget", "18446744073709551615G", "p", "f"]
451 .iter().map(|s| s.into()).collect();
452 assert!(parse_from(argv).is_err());
453 }
454
455 #[test]
456 fn output_mode_precedence_is_most_suppressive_wins() {
457 assert_eq!(p(&["pat", "f"]).print.mode, print::Mode::Standard);
458 assert_eq!(p(&["-c", "pat", "f"]).print.mode, print::Mode::Count);
459 assert_eq!(p(&["-l", "pat", "f"]).print.mode, print::Mode::FilesWithMatches);
460 assert_eq!(p(&["-q", "pat", "f"]).print.mode, print::Mode::Quiet);
461 assert_eq!(p(&["--json", "pat", "f"]).print.mode, print::Mode::Json);
462 assert_eq!(p(&["-c", "-l", "pat", "f"]).print.mode, print::Mode::FilesWithMatches);
463 assert_eq!(p(&["-c", "-l", "-q", "pat", "f"]).print.mode, print::Mode::Quiet);
464 assert_eq!(p(&["--json", "-c", "pat", "f"]).print.mode, print::Mode::Count);
465 }
466
467 #[test]
468 fn color_parses_all_three_and_rejects_the_rest() {
469 assert_eq!(p(&["pat", "f"]).color, ColorArg::Auto);
470 assert_eq!(p(&["--color", "always", "pat", "f"]).color, ColorArg::Always);
471 assert_eq!(p(&["--color", "never", "pat", "f"]).color, ColorArg::Never);
472 let argv: Vec<std::ffi::OsString> =
473 ["trg", "--color", "nonsense", "pat", "f"].iter().map(|s| s.into()).collect();
474 assert!(parse_from(argv).is_err());
475 }
476
477 #[test]
482 fn dash_t_with_one_positional_takes_it_as_a_path_not_a_pattern() {
483 let a = p(&["-T", "archive.tgz"]);
484 assert!(a.patterns.is_empty(), "-T needs no pattern, got {:?}", a.patterns);
485 assert_eq!(a.paths, vec![PathBuf::from("archive.tgz")]);
486 }
487
488 #[test]
492 fn dash_t_takes_every_positional_as_a_path() {
493 let a = p(&["-T", "a.tgz", "b.tgz", "c.tgz"]);
494 assert!(a.patterns.is_empty(), "-T takes no pattern, got {:?}", a.patterns);
495 assert_eq!(
496 a.paths,
497 vec![PathBuf::from("a.tgz"), PathBuf::from("b.tgz"), PathBuf::from("c.tgz")]
498 );
499 }
500
501 #[test]
504 fn dash_t_after_the_positionals_parses_the_same() {
505 let a = p(&["a.tgz", "b.tgz", "-T"]);
506 assert!(a.patterns.is_empty(), "got {:?}", a.patterns);
507 assert_eq!(a.paths, vec![PathBuf::from("a.tgz"), PathBuf::from("b.tgz")]);
508 }
509
510 #[test]
514 fn an_explicit_e_makes_every_positional_a_path_whatever_the_order() {
515 let a = p(&["a.tgz", "-e", "pat", "b.tgz"]);
516 assert_eq!(a.patterns, vec!["pat"]);
517 assert_eq!(a.paths, vec![PathBuf::from("a.tgz"), PathBuf::from("b.tgz")]);
518 }
519
520 #[test]
523 fn dash_t_with_an_explicit_e_pattern_keeps_both() {
524 let a = p(&["-T", "-e", "pat", "archive.tgz"]);
525 assert_eq!(a.patterns, vec!["pat"]);
526 assert_eq!(a.paths, vec![PathBuf::from("archive.tgz")]);
527 }
528
529 fn err(args: &[&str]) -> String {
530 let argv: Vec<std::ffi::OsString> =
531 std::iter::once("trg".into()).chain(args.iter().map(|s| s.into())).collect();
532 match parse_from(argv) {
533 Ok(_) => panic!("expected a usage error, got a parse"),
534 Err(e) => format!("{e:#}"),
535 }
536 }
537
538 #[test]
541 fn a_missing_pattern_is_still_an_error_without_dash_t() {
542 assert!(err(&[]).contains("no pattern given"));
543 assert!(err(&["-e", "pat"]).contains("no path given"), "pattern present, path missing");
544 }
545
546 #[test]
550 fn a_missing_path_is_a_usage_error_not_an_empty_clean_run() {
551 assert!(err(&["needle"]).contains("no path given"), "pattern with no path");
552 assert!(err(&["-T"]).contains("no path given"), "-T with no path");
553 assert!(err(&["-q", "needle"]).contains("no path given"), "-q must not hide it");
554 }
555
556 #[test]
560 fn one_positional_is_the_pattern_and_leaves_no_path() {
561 assert!(err(&["archive.tgz"]).contains("no path given"));
562 }
563
564 #[test]
565 fn inflate_budget_accepts_u64_max_with_no_suffix() {
566 let a = p(&["--inflate-budget", &u64::MAX.to_string(), "p", "f"]);
567 assert_eq!(a.inflate_budget, u64::MAX);
568 }
569
570 #[test]
571 fn inflate_budget_accepts_a_large_value_with_a_suffix_just_under_the_boundary() {
572 let n = u64::MAX / (1024 * 1024 * 1024);
574 let a = p(&["--inflate-budget", &format!("{n}G"), "p", "f"]);
575 assert_eq!(a.inflate_budget, n * 1024 * 1024 * 1024);
576 }
577}