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