this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at a5e0039efea1ba1e6e43755f2becd52a8713624d 483 lines 15 kB view raw
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}