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