this repo has no description
1use clap::Args;
2use std::fmt;
3use std::io;
4use std::io::{Read, Write};
5use std::path::{Path, PathBuf};
6use std::str::FromStr;
7
8pub type Result<T = ()> = anyhow::Result<T>;
9
10/// Enum to represent whether a compressor extracts to a file or directory by default
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum ExtractedTarget {
13 /// Extract to a single file (e.g., gzip, bzip2, xz)
14 File,
15 /// Extract to a directory (e.g., zip, tar)
16 Directory,
17}
18
19#[derive(Args, Debug)]
20pub struct CommonArgs {
21 /// Input file/directory
22 #[arg(short, long)]
23 pub input: Option<String>,
24
25 /// Output file/directory
26 #[arg(short, long)]
27 pub output: Option<String>,
28
29 /// Compress the input (default)
30 #[arg(short, long)]
31 pub compress: bool,
32
33 /// Extract the input
34 #[arg(short, long, visible_alias = "decompress")]
35 pub extract: bool,
36
37 /// Append the input(s) to an existing archive.
38 /// Only supported by container formats that can grow in place (tar, zip);
39 /// stream codecs and mixed pipelines like `tar.gz` will error.
40 #[arg(short, long)]
41 pub append: bool,
42
43 /// List of I/O.
44 /// This consists of all the inputs followed by the single output, with intelligent fallback to stdin/stdout.
45 #[arg()]
46 pub io_list: Vec<String>,
47
48 /// Ignore pipes when inferring I/O
49 #[arg(long)]
50 pub ignore_pipes: bool,
51
52 /// Ignore stdin when inferring I/O
53 #[arg(long)]
54 pub ignore_stdin: bool,
55
56 /// Ignore stdout when inferring I/O
57 #[arg(long)]
58 pub ignore_stdout: bool,
59
60 /// Overwrite the output path if it already exists.
61 #[arg(short, long)]
62 pub force: bool,
63
64 /// List the contents of an archive (for container formats like tar and zip).
65 #[arg(short, long)]
66 pub list: bool,
67}
68
69/// Trait for validating compression levels for different compressors
70pub trait CompressionLevelValidator {
71 /// Get the minimum valid compression level
72 fn min_level(&self) -> i32;
73
74 /// Get the maximum valid compression level
75 fn max_level(&self) -> i32;
76
77 /// Get the default compression level
78 fn default_level(&self) -> i32;
79
80 /// Map special names to compression levels
81 fn name_to_level(&self, name: &str) -> Option<i32>;
82
83 /// Validate if a compression level is within the valid range
84 #[cfg(test)]
85 fn is_valid_level(&self, level: i32) -> bool {
86 level >= self.min_level() && level <= self.max_level()
87 }
88
89 /// Validate and clamp a compression level to the valid range
90 fn validate_and_clamp_level(&self, level: i32) -> i32 {
91 if level < self.min_level() {
92 self.min_level()
93 } else if level > self.max_level() {
94 self.max_level()
95 } else {
96 level
97 }
98 }
99}
100
101/// Default implementation for most compressors (0-9 range)
102#[derive(Debug, Clone, Copy)]
103pub struct DefaultCompressionValidator;
104
105impl CompressionLevelValidator for DefaultCompressionValidator {
106 fn min_level(&self) -> i32 {
107 0
108 }
109 fn max_level(&self) -> i32 {
110 9
111 }
112 fn default_level(&self) -> i32 {
113 6
114 }
115
116 fn name_to_level(&self, name: &str) -> Option<i32> {
117 match name.to_lowercase().as_str() {
118 "none" => Some(0),
119 "fast" => Some(1),
120 "best" => Some(9),
121 _ => None,
122 }
123 }
124}
125
126#[derive(Debug, Clone, Copy)]
127pub struct CompressionLevel {
128 pub level: i32,
129}
130
131impl Default for CompressionLevel {
132 fn default() -> Self {
133 CompressionLevel { level: 6 }
134 }
135}
136
137impl FromStr for CompressionLevel {
138 type Err = &'static str;
139
140 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
141 // Check for an int
142 if let Ok(level) = s.parse::<i32>() {
143 return Ok(CompressionLevel { level });
144 }
145
146 // Otherwise expect a named level ("none"/"fast"/"best"). The concrete
147 // compressor re-interprets this value through its own validator, so we
148 // start from the default mapping.
149 let level = DefaultCompressionValidator
150 .name_to_level(&s.to_lowercase())
151 .ok_or("Invalid compression level")?;
152 Ok(CompressionLevel { level })
153 }
154}
155
156#[derive(Args, Debug, Default, Clone, Copy)]
157pub struct LevelArgs {
158 /// Level of compression.
159 /// `none`, `fast`, and `best` are mapped to appropriate values for each compressor.
160 #[arg(long, default_value = "fast")]
161 pub level: CompressionLevel,
162}
163
164impl LevelArgs {
165 /// Resolve the user-requested compression level against a codec-specific
166 /// validator, clamping to the validator's range. This is the standard way
167 /// for a backend to turn `--level N` into a concrete integer it can pass
168 /// to the underlying library.
169 pub fn resolve(&self, validator: &impl CompressionLevelValidator) -> i32 {
170 validator.validate_and_clamp_level(self.level.level)
171 }
172}
173
174/// Produce an owned copy of a `Compressor` behind `Box<dyn Compressor>`,
175/// preserving all configuration (compression level, progress args, pipeline
176/// chain, etc). `Pipeline` uses this to hand owned instances to worker threads
177/// without losing user-supplied settings.
178///
179/// Implementors don't write this manually — the blanket impl below covers any
180/// `Compressor + Clone + 'static`. `Clone` itself can't be a supertrait of
181/// `Compressor` because it would break object safety for `Box<dyn Compressor>`.
182pub trait CompressorClone {
183 fn clone_boxed(&self) -> Box<dyn Compressor>;
184}
185
186impl<T: Compressor + Clone + 'static> CompressorClone for T {
187 fn clone_boxed(&self) -> Box<dyn Compressor> {
188 Box::new(self.clone())
189 }
190}
191
192/// Common interface for all compressor implementations
193pub trait Compressor: CompressorClone + Send + Sync {
194 /// Name of this Compressor
195 fn name(&self) -> &str;
196
197 /// Default extension for this Compressor
198 fn extension(&self) -> &str {
199 self.name()
200 }
201
202 /// Determine if this compressor extracts to a file or directory by default
203 /// FILE compressors (like gzip, bzip2, xz) extract to a single file
204 /// DIRECTORY compressors (like zip, tar) extract to a directory
205 fn default_extracted_target(&self) -> ExtractedTarget {
206 ExtractedTarget::File
207 }
208
209 /// Detect if the input is an archive of this type
210 /// Just checks the extension by default
211 /// Some compressors may overwrite this to do more advanced detection
212 fn is_archive(&self, in_path: &Path) -> bool {
213 in_path
214 .extension()
215 .is_some_and(|ext| ext == self.extension())
216 }
217
218 /// Generate the default name for the compressed file
219 fn default_compressed_filename(&self, in_path: &Path) -> String {
220 let name = in_path
221 .file_name()
222 .and_then(|f| f.to_str())
223 .unwrap_or("archive");
224 format!("{name}.{}", self.extension())
225 }
226
227 /// Generate the default extracted filename
228 fn default_extracted_filename(&self, in_path: &Path) -> String {
229 if self.default_extracted_target() == ExtractedTarget::Directory {
230 return ".".to_string();
231 }
232
233 // If the file has no extension, return the current directory
234 if let Some(ext) = in_path.extension() {
235 // If the file has the extension for this type, return the filename without the extension
236 if let Some(ext_str) = ext.to_str()
237 && ext_str == self.extension()
238 && let Some(stem) = in_path.file_stem()
239 && let Some(stem_str) = stem.to_str()
240 {
241 return stem_str.to_string();
242 }
243 }
244 "archive".to_string()
245 }
246
247 fn compress(&self, input: CmprssInput, output: CmprssOutput) -> Result;
248
249 fn extract(&self, input: CmprssInput, output: CmprssOutput) -> Result;
250
251 /// Append the input to an existing archive pointed at by `output`.
252 ///
253 /// The default implementation bails: only container formats that can grow
254 /// in place — currently tar and zip — support appending. Stream codecs
255 /// (gzip, xz, …) have no notion of entries, and compound pipelines like
256 /// `tar.gz` would require decompress-then-recompress which defeats the
257 /// point of an in-place append.
258 fn append(&self, _input: CmprssInput, _output: CmprssOutput) -> Result {
259 anyhow::bail!(
260 "{} archives do not support --append; only container formats (tar, zip) can be appended to in place",
261 self.name()
262 )
263 }
264
265 /// List the contents of the archive to stdout.
266 ///
267 /// The default implementation bails: only container formats — `tar`,
268 /// `zip`, and pipelines whose innermost layer is one of those — can
269 /// meaningfully enumerate their contents. Stream codecs (gzip, xz, …)
270 /// just compress a single byte stream and have nothing to list.
271 fn list(&self, _input: CmprssInput) -> Result {
272 anyhow::bail!(
273 "{} archives cannot be listed; only container formats (tar, zip) support --list",
274 self.name()
275 )
276 }
277}
278
279impl fmt::Debug for dyn Compressor {
280 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
281 write!(f, "Compressor {{ name: {} }}", self.name())
282 }
283}
284
285/// Wrapper for Read + Send to allow Debug
286pub struct ReadWrapper(pub Box<dyn Read + Send>);
287
288impl Read for ReadWrapper {
289 fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
290 self.0.read(buf)
291 }
292}
293
294impl fmt::Debug for ReadWrapper {
295 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
296 write!(f, "ReadWrapper")
297 }
298}
299
300/// Wrapper for Write + Send to allow Debug
301pub struct WriteWrapper(pub Box<dyn Write + Send>);
302
303impl Write for WriteWrapper {
304 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
305 self.0.write(buf)
306 }
307
308 fn flush(&mut self) -> io::Result<()> {
309 self.0.flush()
310 }
311}
312
313impl fmt::Debug for WriteWrapper {
314 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
315 write!(f, "WriteWrapper")
316 }
317}
318
319/// Defines the possible inputs of a compressor
320#[derive(Debug)]
321pub enum CmprssInput {
322 /// Path(s) to the input files.
323 Path(Vec<PathBuf>),
324 /// Input pipe
325 Pipe(std::io::Stdin),
326 /// In-memory reader (for piping between compressors)
327 Reader(ReadWrapper),
328}
329
330/// Defines the possible outputs of a compressor
331#[derive(Debug)]
332pub enum CmprssOutput {
333 Path(PathBuf),
334 Pipe(std::io::Stdout),
335 /// In-memory writer (for piping between compressors)
336 Writer(WriteWrapper),
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342 use std::path::Path;
343
344 /// A simple implementation of the Compressor trait for testing
345 #[derive(Clone)]
346 struct TestCompressor;
347
348 impl Compressor for TestCompressor {
349 fn name(&self) -> &str {
350 "test"
351 }
352
353 // We'll use the default implementation for extension() and other methods
354
355 fn compress(&self, _: CmprssInput, _: CmprssOutput) -> Result {
356 Ok(())
357 }
358
359 fn extract(&self, _: CmprssInput, _: CmprssOutput) -> Result {
360 Ok(())
361 }
362 }
363
364 /// A compressor that overrides the default extension
365 #[derive(Clone)]
366 struct CustomExtensionCompressor;
367
368 impl Compressor for CustomExtensionCompressor {
369 fn name(&self) -> &str {
370 "custom"
371 }
372
373 fn extension(&self) -> &str {
374 "cst"
375 }
376
377 fn compress(&self, _: CmprssInput, _: CmprssOutput) -> Result {
378 Ok(())
379 }
380
381 fn extract(&self, _: CmprssInput, _: CmprssOutput) -> Result {
382 Ok(())
383 }
384 }
385
386 #[test]
387 fn test_default_name_extension() {
388 let compressor = TestCompressor;
389 assert_eq!(compressor.name(), "test");
390 assert_eq!(compressor.extension(), "test");
391 }
392
393 #[test]
394 fn test_custom_extension() {
395 let compressor = CustomExtensionCompressor;
396 assert_eq!(compressor.name(), "custom");
397 assert_eq!(compressor.extension(), "cst");
398 }
399
400 #[test]
401 fn test_is_archive_detection() {
402 use tempfile::tempdir;
403
404 let compressor = TestCompressor;
405 let temp_dir = tempdir().expect("Failed to create temp dir");
406
407 // Test with matching extension
408 let archive_path = temp_dir.path().join("archive.test");
409 std::fs::File::create(&archive_path).expect("Failed to create test file");
410 assert!(compressor.is_archive(&archive_path));
411
412 // Test with non-matching extension
413 let non_archive_path = temp_dir.path().join("archive.txt");
414 std::fs::File::create(&non_archive_path).expect("Failed to create test file");
415 assert!(!compressor.is_archive(&non_archive_path));
416
417 // Test with no extension
418 let no_ext_path = temp_dir.path().join("archive");
419 std::fs::File::create(&no_ext_path).expect("Failed to create test file");
420 assert!(!compressor.is_archive(&no_ext_path));
421 }
422
423 #[test]
424 fn test_default_compressed_filename() {
425 let compressor = TestCompressor;
426
427 // Test with normal filename
428 let path = Path::new("file.txt");
429 assert_eq!(
430 compressor.default_compressed_filename(path),
431 "file.txt.test"
432 );
433
434 // Test with no extension
435 let path = Path::new("file");
436 assert_eq!(compressor.default_compressed_filename(path), "file.test");
437 }
438
439 #[test]
440 fn test_default_extracted_filename() {
441 let compressor = TestCompressor;
442
443 // Test with matching extension
444 let path = Path::new("archive.test");
445 assert_eq!(compressor.default_extracted_filename(path), "archive");
446
447 // Test with non-matching extension
448 let path = Path::new("archive.txt");
449 assert_eq!(compressor.default_extracted_filename(path), "archive");
450
451 // Test with no extension
452 let path = Path::new("archive");
453 assert_eq!(compressor.default_extracted_filename(path), "archive");
454 }
455
456 #[test]
457 fn test_compression_level_parsing() {
458 // Test numeric levels
459 assert_eq!(CompressionLevel::from_str("1").unwrap().level, 1);
460 assert_eq!(CompressionLevel::from_str("9").unwrap().level, 9);
461
462 // Test named levels
463 let validator = DefaultCompressionValidator;
464 assert_eq!(
465 CompressionLevel::from_str("fast").unwrap().level,
466 validator.name_to_level("fast").unwrap()
467 );
468 assert_eq!(
469 CompressionLevel::from_str("best").unwrap().level,
470 validator.name_to_level("best").unwrap()
471 );
472
473 // Test invalid values
474 assert!(CompressionLevel::from_str("invalid").is_err());
475 }
476
477 #[test]
478 fn test_compression_level_defaults() {
479 let default_level = CompressionLevel::default();
480 let validator = DefaultCompressionValidator;
481 assert_eq!(default_level.level, validator.default_level());
482 }
483
484 #[test]
485 fn test_default_compression_validator() {
486 let validator = DefaultCompressionValidator;
487
488 use crate::test_utils::test_compression_validator_helper;
489 test_compression_validator_helper(
490 &validator,
491 0, // min_level
492 9, // max_level
493 6, // default_level
494 Some(1), // fast_name_level
495 Some(9), // best_name_level
496 Some(0), // none_name_level
497 );
498 }
499}