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
60/// Trait for validating compression levels for different compressors
61#[allow(dead_code)]
62pub trait CompressionLevelValidator {
63 /// Get the minimum valid compression level
64 fn min_level(&self) -> i32;
65
66 /// Get the maximum valid compression level
67 fn max_level(&self) -> i32;
68
69 /// Get the default compression level
70 fn default_level(&self) -> i32;
71
72 /// Map special names to compression levels
73 fn name_to_level(&self, name: &str) -> Option<i32>;
74
75 /// Validate if a compression level is within the valid range
76 fn is_valid_level(&self, level: i32) -> bool {
77 level >= self.min_level() && level <= self.max_level()
78 }
79
80 /// Validate and clamp a compression level to the valid range
81 fn validate_and_clamp_level(&self, level: i32) -> i32 {
82 if level < self.min_level() {
83 self.min_level()
84 } else if level > self.max_level() {
85 self.max_level()
86 } else {
87 level
88 }
89 }
90}
91
92/// Default implementation for most compressors (0-9 range)
93#[derive(Debug, Clone, Copy)]
94pub struct DefaultCompressionValidator;
95
96impl CompressionLevelValidator for DefaultCompressionValidator {
97 fn min_level(&self) -> i32 {
98 0
99 }
100 fn max_level(&self) -> i32 {
101 9
102 }
103 fn default_level(&self) -> i32 {
104 6
105 }
106
107 fn name_to_level(&self, name: &str) -> Option<i32> {
108 match name.to_lowercase().as_str() {
109 "none" => Some(0),
110 "fast" => Some(1),
111 "best" => Some(9),
112 _ => None,
113 }
114 }
115}
116
117#[derive(Debug, Clone, Copy)]
118pub struct CompressionLevel {
119 pub level: i32,
120}
121
122impl Default for CompressionLevel {
123 fn default() -> Self {
124 CompressionLevel { level: 6 }
125 }
126}
127
128impl FromStr for CompressionLevel {
129 type Err = &'static str;
130
131 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
132 // Check for an int
133 if let Ok(level) = s.parse::<i32>() {
134 return Ok(CompressionLevel { level });
135 }
136
137 // Try to parse special names
138 let s = s.to_lowercase();
139 match s.as_str() {
140 "none" | "fast" | "best" => Ok(CompressionLevel {
141 // We'll use the DefaultCompressionValidator values here
142 // The actual compressor will interpret these values according to its own validator
143 level: DefaultCompressionValidator.name_to_level(&s).unwrap(),
144 }),
145 _ => Err("Invalid compression level"),
146 }
147 }
148}
149
150#[derive(Args, Debug, Default, Clone, Copy)]
151pub struct LevelArgs {
152 /// Level of compression.
153 /// `none`, `fast`, and `best` are mapped to appropriate values for each compressor.
154 #[arg(long, default_value = "fast")]
155 pub level: CompressionLevel,
156}
157
158/// Common interface for all compressor implementations
159#[allow(unused_variables)]
160pub trait Compressor: Send + Sync {
161 /// Name of this Compressor
162 fn name(&self) -> &str;
163
164 /// Default extension for this Compressor
165 fn extension(&self) -> &str {
166 self.name()
167 }
168
169 /// Determine if this compressor extracts to a file or directory by default
170 /// FILE compressors (like gzip, bzip2, xz) extract to a single file
171 /// DIRECTORY compressors (like zip, tar) extract to a directory
172 fn default_extracted_target(&self) -> ExtractedTarget {
173 ExtractedTarget::FILE
174 }
175
176 /// Detect if the input is an archive of this type
177 /// Just checks the extension by default
178 /// Some compressors may overwrite this to do more advanced detection
179 fn is_archive(&self, in_path: &Path) -> bool {
180 if in_path.extension().is_none() {
181 return false;
182 }
183 in_path.extension().unwrap() == self.extension()
184 }
185
186 /// Generate the default name for the compressed file
187 fn default_compressed_filename(&self, in_path: &Path) -> String {
188 format!(
189 "{}.{}",
190 in_path
191 .file_name()
192 .unwrap_or_else(|| OsStr::new("archive"))
193 .to_str()
194 .unwrap(),
195 self.extension()
196 )
197 }
198
199 /// Generate the default extracted filename
200 fn default_extracted_filename(&self, in_path: &Path) -> String {
201 if self.default_extracted_target() == ExtractedTarget::DIRECTORY {
202 return ".".to_string();
203 }
204
205 // If the file has no extension, return the current directory
206 if let Some(ext) = in_path.extension() {
207 // If the file has the extension for this type, return the filename without the extension
208 if let Some(ext_str) = ext.to_str() {
209 if ext_str == self.extension() {
210 if let Some(stem) = in_path.file_stem() {
211 if let Some(stem_str) = stem.to_str() {
212 return stem_str.to_string();
213 }
214 }
215 }
216 }
217 }
218 "archive".to_string()
219 }
220
221 fn compress(&self, input: CmprssInput, output: CmprssOutput) -> Result;
222
223 fn extract(&self, input: CmprssInput, output: CmprssOutput) -> Result;
224}
225
226impl fmt::Debug for dyn Compressor {
227 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
228 write!(f, "Compressor {{ name: {} }}", self.name())
229 }
230}
231
232/// Wrapper for Read + Send to allow Debug
233pub struct ReadWrapper(pub Box<dyn Read + Send>);
234
235impl Read for ReadWrapper {
236 fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
237 self.0.read(buf)
238 }
239}
240
241impl fmt::Debug for ReadWrapper {
242 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
243 write!(f, "ReadWrapper")
244 }
245}
246
247/// Wrapper for Write + Send to allow Debug
248pub struct WriteWrapper(pub Box<dyn Write + Send>);
249
250impl Write for WriteWrapper {
251 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
252 self.0.write(buf)
253 }
254
255 fn flush(&mut self) -> io::Result<()> {
256 self.0.flush()
257 }
258}
259
260impl fmt::Debug for WriteWrapper {
261 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
262 write!(f, "WriteWrapper")
263 }
264}
265
266/// Defines the possible inputs of a compressor
267#[derive(Debug)]
268pub enum CmprssInput {
269 /// Path(s) to the input files.
270 Path(Vec<PathBuf>),
271 /// Input pipe
272 Pipe(std::io::Stdin),
273 /// In-memory reader (for piping between compressors)
274 Reader(ReadWrapper),
275}
276
277/// Defines the possible outputs of a compressor
278#[derive(Debug)]
279pub enum CmprssOutput {
280 Path(PathBuf),
281 Pipe(std::io::Stdout),
282 /// In-memory writer (for piping between compressors)
283 Writer(WriteWrapper),
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289 use std::path::Path;
290
291 /// A simple implementation of the Compressor trait for testing
292 struct TestCompressor;
293
294 impl Compressor for TestCompressor {
295 fn name(&self) -> &str {
296 "test"
297 }
298
299 // We'll use the default implementation for extension() and other methods
300
301 fn compress(&self, _: CmprssInput, _: CmprssOutput) -> Result {
302 Ok(())
303 }
304
305 fn extract(&self, _: CmprssInput, _: CmprssOutput) -> Result {
306 Ok(())
307 }
308 }
309
310 /// A compressor that overrides the default extension
311 struct CustomExtensionCompressor;
312
313 impl Compressor for CustomExtensionCompressor {
314 fn name(&self) -> &str {
315 "custom"
316 }
317
318 fn extension(&self) -> &str {
319 "cst"
320 }
321
322 fn compress(&self, _: CmprssInput, _: CmprssOutput) -> Result {
323 Ok(())
324 }
325
326 fn extract(&self, _: CmprssInput, _: CmprssOutput) -> Result {
327 Ok(())
328 }
329 }
330
331 #[test]
332 fn test_default_name_extension() {
333 let compressor = TestCompressor;
334 assert_eq!(compressor.name(), "test");
335 assert_eq!(compressor.extension(), "test");
336 }
337
338 #[test]
339 fn test_custom_extension() {
340 let compressor = CustomExtensionCompressor;
341 assert_eq!(compressor.name(), "custom");
342 assert_eq!(compressor.extension(), "cst");
343 }
344
345 #[test]
346 fn test_is_archive_detection() {
347 use tempfile::tempdir;
348
349 let compressor = TestCompressor;
350 let temp_dir = tempdir().expect("Failed to create temp dir");
351
352 // Test with matching extension
353 let archive_path = temp_dir.path().join("archive.test");
354 std::fs::File::create(&archive_path).expect("Failed to create test file");
355 assert!(compressor.is_archive(&archive_path));
356
357 // Test with non-matching extension
358 let non_archive_path = temp_dir.path().join("archive.txt");
359 std::fs::File::create(&non_archive_path).expect("Failed to create test file");
360 assert!(!compressor.is_archive(&non_archive_path));
361
362 // Test with no extension
363 let no_ext_path = temp_dir.path().join("archive");
364 std::fs::File::create(&no_ext_path).expect("Failed to create test file");
365 assert!(!compressor.is_archive(&no_ext_path));
366 }
367
368 #[test]
369 fn test_default_compressed_filename() {
370 let compressor = TestCompressor;
371
372 // Test with normal filename
373 let path = Path::new("file.txt");
374 assert_eq!(
375 compressor.default_compressed_filename(path),
376 "file.txt.test"
377 );
378
379 // Test with no extension
380 let path = Path::new("file");
381 assert_eq!(compressor.default_compressed_filename(path), "file.test");
382 }
383
384 #[test]
385 fn test_default_extracted_filename() {
386 let compressor = TestCompressor;
387
388 // Test with matching extension
389 let path = Path::new("archive.test");
390 assert_eq!(compressor.default_extracted_filename(path), "archive");
391
392 // Test with non-matching extension
393 let path = Path::new("archive.txt");
394 assert_eq!(compressor.default_extracted_filename(path), "archive");
395
396 // Test with no extension
397 let path = Path::new("archive");
398 assert_eq!(compressor.default_extracted_filename(path), "archive");
399 }
400
401 #[test]
402 fn test_compression_level_parsing() {
403 // Test numeric levels
404 assert_eq!(CompressionLevel::from_str("1").unwrap().level, 1);
405 assert_eq!(CompressionLevel::from_str("9").unwrap().level, 9);
406
407 // Test named levels
408 let validator = DefaultCompressionValidator;
409 assert_eq!(
410 CompressionLevel::from_str("fast").unwrap().level,
411 validator.name_to_level("fast").unwrap()
412 );
413 assert_eq!(
414 CompressionLevel::from_str("best").unwrap().level,
415 validator.name_to_level("best").unwrap()
416 );
417
418 // Test invalid values
419 assert!(CompressionLevel::from_str("invalid").is_err());
420 }
421
422 #[test]
423 fn test_compression_level_defaults() {
424 let default_level = CompressionLevel::default();
425 let validator = DefaultCompressionValidator;
426 assert_eq!(default_level.level, validator.default_level());
427 }
428
429 #[test]
430 fn test_default_compression_validator() {
431 let validator = DefaultCompressionValidator;
432
433 use crate::test_utils::test_compression_validator_helper;
434 test_compression_validator_helper(
435 &validator,
436 0, // min_level
437 9, // max_level
438 6, // default_level
439 Some(1), // fast_name_level
440 Some(9), // best_name_level
441 Some(0), // none_name_level
442 );
443 }
444}