Skip to main content

trg/
cli.rs

1//! Argument parsing. rg-compatible flag names wherever the job is the same;
2//! `-g` deliberately means *member* filter, an approved divergence.
3
4use 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    /// Flags still accepted for old scripts but no longer doing anything,
130    /// each named once for a note on stderr.
131    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
173/// `512K`, `2M`, `1G`, or a bare byte count.
174fn 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    // Positionals are banked rather than assigned as they arrive, so the rule
208    // that splits PATTERN from PATH does not depend on whether `-T` or `-e`
209    // happened to come before or after them on the command line.
210    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            // Removed in 0.7.0 with the whole-buffer path, which never beat
293            // zlib-ng streaming on total CPU. Still parsed and validated, so an
294            // old script keeps working and a typo is still a 2; the value is
295            // then dropped.
296            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    // -j given explicitly after --turbo must win; --turbo already respected
328    // explicit_jobs, so nothing more is needed here.
329    if let Some(n) = ctx_both {
330        a.search.before = n;
331        a.search.after = n;
332    }
333    // Collapse the output-mode flags into exactly one mode here, so nothing
334    // downstream has to re-derive the precedence. Most suppressive wins: `-q`
335    // asked for no output at all, and honouring the noisier flag over it would
336    // be the wrong way to resolve a contradiction.
337    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    // Split the banked positionals into PATTERN and PATH.
350    //
351    // `-T` asks a question about the tar headers, not about the members'
352    // contents: `list_members` never consults the matcher at all. So it takes
353    // no pattern *whatsoever* and every positional is a path. The earlier rule
354    // — "exactly one positional is a path" — closed `trg -T archive.tgz` but
355    // not `trg -T *.tgz`, where the shell supplies several and the first
356    // archive was silently eaten as a pattern it would never be read for. An
357    // argument the mode cannot use has no unambiguous reading, so it does not
358    // get one; `trg -T -e pat a.tgz` still parses, because `-e` is explicit.
359    //
360    // Otherwise the first positional is the pattern unless `-e` already
361    // supplied one, matching ripgrep.
362    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    // `-T` is the one mode with no pattern to be missing.
372    if a.patterns.is_empty() && !a.list_members {
373        anyhow::bail!("no pattern given\n\n{HELP}");
374    }
375    // A run with nothing to read must never reach the exit-code contract: with
376    // no paths the job list is empty, the scheduler returns immediately, and
377    // `matched: false, partial: false` is a 1 — "I read everything and the logs
378    // are clean" for a run that opened nothing. `trg needle` with the path
379    // forgotten, and `trg needle $LOGS` with $LOGS unset, both landed there.
380    // Searching the working directory instead, as ripgrep does, would be worse:
381    // a confident 1 from a directory nobody named.
382    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    /// The trap this fix removes: `-T` needs no pattern, but the positional
530    /// rule gave the archive path to the pattern slot, leaving no paths at all.
531    /// The command exited 1 in silence — the first natural use of a documented
532    /// flag doing nothing.
533    #[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    /// The trap the one-positional rule did not close: a shell glob supplies
541    /// several positionals, and the first archive was eaten as a pattern `-T`
542    /// never reads. Under `-T` every positional is a path, no exceptions.
543    #[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    /// Flag order must not change the reading: `-T` after the positionals is
554    /// the same command.
555    #[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    /// `-e` after a positional still makes that positional a path, so the
563    /// pattern slot is decided by the whole command line rather than by which
564    /// argument arrived first.
565    #[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    /// `-e` fills the pattern slot explicitly, so the lone positional is a path
573    /// for the ordinary reason and the `-T` rule must not double-move it.
574    #[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    /// Only `-T` is exempt. Everything else still refuses to run patternless
591    /// rather than searching for the empty string.
592    #[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    /// The blocker: with no path there is nothing to read, and a run that read
599    /// nothing must not be able to reach the exit-code contract — an empty job
600    /// list produced `matched: false, partial: false`, which is a 1.
601    #[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    /// A pattern is still a pattern, so this is the *path* that is missing —
609    /// the realistic form is `trg needle $LOGS` with `$LOGS` unset, which the
610    /// shell collapses to exactly this.
611    #[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        // u64::MAX / 1024^3, so the multiply by G does not overflow.
624        let n = u64::MAX / (1024 * 1024 * 1024);
625        assert_eq!(parse_size(&format!("{n}G")).unwrap(), n * 1024 * 1024 * 1024);
626    }
627}