this repo has no description
0
fork

Configure Feed

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

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