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 (.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        --escape WHEN     show control bytes from logs and member names as
46                          text (\\x1b) instead of sending them to the
47                          terminal: auto | always | never (auto: on a tty,
48                          per stream; piped output stays byte-exact)
49                          If several output modes are given, the most
50                          suppressive wins: -q, then -l, then -c, then --json.
51
52ARCHIVES:
53    -g, --glob PAT        filter MEMBERS inside archives; repeatable
54    -T, --list-members    list member paths without searching. Takes no
55                          PATTERN at all: every positional is a PATH, so
56                          `trg -T *.tgz` lists all of them
57        --archive-sep C   separator in archive:member:line (default ':')
58        --no-sort         allow output in completion order
59
60RESOURCES:
61    -j, --jobs NUM        archives searched concurrently (default 1)
62        --turbo           -j <ncpus> --nice 0
63        --nice NUM        scheduling priority (default 10)
64        --no-nice         do not lower priority
65        --no-drop-cache   keep archive pages in the page cache
66        --inflate MODE    auto | stream | buffer (default auto)
67        --inflate-budget SIZE
68                          whole-buffer inflate ceiling (default 64M)
69        --load-limit N    clamp jobs if 1-min load average exceeds N
70
71    -h, --help            this message
72    -V, --version         version
73
74EXIT CODES:
75    0  matches found, everything read
76    1  no matches, everything read
77    2  something went unread — never trust a 1 you did not get
78
79NOTES (documented, and surprising the first time):
80    -T lists every member, including ones a search skips as binary.
81    Two directories symlinking the same file search it once (identity dedup).
82    An intact archive behind a corrupt gzip trailer is a 0; truncation is
83      still caught and is a 2.
84    --turbo's ordered output is bounded by neither --inflate-budget nor the
85      spill cap; peak RSS grows with the match volume held in order.
86    On a broken pipe (`| head`) trg stops early and exits 0, leaving later
87      archives unread.
88    Compression is detected from content, never from the filename, so there is
89      no -z flag. gzip, zstd and xz are recognised, each unwrapped one level, so a
90      logrotate'd access.log.1.gz, .zst or .xz inside the archive is searched
91      as text rather than skipped as binary.
92    --inflate=buffer is gzip-only; a zstd or xz archive always streams, which costs a
93      slower path and never a wrong answer.
94";
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum InflateMode { Auto, Stream, Buffer }
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum ColorArg { Auto, Always, Never }
101
102pub struct Args {
103    pub patterns: Vec<String>,
104    pub paths: Vec<PathBuf>,
105    pub globs: Vec<String>,
106    pub jobs: usize,
107    pub nice: Option<i32>,
108    pub drop_cache: bool,
109    pub inflate: InflateMode,
110    pub inflate_budget: u64,
111    pub load_limit: Option<f64>,
112    pub archive_sep: char,
113    pub sort: bool,
114    pub list_members: bool,
115    pub count: bool,
116    pub files_with_matches: bool,
117    pub quiet: bool,
118    pub json: bool,
119    pub color: ColorArg,
120    pub escape: ColorArg,
121    pub search: search::SearchOpts,
122    pub print: print::PrintOpts,
123}
124
125impl Default for Args {
126    fn default() -> Self {
127        Self {
128            patterns: Vec::new(),
129            paths: Vec::new(),
130            globs: Vec::new(),
131            jobs: 1,
132            nice: Some(10),
133            drop_cache: true,
134            inflate: InflateMode::Auto,
135            inflate_budget: 64 * 1024 * 1024,
136            load_limit: None,
137            archive_sep: ':',
138            sort: true,
139            list_members: false,
140            count: false,
141            files_with_matches: false,
142            quiet: false,
143            json: false,
144            color: ColorArg::Auto,
145            escape: ColorArg::Auto,
146            search: search::SearchOpts::default(),
147            print: print::PrintOpts::default(),
148        }
149    }
150}
151
152/// `512K`, `2M`, `1G`, or a bare byte count.
153fn parse_size(s: &str) -> anyhow::Result<u64> {
154    let s = s.trim();
155    let (num, mult) = match s.chars().last() {
156        Some('K') | Some('k') => (&s[..s.len() - 1], 1024),
157        Some('M') | Some('m') => (&s[..s.len() - 1], 1024 * 1024),
158        Some('G') | Some('g') => (&s[..s.len() - 1], 1024 * 1024 * 1024),
159        _ => (s, 1),
160    };
161    let n: u64 = num.parse()?;
162    n.checked_mul(mult)
163        .ok_or_else(|| anyhow::anyhow!("--inflate-budget value too large: {s}"))
164}
165
166pub fn parse() -> anyhow::Result<Args> {
167    parse_from(std::env::args_os())
168}
169
170pub fn parse_from<I>(argv: I) -> anyhow::Result<Args>
171where
172    I: IntoIterator<Item = OsString>,
173{
174    use lexopt::prelude::*;
175
176    let mut a = Args::default();
177    let mut explicit_jobs = false;
178    let mut explicit_nice = false;
179    let mut ctx_both: Option<usize> = None;
180    // Positionals are banked rather than assigned as they arrive, so the rule
181    // that splits PATTERN from PATH does not depend on whether `-T` or `-e`
182    // happened to come before or after them on the command line.
183    let mut positionals: Vec<OsString> = Vec::new();
184    let mut explicit_patterns = false;
185
186    let mut p = lexopt::Parser::from_iter(argv);
187    while let Some(arg) = p.next()? {
188        match arg {
189            Short('e') | Long("regexp") => {
190                a.patterns.push(p.value()?.string()?);
191                explicit_patterns = true;
192            }
193            Short('i') | Long("ignore-case") => a.search.case_insensitive = true,
194            Short('S') | Long("smart-case") => a.search.smart_case = true,
195            Short('w') | Long("word-regexp") => a.search.word = true,
196            Short('F') | Long("fixed-strings") => a.search.fixed = true,
197            Short('v') | Long("invert-match") => a.search.invert = true,
198            Short('a') | Long("text") => a.search.text = true,
199            Short('m') | Long("max-count") => {
200                a.search.max_count = Some(p.value()?.parse()?)
201            }
202            Long("crlf") => a.search.crlf = true,
203
204            Short('n') | Long("line-number") => a.search.line_numbers = true,
205            Short('N') | Long("no-line-number") => a.search.line_numbers = false,
206            Short('A') | Long("after") | Long("after-context") => {
207                a.search.after = p.value()?.parse()?
208            }
209            Short('B') | Long("before") | Long("before-context") => {
210                a.search.before = p.value()?.parse()?
211            }
212            Short('C') | Long("context") => ctx_both = Some(p.value()?.parse()?),
213            Short('o') | Long("only-matching") => a.print.only_matching = true,
214            Short('c') | Long("count") => a.count = true,
215            Short('l') | Long("files-with-matches") => a.files_with_matches = true,
216            Short('q') | Long("quiet") => a.quiet = true,
217            Long("json") => a.json = true,
218            Long("color") => {
219                a.color = match p.value()?.string()?.as_str() {
220                    "auto" => ColorArg::Auto,
221                    "always" => ColorArg::Always,
222                    "never" => ColorArg::Never,
223                    other => anyhow::bail!("--color expects auto|always|never, got '{other}'"),
224                }
225            }
226            Long("escape") => {
227                a.escape = match p.value()?.string()?.as_str() {
228                    "auto" => ColorArg::Auto,
229                    "always" => ColorArg::Always,
230                    "never" => ColorArg::Never,
231                    other => anyhow::bail!("--escape expects auto|always|never, got '{other}'"),
232                }
233            }
234
235            Short('g') | Long("glob") => a.globs.push(p.value()?.string()?),
236            Short('T') | Long("list-members") => a.list_members = true,
237            Long("archive-sep") => {
238                let v = p.value()?.string()?;
239                let mut it = v.chars();
240                match (it.next(), it.next()) {
241                    (Some(c), None) => a.archive_sep = c,
242                    _ => anyhow::bail!("--archive-sep expects exactly one character"),
243                }
244            }
245            Long("no-sort") => a.sort = false,
246
247            Short('j') | Long("jobs") => {
248                a.jobs = p.value()?.parse()?;
249                explicit_jobs = true;
250            }
251            Long("turbo") => {
252                if !explicit_jobs { a.jobs = num_cpus::get(); }
253                if !explicit_nice { a.nice = Some(0); }
254            }
255            Long("nice") => {
256                a.nice = Some(p.value()?.parse()?);
257                explicit_nice = true;
258            }
259            Long("no-nice") => {
260                a.nice = None;
261                explicit_nice = true;
262            }
263            Long("no-drop-cache") => a.drop_cache = false,
264            Long("inflate") => {
265                a.inflate = match p.value()?.string()?.as_str() {
266                    "auto" => InflateMode::Auto,
267                    "stream" => InflateMode::Stream,
268                    "buffer" => InflateMode::Buffer,
269                    other => anyhow::bail!("--inflate expects auto|stream|buffer, got '{other}'"),
270                }
271            }
272            Long("inflate-budget") => a.inflate_budget = parse_size(&p.value()?.string()?)?,
273            Long("load-limit") => a.load_limit = Some(p.value()?.parse()?),
274
275            Short('h') | Long("help") => {
276                print!("{HELP}");
277                std::process::exit(0);
278            }
279            Short('V') | Long("version") => {
280                println!("trg {}", env!("CARGO_PKG_VERSION"));
281                std::process::exit(0);
282            }
283
284            Value(v) => positionals.push(v),
285            other => return Err(other.unexpected().into()),
286        }
287    }
288
289    // -j given explicitly after --turbo must win; --turbo already respected
290    // explicit_jobs, so nothing more is needed here.
291    if let Some(n) = ctx_both {
292        a.search.before = n;
293        a.search.after = n;
294    }
295    // Collapse the output-mode flags into exactly one mode here, so nothing
296    // downstream has to re-derive the precedence. Most suppressive wins: `-q`
297    // asked for no output at all, and honouring the noisier flag over it would
298    // be the wrong way to resolve a contradiction.
299    a.print.mode = if a.quiet {
300        print::Mode::Quiet
301    } else if a.files_with_matches {
302        print::Mode::FilesWithMatches
303    } else if a.count {
304        print::Mode::Count
305    } else if a.json {
306        print::Mode::Json
307    } else {
308        print::Mode::Standard
309    };
310
311    // Split the banked positionals into PATTERN and PATH.
312    //
313    // `-T` asks a question about the tar headers, not about the members'
314    // contents: `list_members` never consults the matcher at all. So it takes
315    // no pattern *whatsoever* and every positional is a path. The earlier rule
316    // — "exactly one positional is a path" — closed `trg -T archive.tgz` but
317    // not `trg -T *.tgz`, where the shell supplies several and the first
318    // archive was silently eaten as a pattern it would never be read for. An
319    // argument the mode cannot use has no unambiguous reading, so it does not
320    // get one; `trg -T -e pat a.tgz` still parses, because `-e` is explicit.
321    //
322    // Otherwise the first positional is the pattern unless `-e` already
323    // supplied one, matching ripgrep.
324    let mut rest = positionals.into_iter();
325    if !a.list_members
326        && !explicit_patterns
327        && let Some(first) = rest.next()
328    {
329        a.patterns.push(first.string()?);
330    }
331    a.paths.extend(rest.map(PathBuf::from));
332
333    // `-T` is the one mode with no pattern to be missing.
334    if a.patterns.is_empty() && !a.list_members {
335        anyhow::bail!("no pattern given\n\n{HELP}");
336    }
337    // A run with nothing to read must never reach the exit-code contract: with
338    // no paths the job list is empty, the scheduler returns immediately, and
339    // `matched: false, partial: false` is a 1 — "I read everything and the logs
340    // are clean" for a run that opened nothing. `trg needle` with the path
341    // forgotten, and `trg needle $LOGS` with $LOGS unset, both landed there.
342    // Searching the working directory instead, as ripgrep does, would be worse:
343    // a confident 1 from a directory nobody named.
344    if a.paths.is_empty() {
345        anyhow::bail!("no path given: name at least one archive or directory\n\n{HELP}");
346    }
347    Ok(a)
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    fn p(args: &[&str]) -> Args {
355        let argv: Vec<std::ffi::OsString> =
356            std::iter::once("trg".into()).chain(args.iter().map(|s| s.into())).collect();
357        parse_from(argv).unwrap()
358    }
359
360    #[test]
361    fn defaults_match_the_spec() {
362        let a = p(&["pat", "f.tgz"]);
363        assert_eq!(a.jobs, 1);
364        assert_eq!(a.nice, Some(10));
365        assert!(a.drop_cache);
366        assert!(matches!(a.inflate, InflateMode::Auto));
367        assert_eq!(a.inflate_budget, 64 * 1024 * 1024);
368        assert_eq!(a.archive_sep, ':');
369        assert!(a.sort);
370        assert_eq!(a.search.heap_limit, 16 * 1024 * 1024);
371        assert!(a.load_limit.is_none());
372    }
373
374    #[test]
375    fn first_positional_is_the_pattern_rest_are_paths() {
376        let a = p(&["NEEDLE", "a.tgz", "b.tgz"]);
377        assert_eq!(a.patterns, vec!["NEEDLE"]);
378        assert_eq!(a.paths.len(), 2);
379    }
380
381    #[test]
382    fn dash_e_patterns_do_not_consume_the_first_positional() {
383        let a = p(&["-e", "one", "-e", "two", "a.tgz"]);
384        assert_eq!(a.patterns, vec!["one", "two"]);
385        assert_eq!(a.paths.len(), 1);
386    }
387
388    #[test]
389    fn turbo_raises_jobs_and_clears_nice() {
390        let a = p(&["--turbo", "pat", "f.tgz"]);
391        assert_eq!(a.jobs, num_cpus::get());
392        assert_eq!(a.nice, Some(0));
393        assert!(a.drop_cache, "cache dropping stays on under --turbo");
394    }
395
396    #[test]
397    fn explicit_j_after_turbo_wins() {
398        let a = p(&["--turbo", "-j", "2", "pat", "f.tgz"]);
399        assert_eq!(a.jobs, 2);
400    }
401
402    #[test]
403    fn no_nice_disables_priority_lowering() {
404        let a = p(&["--no-nice", "pat", "f.tgz"]);
405        assert_eq!(a.nice, None);
406    }
407
408    #[test]
409    fn context_flags_match_rg_semantics() {
410        let a = p(&["-C", "3", "pat", "f.tgz"]);
411        assert_eq!((a.search.before, a.search.after), (3, 3));
412        let a = p(&["-A", "2", "-B", "1", "pat", "f.tgz"]);
413        assert_eq!((a.search.before, a.search.after), (1, 2));
414    }
415
416    #[test]
417    fn n_capital_disables_line_numbers() {
418        assert!(p(&["pat", "f.tgz"]).search.line_numbers);
419        assert!(!p(&["-N", "pat", "f.tgz"]).search.line_numbers);
420    }
421
422    #[test]
423    fn size_suffixes_parse() {
424        assert_eq!(p(&["--inflate-budget", "512K", "p", "f"]).inflate_budget, 512 * 1024);
425        assert_eq!(p(&["--inflate-budget", "2M", "p", "f"]).inflate_budget, 2 * 1024 * 1024);
426        assert_eq!(p(&["--inflate-budget", "1G", "p", "f"]).inflate_budget, 1024 * 1024 * 1024);
427        assert_eq!(p(&["--inflate-budget", "1024", "p", "f"]).inflate_budget, 1024);
428    }
429
430    #[test]
431    fn globs_accumulate() {
432        let a = p(&["-g", "*.access.log", "-g", "*.error.log", "pat", "f.tgz"]);
433        assert_eq!(a.globs.len(), 2);
434    }
435
436    #[test]
437    fn archive_sep_is_configurable() {
438        assert_eq!(p(&["--archive-sep", "!", "pat", "f.tgz"]).archive_sep, '!');
439    }
440
441    #[test]
442    fn unknown_flag_is_an_error_not_a_pattern() {
443        let argv: Vec<std::ffi::OsString> =
444            ["trg", "--nonsense", "pat", "f.tgz"].iter().map(|s| s.into()).collect();
445        assert!(parse_from(argv).is_err());
446    }
447
448    #[test]
449    fn inflate_mode_rejects_garbage() {
450        let argv: Vec<std::ffi::OsString> =
451            ["trg", "--inflate", "sideways", "p", "f"].iter().map(|s| s.into()).collect();
452        assert!(parse_from(argv).is_err());
453    }
454
455    #[test]
456    fn crlf_flag_sets_the_search_opt_and_defaults_to_false() {
457        assert!(!p(&["pat", "f.tgz"]).search.crlf);
458        assert!(p(&["--crlf", "pat", "f.tgz"]).search.crlf);
459    }
460
461    #[test]
462    fn inflate_budget_overflow_is_an_error_not_a_wrapped_value() {
463        let argv: Vec<std::ffi::OsString> =
464            ["trg", "--inflate-budget", "18446744073709551615G", "p", "f"]
465                .iter().map(|s| s.into()).collect();
466        assert!(parse_from(argv).is_err());
467    }
468
469    #[test]
470    fn output_mode_precedence_is_most_suppressive_wins() {
471        assert_eq!(p(&["pat", "f"]).print.mode, print::Mode::Standard);
472        assert_eq!(p(&["-c", "pat", "f"]).print.mode, print::Mode::Count);
473        assert_eq!(p(&["-l", "pat", "f"]).print.mode, print::Mode::FilesWithMatches);
474        assert_eq!(p(&["-q", "pat", "f"]).print.mode, print::Mode::Quiet);
475        assert_eq!(p(&["--json", "pat", "f"]).print.mode, print::Mode::Json);
476        assert_eq!(p(&["-c", "-l", "pat", "f"]).print.mode, print::Mode::FilesWithMatches);
477        assert_eq!(p(&["-c", "-l", "-q", "pat", "f"]).print.mode, print::Mode::Quiet);
478        assert_eq!(p(&["--json", "-c", "pat", "f"]).print.mode, print::Mode::Count);
479    }
480
481    #[test]
482    fn color_parses_all_three_and_rejects_the_rest() {
483        assert_eq!(p(&["pat", "f"]).color, ColorArg::Auto);
484        assert_eq!(p(&["--color", "always", "pat", "f"]).color, ColorArg::Always);
485        assert_eq!(p(&["--color", "never", "pat", "f"]).color, ColorArg::Never);
486        let argv: Vec<std::ffi::OsString> =
487            ["trg", "--color", "nonsense", "pat", "f"].iter().map(|s| s.into()).collect();
488        assert!(parse_from(argv).is_err());
489    }
490
491    /// The trap this fix removes: `-T` needs no pattern, but the positional
492    /// rule gave the archive path to the pattern slot, leaving no paths at all.
493    /// The command exited 1 in silence — the first natural use of a documented
494    /// flag doing nothing.
495    #[test]
496    fn dash_t_with_one_positional_takes_it_as_a_path_not_a_pattern() {
497        let a = p(&["-T", "archive.tgz"]);
498        assert!(a.patterns.is_empty(), "-T needs no pattern, got {:?}", a.patterns);
499        assert_eq!(a.paths, vec![PathBuf::from("archive.tgz")]);
500    }
501
502    /// The trap the one-positional rule did not close: a shell glob supplies
503    /// several positionals, and the first archive was eaten as a pattern `-T`
504    /// never reads. Under `-T` every positional is a path, no exceptions.
505    #[test]
506    fn dash_t_takes_every_positional_as_a_path() {
507        let a = p(&["-T", "a.tgz", "b.tgz", "c.tgz"]);
508        assert!(a.patterns.is_empty(), "-T takes no pattern, got {:?}", a.patterns);
509        assert_eq!(
510            a.paths,
511            vec![PathBuf::from("a.tgz"), PathBuf::from("b.tgz"), PathBuf::from("c.tgz")]
512        );
513    }
514
515    /// Flag order must not change the reading: `-T` after the positionals is
516    /// the same command.
517    #[test]
518    fn dash_t_after_the_positionals_parses_the_same() {
519        let a = p(&["a.tgz", "b.tgz", "-T"]);
520        assert!(a.patterns.is_empty(), "got {:?}", a.patterns);
521        assert_eq!(a.paths, vec![PathBuf::from("a.tgz"), PathBuf::from("b.tgz")]);
522    }
523
524    /// `-e` after a positional still makes that positional a path, so the
525    /// pattern slot is decided by the whole command line rather than by which
526    /// argument arrived first.
527    #[test]
528    fn an_explicit_e_makes_every_positional_a_path_whatever_the_order() {
529        let a = p(&["a.tgz", "-e", "pat", "b.tgz"]);
530        assert_eq!(a.patterns, vec!["pat"]);
531        assert_eq!(a.paths, vec![PathBuf::from("a.tgz"), PathBuf::from("b.tgz")]);
532    }
533
534    /// `-e` fills the pattern slot explicitly, so the lone positional is a path
535    /// for the ordinary reason and the `-T` rule must not double-move it.
536    #[test]
537    fn dash_t_with_an_explicit_e_pattern_keeps_both() {
538        let a = p(&["-T", "-e", "pat", "archive.tgz"]);
539        assert_eq!(a.patterns, vec!["pat"]);
540        assert_eq!(a.paths, vec![PathBuf::from("archive.tgz")]);
541    }
542
543    fn err(args: &[&str]) -> String {
544        let argv: Vec<std::ffi::OsString> =
545            std::iter::once("trg".into()).chain(args.iter().map(|s| s.into())).collect();
546        match parse_from(argv) {
547            Ok(_) => panic!("expected a usage error, got a parse"),
548            Err(e) => format!("{e:#}"),
549        }
550    }
551
552    /// Only `-T` is exempt. Everything else still refuses to run patternless
553    /// rather than searching for the empty string.
554    #[test]
555    fn a_missing_pattern_is_still_an_error_without_dash_t() {
556        assert!(err(&[]).contains("no pattern given"));
557        assert!(err(&["-e", "pat"]).contains("no path given"), "pattern present, path missing");
558    }
559
560    /// The blocker: with no path there is nothing to read, and a run that read
561    /// nothing must not be able to reach the exit-code contract — an empty job
562    /// list produced `matched: false, partial: false`, which is a 1.
563    #[test]
564    fn a_missing_path_is_a_usage_error_not_an_empty_clean_run() {
565        assert!(err(&["needle"]).contains("no path given"), "pattern with no path");
566        assert!(err(&["-T"]).contains("no path given"), "-T with no path");
567        assert!(err(&["-q", "needle"]).contains("no path given"), "-q must not hide it");
568    }
569
570    /// A pattern is still a pattern, so this is the *path* that is missing —
571    /// the realistic form is `trg needle $LOGS` with `$LOGS` unset, which the
572    /// shell collapses to exactly this.
573    #[test]
574    fn one_positional_is_the_pattern_and_leaves_no_path() {
575        assert!(err(&["archive.tgz"]).contains("no path given"));
576    }
577
578    #[test]
579    fn inflate_budget_accepts_u64_max_with_no_suffix() {
580        let a = p(&["--inflate-budget", &u64::MAX.to_string(), "p", "f"]);
581        assert_eq!(a.inflate_budget, u64::MAX);
582    }
583
584    #[test]
585    fn inflate_budget_accepts_a_large_value_with_a_suffix_just_under_the_boundary() {
586        // u64::MAX / 1024^3, so the multiply by G does not overflow.
587        let n = u64::MAX / (1024 * 1024 * 1024);
588        let a = p(&["--inflate-budget", &format!("{n}G"), "p", "f"]);
589        assert_eq!(a.inflate_budget, n * 1024 * 1024 * 1024);
590    }
591}