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