this repo has no description
0
fork

Configure Feed

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

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