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