Skip to main content

stuffr_cli/
cli.rs

1//! The `stuffr` command's argument surface.
2//!
3//! Pulled out of `main.rs` and re-exported from `lib.rs` so an integration
4//! test can ask clap itself — via [`clap::CommandFactory`] — what flags and
5//! subcommands exist, rather than keeping a second, hand-maintained list that
6//! the examples page could silently drift out of sync with. `main.rs` still
7//! owns everything that isn't the argument shape: parsing, dispatch, and the
8//! examples text itself.
9
10use clap::{Parser, Subcommand};
11
12#[derive(Parser)]
13#[command(
14    name = "stuffr",
15    version,
16    about = "Universal compression and archive toolkit"
17)]
18pub struct Cli {
19    #[command(subcommand)]
20    pub command: Option<Command>,
21    /// Print detailed usage examples covering every feature, then exit.
22    #[arg(long)]
23    pub examples: bool,
24}
25
26#[derive(Subcommand)]
27pub enum Command {
28    /// Compress a file, or collect several into an archive.
29    Pack {
30        /// Input paths, or `-` for stdin.
31        ///
32        /// More than one path needs an output that names a CONTAINER (`-o
33        /// bundle.tar`, or `--format tar`): a codec compresses one stream and
34        /// has nowhere to put a second.
35        #[arg(required = true, num_args = 1.., value_name = "PATHS")]
36        paths: Vec<String>,
37        /// Output path. Defaults to INPUT plus the format's extension.
38        #[arg(short, long)]
39        output: Option<String>,
40        /// Use this format instead of inferring one from the output name.
41        #[arg(long)]
42        format: Option<String>,
43        /// Compression level. gzip accepts 0-9.
44        #[arg(long)]
45        level: Option<i32>,
46        /// Overwrite an existing output.
47        #[arg(long)]
48        force: bool,
49        /// Skip the fsync that makes the output durable before it is published.
50        #[arg(long)]
51        no_sync: bool,
52        /// Use a weak fallback encoder that this build ships in place of the
53        /// format's real one. Worse ratio, and buffers the whole input.
54        #[arg(long)]
55        allow_weak_encoder: bool,
56        /// Encode with N worker threads. 0 means auto-detect.
57        ///
58        /// OMITTING this flag is not the same as passing 0: without it, stuffr
59        /// encodes single-threaded, so the same input always produces the same
60        /// bytes on any machine. Multi-threaded xz and zstd split the input
61        /// per worker, so their output depends on the worker count.
62        #[arg(long)]
63        threads: Option<usize>,
64        /// Use the full detected CPU budget, uncapped.
65        ///
66        /// Lifts the CPU cap only — `--memory-limit` still binds. Turbo means
67        /// "use my cores", not "ignore the OOM killer".
68        #[arg(long)]
69        turbo: bool,
70        /// Cap the memory stuffr will ask for, e.g. 512M or 2G.
71        ///
72        /// On encode this bounds the worker count. Defaults to 25% of
73        /// available RAM, honouring cgroup limits.
74        #[arg(long, value_name = "SIZE")]
75        memory_limit: Option<String>,
76        /// Fail (exit 4) if anything was approximated or lost.
77        ///
78        /// On the pack side that means something the walk met and could not
79        /// store — a socket, an undecodable name, an unreadable directory —
80        /// or an entry shape this container has none of, such as a directory
81        /// or a symlink in an `ar`.
82        #[arg(long)]
83        strict_fidelity: bool,
84    },
85    /// Decompress a file, or extract an archive's entries.
86    Unpack {
87        /// Input path, or `-` for stdin.
88        input: String,
89        /// Entries to extract; every entry when none are given.
90        ///
91        /// An exact entry name, or a directory selecting everything beneath
92        /// it. Needs -C, which is what asks for entry-aware extraction.
93        #[arg(value_name = "PATTERNS")]
94        patterns: Vec<String>,
95        /// Extract the entry at this position instead of naming it. Repeatable.
96        ///
97        /// 0-BASED, counting in archive order — the same number `stuffr list`
98        /// prints in its first column. This is how you reach an entry whose
99        /// name you cannot type: `Makefile` and `makefile` from a
100        /// case-sensitive filesystem on a case-insensitive one. Needs -C,
101        /// like PATTERNS, and cannot be combined with them — they are two
102        /// ways of saying the same thing.
103        ///
104        /// It does NOT reach a record a zip's own index shadows: `unpack`
105        /// counts the entries the index enumerates, so a record a later one
106        /// hides is not at any index here — it has no number at all. `stuffr
107        /// salvage --list` finds those, and `salvage --index N` selects them
108        /// by SCAN position, which is a different numbering.
109        #[arg(long, value_name = "N")]
110        index: Vec<usize>,
111        /// Extract the archive's entries into this directory.
112        ///
113        /// Absolute entry paths, `..` traversal and symlinks escaping this
114        /// directory are refused (exit 7), never silently sanitised.
115        #[arg(short = 'C', long, value_name = "DIR")]
116        directory: Option<String>,
117        /// Fail (exit 4) if anything was approximated or lost.
118        ///
119        /// On extraction that means an entry whose mode or mtime could not be
120        /// restored, or one skipped for having no shape on disk.
121        #[arg(long)]
122        strict_fidelity: bool,
123        /// Output path. Defaults to INPUT with its extension removed.
124        #[arg(short, long)]
125        output: Option<String>,
126        /// Overwrite an existing output.
127        #[arg(long)]
128        force: bool,
129        /// Use this format instead of detecting one. Required for formats with
130        /// no magic bytes and no extension.
131        #[arg(long)]
132        format: Option<String>,
133        /// Refuse a decode expanding by more than this ratio.
134        #[arg(long)]
135        max_ratio: Option<u64>,
136        /// Skip the fsync that makes the output durable before it is published.
137        #[arg(long)]
138        no_sync: bool,
139        /// Cap the memory stuffr will ask for, e.g. 512M or 2G.
140        ///
141        /// Defaults to 25% of available RAM, honouring cgroup limits.
142        #[arg(long, value_name = "SIZE")]
143        memory_limit: Option<String>,
144    },
145    /// Change a file's compression or its archive format, without unpacking
146    /// it to disk.
147    ///
148    /// Codec mode — the container is kept (`.tar.gz` -> `.tar.zst`, `.gz` ->
149    /// `.xz`, `.tar.xz` -> `.tar`): the container, or the plain stream, is
150    /// copied byte for byte and only the codec around it changes.
151    ///
152    /// Entry mode — the container changes (`.tgz` -> `.zip`, `.zip` ->
153    /// `.tar.xz`): every entry is read from the source and written into the
154    /// target under pack's rules, and what the target cannot hold (a
155    /// directory or symlink in an ar, a device anywhere) is skipped with a
156    /// fidelity warning. A hard link stays a link into tar and is written
157    /// as a copy of its target elsewhere, or skipped, named, when no copy
158    /// is available.
159    ///
160    /// The input's chain is detected by content, never by its name; the
161    /// target's comes from OUT's name or --format.
162    Convert {
163        /// Input path, or `-` for stdin.
164        input: String,
165        /// Output path; the same as -o. Give one or the other, not both.
166        #[arg(value_name = "OUT")]
167        output_pos: Option<String>,
168        /// Output path, or `-` for stdout (which needs --format).
169        #[arg(short, long)]
170        output: Option<String>,
171        /// Use this target format instead of inferring one from the output
172        /// name. A codec or a container; one the name contradicts is refused.
173        #[arg(long)]
174        format: Option<String>,
175        /// Compression level for the target codec. gzip accepts 0-9.
176        ///
177        /// Also what makes converting a file to the chain it already has
178        /// (`.tar.gz` -> `.tar.gz`) a re-encode rather than "nothing to
179        /// convert".
180        #[arg(long)]
181        level: Option<i32>,
182        /// Encode with N worker threads. 0 means auto-detect.
183        ///
184        /// OMITTING this flag is not the same as passing 0: without it, stuffr
185        /// encodes single-threaded, so the same input always produces the same
186        /// bytes on any machine. Multi-threaded xz and zstd split the input
187        /// per worker, so their output depends on the worker count.
188        #[arg(long)]
189        threads: Option<usize>,
190        /// Use the full detected CPU budget, uncapped.
191        ///
192        /// Lifts the CPU cap only — `--memory-limit` still binds. Turbo means
193        /// "use my cores", not "ignore the OOM killer".
194        #[arg(long)]
195        turbo: bool,
196        /// Use a weak fallback encoder that this build ships in place of the
197        /// format's real one. Worse ratio, and buffers the whole input.
198        #[arg(long)]
199        allow_weak_encoder: bool,
200        /// Overwrite an existing output.
201        #[arg(long)]
202        force: bool,
203        /// Fail (exit 4) if anything was approximated or lost.
204        ///
205        /// In codec mode the container is copied byte for byte and nothing
206        /// is lost. In entry mode every skipped entry or missing field is a
207        /// warning, and this turns any of them into exit 4 — with the output
208        /// still written, as with pack.
209        #[arg(long)]
210        strict_fidelity: bool,
211        /// Refuse a decode expanding by more than this ratio.
212        #[arg(long)]
213        max_ratio: Option<u64>,
214        /// Cap the memory stuffr will ask for, e.g. 512M or 2G.
215        ///
216        /// Bounds every source codec layer's decoder and the encoder's worker
217        /// count. Defaults to 25% of available RAM, honouring cgroup limits.
218        #[arg(long, value_name = "SIZE")]
219        memory_limit: Option<String>,
220        /// Skip the fsync that makes the output durable before it is published.
221        #[arg(long)]
222        no_sync: bool,
223    },
224    /// Decompress a file, or stream an archive's entries, to stdout.
225    Cat {
226        /// Input path, or `-` for stdin.
227        input: String,
228        /// Entries whose bytes to stream, in archive order.
229        ///
230        /// An exact entry name, or a directory selecting everything beneath
231        /// it. Naming one is what asks for entry-aware streaming; without
232        /// any, `cat` decodes the input as a single stream.
233        #[arg(value_name = "PATTERNS")]
234        patterns: Vec<String>,
235        /// Stream the entry at this position instead of naming it. Repeatable.
236        ///
237        /// 0-BASED, counting in archive order — the same number `stuffr list`
238        /// prints in its first column. `stuffr cat a.zip --index 6 > out.sh`
239        /// is how you pull out an entry whose name is ambiguous or
240        /// untypeable. Like PATTERNS, naming one asks for entry-aware
241        /// streaming; the two cannot be combined.
242        #[arg(long, value_name = "N")]
243        index: Vec<usize>,
244        /// Use this format instead of detecting one. Required for formats with
245        /// no magic bytes and no extension.
246        #[arg(long)]
247        format: Option<String>,
248        /// Refuse a decode expanding by more than this ratio.
249        #[arg(long)]
250        max_ratio: Option<u64>,
251        /// Cap the memory stuffr will ask for, e.g. 512M or 2G.
252        ///
253        /// Defaults to 25% of available RAM, honouring cgroup limits.
254        #[arg(long, value_name = "SIZE")]
255        memory_limit: Option<String>,
256    },
257    /// Identify a stream without decoding it.
258    Info {
259        /// Input path, or `-` for stdin.
260        input: String,
261        /// Emit machine-readable JSON instead of the human-readable report.
262        #[arg(long)]
263        json: bool,
264        /// Cap the memory stuffr will ask for, e.g. 512M or 2G.
265        ///
266        /// Defaults to 25% of available RAM, honouring cgroup limits.
267        #[arg(long, value_name = "SIZE")]
268        memory_limit: Option<String>,
269    },
270    /// List the formats this build contains.
271    Formats,
272    /// Create compatibility symlinks (`bzip2`, `bunzip2`, `bzcat`,
273    /// `bzip2recover`) to this binary in a directory. Opt-in, never automatic.
274    InstallLinks {
275        /// Directory to put the links in. It must already exist.
276        #[arg(long, value_name = "DIR")]
277        dir: std::path::PathBuf,
278        /// Only these names, comma-separated or repeated. Default: all.
279        #[arg(long, value_name = "NAME", value_delimiter = ',')]
280        names: Vec<String>,
281        /// Replace an existing file or foreign link of the same name.
282        #[arg(long)]
283        force: bool,
284        /// Remove stuffr's links instead of creating them.
285        #[arg(long)]
286        remove: bool,
287        /// Say what would change, and change nothing.
288        #[arg(long)]
289        dry_run: bool,
290    },
291    /// List an archive's entries without extracting.
292    ///
293    /// The first column is the entry's 0-based position in archive order,
294    /// which is what `cat --index` and `unpack --index` select by.
295    #[command(alias = "ls")]
296    List {
297        /// Archive path, or `-` for stdin.
298        input: String,
299        /// Emit machine-readable JSON instead of one entry per line.
300        #[arg(long)]
301        json: bool,
302        /// Refuse a codec layer beneath the container expanding by more
303        /// than this ratio (e.g. a `.tar.gz` bomb).
304        #[arg(long)]
305        max_ratio: Option<u64>,
306        /// Cap the memory stuffr will ask for, e.g. 512M or 2G.
307        ///
308        /// Bounds the codec layer beneath the container, which `--max-ratio`
309        /// cannot see: a pure xz/lzma/lzip decoder sizes its dictionary from
310        /// a value the file declares in its own header, before producing any
311        /// output. Defaults to 25% of available RAM, honouring cgroup limits.
312        #[arg(long, value_name = "SIZE")]
313        memory_limit: Option<String>,
314        /// Fail (exit 4) if the read approximated or lost anything.
315        ///
316        /// A listing can be incomplete — a zip whose index shadows records
317        /// enumerates fewer entries than the archive declares — so `list`
318        /// gates on fidelity for the same reason `test` and `unpack -C` do.
319        #[arg(long)]
320        strict_fidelity: bool,
321    },
322    /// Verify every entry's integrity without extracting.
323    Test {
324        /// Archive path, or `-` for stdin.
325        input: String,
326        /// Refuse a codec layer beneath the container expanding by more
327        /// than this ratio (e.g. a `.tar.gz` bomb).
328        #[arg(long)]
329        max_ratio: Option<u64>,
330        /// Cap the memory stuffr will ask for, e.g. 512M or 2G.
331        ///
332        /// Bounds the codec layer beneath the container, which `--max-ratio`
333        /// cannot see: a pure xz/lzma/lzip decoder sizes its dictionary from
334        /// a value the file declares in its own header, before producing any
335        /// output. Defaults to 25% of available RAM, honouring cgroup limits.
336        #[arg(long, value_name = "SIZE")]
337        memory_limit: Option<String>,
338        /// Fail (exit 4) if the read approximated or lost anything.
339        #[arg(long)]
340        strict_fidelity: bool,
341    },
342    /// Recover what a damaged zip's scan can prove, even where its central
343    /// directory alone would show less — or would wrongly unify two records
344    /// that happen to share a name.
345    ///
346    /// The one recovery-biased verb in this tool. `list`, `cat`, `unpack` and
347    /// `test` all stay exactly as uncompromising as they already are, and it
348    /// is salvage's willingness to accept less-than-proven content that buys
349    /// them that freedom.
350    Salvage {
351        /// Input path.
352        ///
353        /// Salvage scans back and forth over the archive, which needs real
354        /// random access — unlike every other verb here, this one does not
355        /// accept `-` for stdin.
356        input: String,
357        /// Print every record the scan found, including ones this build
358        /// never writes (a shadow, a skipped partial, an unverified entry).
359        ///
360        /// A record that repeats an earlier record's name is marked one of
361        /// two ways, and they mean different things. `[shadowed: dup of #N]`
362        /// is a MEASUREMENT: name, declared length and checksum all agree
363        /// with record N, so nothing is lost by not writing this one.
364        /// `[name collision: #N uses this name too]` is the weaker fact:
365        /// the name repeats but the content was not proven identical, so
366        /// both records are worth having and `-C` writes the second one as
367        /// `name.salvaged-N`, never over the first. It is the marker
368        /// `stuffr list`'s own "repeat a name … and are shadowed" fidelity
369        /// warning corresponds to.
370        ///
371        /// `stuffr list` on the same archive can print FEWER rows than this:
372        /// a record a later one shadows is invisible to `list`, which only
373        /// ever sees what the central directory's own name index kept. Works
374        /// with no destination at all (neither `-C` nor `-o`) — listing is
375        /// not recovering, and this is the diagnostic half of the feature:
376        /// seeing what an archive holds without extracting anything.
377        #[arg(long)]
378        list: bool,
379        /// Recover entries into this directory.
380        ///
381        /// One of `-C`, `-o` or `--list` is required. Mutually exclusive
382        /// with `-o`: a directory tree and one named file are two different
383        /// destinations.
384        ///
385        /// A damaged archive can hold two records under one name. The
386        /// second one recovered lands as `name.salvaged-N`, where N is its
387        /// scan position, rather than over the first — both records exist,
388        /// both are worth having, and the run exits 4 so a script knows a
389        /// name had to be changed.
390        #[arg(short = 'C', long, value_name = "DIR")]
391        directory: Option<String>,
392        /// Recover exactly one entry's bytes to this file, naming it with
393        /// exactly one `--index N`.
394        ///
395        /// A `Partial` entry still lands under `FILE.partial`, never `FILE`
396        /// itself — the same rule `-C` follows, for the same reason: a
397        /// truncated file under the name you asked for would be
398        /// indistinguishable from a whole one. Refused (exit 2) with more
399        /// than one `--index`, or together with `-C`.
400        #[arg(short = 'o', long, value_name = "FILE")]
401        output: Option<String>,
402        /// Recover only the record at this SCAN position instead of every
403        /// one found. Repeatable with `-C`; `-o` takes exactly one.
404        ///
405        /// A scan position is 0-based and counts every record the scan
406        /// found, shadowed ones included — a DIFFERENT numbering from
407        /// `stuffr list`'s own index, which never counts a shadowed record at
408        /// all. On an archive where a name is duplicated, `list --index 6`
409        /// and `salvage --index 6` can name different entries, or `list`'s
410        /// may not exist at all. Always a scan position, never a list index.
411        #[arg(long, value_name = "N")]
412        index: Vec<usize>,
413        /// What to do with an entry whose payload could not be fully proven:
414        /// `keep` (default) writes it as `name.partial`, never under its
415        /// real name; `skip` recovers nothing for it; `ask` has no terminal
416        /// to ask from at this layer and is read conservatively as `skip`.
417        #[arg(long, value_name = "POLICY")]
418        partial: Option<String>,
419        /// Raise the structural ceiling on a single entry's declared length,
420        /// e.g. 8G. A declaration over the ceiling is refused before
421        /// anything is allocated on its say-so, either way.
422        #[arg(long, value_name = "SIZE")]
423        max_entry: Option<String>,
424        /// Demand proof: skip every partial entry regardless of `--partial`,
425        /// and every unattested one (a format with no checksum and no header
426        /// self-check, whose entries are otherwise written under their real
427        /// names). The strict path every other verb in this tool already
428        /// stands on, applied here on request instead of by default.
429        #[arg(long)]
430        strict: bool,
431        /// Use this format instead of detecting one.
432        ///
433        /// Selects among the formats this build's salvage can attempt to
434        /// scan — `zip`, `tar`, `ar`, `cpio`, `arc`, `zoo`, `lha`, `arj` — not
435        /// merely accepted or rejected as in an earlier stage. Naming one
436        /// this build has no salvage scanner for is refused at exit 3,
437        /// exactly as an undetected archive of that same format would be.
438        /// `ar`'s scanner walks members in order and cannot cross a damaged
439        /// header; it says on stderr where it stopped.
440        ///
441        /// Scanners exist for: zip, arc, zoo, lha, arj, tar, cpio, ar.
442        #[arg(long)]
443        format: Option<String>,
444    },
445}