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 /// Decompress a file, or stream an archive's entries, to stdout.
146 Cat {
147 /// Input path, or `-` for stdin.
148 input: String,
149 /// Entries whose bytes to stream, in archive order.
150 ///
151 /// An exact entry name, or a directory selecting everything beneath
152 /// it. Naming one is what asks for entry-aware streaming; without
153 /// any, `cat` decodes the input as a single stream.
154 #[arg(value_name = "PATTERNS")]
155 patterns: Vec<String>,
156 /// Stream the entry at this position instead of naming it. Repeatable.
157 ///
158 /// 0-BASED, counting in archive order — the same number `stuffr list`
159 /// prints in its first column. `stuffr cat a.zip --index 6 > out.sh`
160 /// is how you pull out an entry whose name is ambiguous or
161 /// untypeable. Like PATTERNS, naming one asks for entry-aware
162 /// streaming; the two cannot be combined.
163 #[arg(long, value_name = "N")]
164 index: Vec<usize>,
165 /// Use this format instead of detecting one. Required for formats with
166 /// no magic bytes and no extension.
167 #[arg(long)]
168 format: Option<String>,
169 /// Refuse a decode expanding by more than this ratio.
170 #[arg(long)]
171 max_ratio: Option<u64>,
172 /// Cap the memory stuffr will ask for, e.g. 512M or 2G.
173 ///
174 /// Defaults to 25% of available RAM, honouring cgroup limits.
175 #[arg(long, value_name = "SIZE")]
176 memory_limit: Option<String>,
177 },
178 /// Identify a stream without decoding it.
179 Info {
180 /// Input path, or `-` for stdin.
181 input: String,
182 /// Emit machine-readable JSON instead of the human-readable report.
183 #[arg(long)]
184 json: bool,
185 /// Cap the memory stuffr will ask for, e.g. 512M or 2G.
186 ///
187 /// Defaults to 25% of available RAM, honouring cgroup limits.
188 #[arg(long, value_name = "SIZE")]
189 memory_limit: Option<String>,
190 },
191 /// List the formats this build contains.
192 Formats,
193 /// List an archive's entries without extracting.
194 ///
195 /// The first column is the entry's 0-based position in archive order,
196 /// which is what `cat --index` and `unpack --index` select by.
197 #[command(alias = "ls")]
198 List {
199 /// Archive path, or `-` for stdin.
200 input: String,
201 /// Emit machine-readable JSON instead of one entry per line.
202 #[arg(long)]
203 json: bool,
204 /// Refuse a codec layer beneath the container expanding by more
205 /// than this ratio (e.g. a `.tar.gz` bomb).
206 #[arg(long)]
207 max_ratio: Option<u64>,
208 /// Cap the memory stuffr will ask for, e.g. 512M or 2G.
209 ///
210 /// Bounds the codec layer beneath the container, which `--max-ratio`
211 /// cannot see: a pure xz/lzma/lzip decoder sizes its dictionary from
212 /// a value the file declares in its own header, before producing any
213 /// output. Defaults to 25% of available RAM, honouring cgroup limits.
214 #[arg(long, value_name = "SIZE")]
215 memory_limit: Option<String>,
216 /// Fail (exit 4) if the read approximated or lost anything.
217 ///
218 /// A listing can be incomplete — a zip whose index shadows records
219 /// enumerates fewer entries than the archive declares — so `list`
220 /// gates on fidelity for the same reason `test` and `unpack -C` do.
221 #[arg(long)]
222 strict_fidelity: bool,
223 },
224 /// Verify every entry's integrity without extracting.
225 Test {
226 /// Archive path, or `-` for stdin.
227 input: String,
228 /// Refuse a codec layer beneath the container expanding by more
229 /// than this ratio (e.g. a `.tar.gz` bomb).
230 #[arg(long)]
231 max_ratio: Option<u64>,
232 /// Cap the memory stuffr will ask for, e.g. 512M or 2G.
233 ///
234 /// Bounds the codec layer beneath the container, which `--max-ratio`
235 /// cannot see: a pure xz/lzma/lzip decoder sizes its dictionary from
236 /// a value the file declares in its own header, before producing any
237 /// output. Defaults to 25% of available RAM, honouring cgroup limits.
238 #[arg(long, value_name = "SIZE")]
239 memory_limit: Option<String>,
240 /// Fail (exit 4) if the read approximated or lost anything.
241 #[arg(long)]
242 strict_fidelity: bool,
243 },
244 /// Recover what a damaged zip's scan can prove, even where its central
245 /// directory alone would show less — or would wrongly unify two records
246 /// that happen to share a name.
247 ///
248 /// The one recovery-biased verb in this tool. `list`, `cat`, `unpack` and
249 /// `test` all stay exactly as uncompromising as they already are, and it
250 /// is salvage's willingness to accept less-than-proven content that buys
251 /// them that freedom.
252 Salvage {
253 /// Input path.
254 ///
255 /// Salvage scans back and forth over the archive, which needs real
256 /// random access — unlike every other verb here, this one does not
257 /// accept `-` for stdin.
258 input: String,
259 /// Print every record the scan found, including ones this build
260 /// never writes (a shadow, a skipped partial, an unverified entry).
261 ///
262 /// A record that repeats an earlier record's name is marked one of
263 /// two ways, and they mean different things. `[shadowed: dup of #N]`
264 /// is a MEASUREMENT: name, declared length and checksum all agree
265 /// with record N, so nothing is lost by not writing this one.
266 /// `[name collision: #N uses this name too]` is the weaker fact:
267 /// the name repeats but the content was not proven identical, so
268 /// both records are worth having and `-C` writes the second one as
269 /// `name.salvaged-N`, never over the first. It is the marker
270 /// `stuffr list`'s own "repeat a name … and are shadowed" fidelity
271 /// warning corresponds to.
272 ///
273 /// `stuffr list` on the same archive can print FEWER rows than this:
274 /// a record a later one shadows is invisible to `list`, which only
275 /// ever sees what the central directory's own name index kept. Works
276 /// with no destination at all (neither `-C` nor `-o`) — listing is
277 /// not recovering, and this is the diagnostic half of the feature:
278 /// seeing what an archive holds without extracting anything.
279 #[arg(long)]
280 list: bool,
281 /// Recover entries into this directory.
282 ///
283 /// One of `-C`, `-o` or `--list` is required. Mutually exclusive
284 /// with `-o`: a directory tree and one named file are two different
285 /// destinations.
286 ///
287 /// A damaged archive can hold two records under one name. The
288 /// second one recovered lands as `name.salvaged-N`, where N is its
289 /// scan position, rather than over the first — both records exist,
290 /// both are worth having, and the run exits 4 so a script knows a
291 /// name had to be changed.
292 #[arg(short = 'C', long, value_name = "DIR")]
293 directory: Option<String>,
294 /// Recover exactly one entry's bytes to this file, naming it with
295 /// exactly one `--index N`.
296 ///
297 /// A `Partial` entry still lands under `FILE.partial`, never `FILE`
298 /// itself — the same rule `-C` follows, for the same reason: a
299 /// truncated file under the name you asked for would be
300 /// indistinguishable from a whole one. Refused (exit 2) with more
301 /// than one `--index`, or together with `-C`.
302 #[arg(short = 'o', long, value_name = "FILE")]
303 output: Option<String>,
304 /// Recover only the record at this SCAN position instead of every
305 /// one found. Repeatable with `-C`; `-o` takes exactly one.
306 ///
307 /// A scan position is 0-based and counts every record the scan
308 /// found, shadowed ones included — a DIFFERENT numbering from
309 /// `stuffr list`'s own index, which never counts a shadowed record at
310 /// all. On an archive where a name is duplicated, `list --index 6`
311 /// and `salvage --index 6` can name different entries, or `list`'s
312 /// may not exist at all. Always a scan position, never a list index.
313 #[arg(long, value_name = "N")]
314 index: Vec<usize>,
315 /// What to do with an entry whose payload could not be fully proven:
316 /// `keep` (default) writes it as `name.partial`, never under its
317 /// real name; `skip` recovers nothing for it; `ask` has no terminal
318 /// to ask from at this layer and is read conservatively as `skip`.
319 #[arg(long, value_name = "POLICY")]
320 partial: Option<String>,
321 /// Raise the structural ceiling on a single entry's declared length,
322 /// e.g. 8G. A declaration over the ceiling is refused before
323 /// anything is allocated on its say-so, either way.
324 #[arg(long, value_name = "SIZE")]
325 max_entry: Option<String>,
326 /// Demand proof: skip every partial entry regardless of `--partial`,
327 /// and every unattested one (a format with no checksum and no header
328 /// self-check, whose entries are otherwise written under their real
329 /// names). The strict path every other verb in this tool already
330 /// stands on, applied here on request instead of by default.
331 #[arg(long)]
332 strict: bool,
333 /// Use this format instead of detecting one.
334 ///
335 /// Selects among the formats this build's salvage can attempt to
336 /// scan — `zip`, `tar`, `ar`, `cpio`, `arc`, `zoo`, `lha`, `arj` — not
337 /// merely accepted or rejected as in an earlier stage. Naming one
338 /// this build has no salvage scanner for is refused at exit 3,
339 /// exactly as an undetected archive of that same format would be.
340 /// `ar`'s scanner walks members in order and cannot cross a damaged
341 /// header; it says on stderr where it stopped.
342 ///
343 /// Scanners exist for: zip, arc, zoo, lha, arj, tar, cpio, ar.
344 #[arg(long)]
345 format: Option<String>,
346 },
347}