this repo has no description
0
fork

Configure Feed

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

at 77172c51a4121ee14d0d9caa4e9cc687f6b32641 483 lines 14 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)] 35 pub extract: bool, 36 37 /// Decompress the input. Alias of --extract 38 #[arg(short, long)] 39 pub decompress: bool, 40 41 /// List of I/O. 42 /// This consists of all the inputs followed by the single output, with intelligent fallback to stdin/stdout. 43 #[arg()] 44 pub io_list: Vec<String>, 45 46 /// Ignore pipes when inferring I/O 47 #[arg(long)] 48 pub ignore_pipes: bool, 49 50 /// Ignore stdin when inferring I/O 51 #[arg(long)] 52 pub ignore_stdin: bool, 53 54 /// Ignore stdout when inferring I/O 55 #[arg(long)] 56 pub ignore_stdout: bool, 57 58 /// Overwrite the output path if it already exists. 59 #[arg(short, long)] 60 pub force: bool, 61 62 /// List the contents of an archive (for container formats like tar and zip). 63 #[arg(short, long)] 64 pub list: bool, 65} 66 67/// Trait for validating compression levels for different compressors 68pub trait CompressionLevelValidator { 69 /// Get the minimum valid compression level 70 fn min_level(&self) -> i32; 71 72 /// Get the maximum valid compression level 73 fn max_level(&self) -> i32; 74 75 /// Get the default compression level 76 fn default_level(&self) -> i32; 77 78 /// Map special names to compression levels 79 fn name_to_level(&self, name: &str) -> Option<i32>; 80 81 /// Validate if a compression level is within the valid range 82 #[cfg(test)] 83 fn is_valid_level(&self, level: i32) -> bool { 84 level >= self.min_level() && level <= self.max_level() 85 } 86 87 /// Validate and clamp a compression level to the valid range 88 fn validate_and_clamp_level(&self, level: i32) -> i32 { 89 if level < self.min_level() { 90 self.min_level() 91 } else if level > self.max_level() { 92 self.max_level() 93 } else { 94 level 95 } 96 } 97} 98 99/// Default implementation for most compressors (0-9 range) 100#[derive(Debug, Clone, Copy)] 101pub struct DefaultCompressionValidator; 102 103impl CompressionLevelValidator for DefaultCompressionValidator { 104 fn min_level(&self) -> i32 { 105 0 106 } 107 fn max_level(&self) -> i32 { 108 9 109 } 110 fn default_level(&self) -> i32 { 111 6 112 } 113 114 fn name_to_level(&self, name: &str) -> Option<i32> { 115 match name.to_lowercase().as_str() { 116 "none" => Some(0), 117 "fast" => Some(1), 118 "best" => Some(9), 119 _ => None, 120 } 121 } 122} 123 124#[derive(Debug, Clone, Copy)] 125pub struct CompressionLevel { 126 pub level: i32, 127} 128 129impl Default for CompressionLevel { 130 fn default() -> Self { 131 CompressionLevel { level: 6 } 132 } 133} 134 135impl FromStr for CompressionLevel { 136 type Err = &'static str; 137 138 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> { 139 // Check for an int 140 if let Ok(level) = s.parse::<i32>() { 141 return Ok(CompressionLevel { level }); 142 } 143 144 // Try to parse special names 145 let s = s.to_lowercase(); 146 match s.as_str() { 147 "none" | "fast" | "best" => Ok(CompressionLevel { 148 // We'll use the DefaultCompressionValidator values here 149 // The actual compressor will interpret these values according to its own validator 150 level: DefaultCompressionValidator.name_to_level(&s).unwrap(), 151 }), 152 _ => Err("Invalid compression level"), 153 } 154 } 155} 156 157#[derive(Args, Debug, Default, Clone, Copy)] 158pub struct LevelArgs { 159 /// Level of compression. 160 /// `none`, `fast`, and `best` are mapped to appropriate values for each compressor. 161 #[arg(long, default_value = "fast")] 162 pub level: CompressionLevel, 163} 164 165impl LevelArgs { 166 /// Resolve the user-requested compression level against a codec-specific 167 /// validator, clamping to the validator's range. This is the standard way 168 /// for a backend to turn `--level N` into a concrete integer it can pass 169 /// to the underlying library. 170 pub fn resolve(&self, validator: &impl CompressionLevelValidator) -> i32 { 171 validator.validate_and_clamp_level(self.level.level) 172 } 173} 174 175/// Common interface for all compressor implementations 176pub trait Compressor: Send + Sync { 177 /// Name of this Compressor 178 fn name(&self) -> &str; 179 180 /// Produce an owned copy of this compressor, preserving all configuration 181 /// (compression level, progress args, pipeline chain, etc). `Pipeline` 182 /// uses this to hand owned instances to worker threads without losing 183 /// user-supplied settings. 184 fn clone_boxed(&self) -> Box<dyn Compressor>; 185 186 /// Default extension for this Compressor 187 fn extension(&self) -> &str { 188 self.name() 189 } 190 191 /// Determine if this compressor extracts to a file or directory by default 192 /// FILE compressors (like gzip, bzip2, xz) extract to a single file 193 /// DIRECTORY compressors (like zip, tar) extract to a directory 194 fn default_extracted_target(&self) -> ExtractedTarget { 195 ExtractedTarget::File 196 } 197 198 /// Detect if the input is an archive of this type 199 /// Just checks the extension by default 200 /// Some compressors may overwrite this to do more advanced detection 201 fn is_archive(&self, in_path: &Path) -> bool { 202 if in_path.extension().is_none() { 203 return false; 204 } 205 in_path.extension().unwrap() == self.extension() 206 } 207 208 /// Generate the default name for the compressed file 209 fn default_compressed_filename(&self, in_path: &Path) -> String { 210 let name = in_path 211 .file_name() 212 .and_then(|f| f.to_str()) 213 .unwrap_or("archive"); 214 format!("{name}.{}", self.extension()) 215 } 216 217 /// Generate the default extracted filename 218 fn default_extracted_filename(&self, in_path: &Path) -> String { 219 if self.default_extracted_target() == ExtractedTarget::Directory { 220 return ".".to_string(); 221 } 222 223 // If the file has no extension, return the current directory 224 if let Some(ext) = in_path.extension() { 225 // If the file has the extension for this type, return the filename without the extension 226 if let Some(ext_str) = ext.to_str() 227 && ext_str == self.extension() 228 && let Some(stem) = in_path.file_stem() 229 && let Some(stem_str) = stem.to_str() 230 { 231 return stem_str.to_string(); 232 } 233 } 234 "archive".to_string() 235 } 236 237 fn compress(&self, input: CmprssInput, output: CmprssOutput) -> Result; 238 239 fn extract(&self, input: CmprssInput, output: CmprssOutput) -> Result; 240 241 /// List the contents of the archive to stdout. 242 /// 243 /// The default implementation bails: only container formats — `tar`, 244 /// `zip`, and pipelines whose innermost layer is one of those — can 245 /// meaningfully enumerate their contents. Stream codecs (gzip, xz, …) 246 /// just compress a single byte stream and have nothing to list. 247 fn list(&self, _input: CmprssInput) -> Result { 248 anyhow::bail!( 249 "{} archives cannot be listed; only container formats (tar, zip) support --list", 250 self.name() 251 ) 252 } 253} 254 255impl fmt::Debug for dyn Compressor { 256 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 257 write!(f, "Compressor {{ name: {} }}", self.name()) 258 } 259} 260 261/// Wrapper for Read + Send to allow Debug 262pub struct ReadWrapper(pub Box<dyn Read + Send>); 263 264impl Read for ReadWrapper { 265 fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { 266 self.0.read(buf) 267 } 268} 269 270impl fmt::Debug for ReadWrapper { 271 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 272 write!(f, "ReadWrapper") 273 } 274} 275 276/// Wrapper for Write + Send to allow Debug 277pub struct WriteWrapper(pub Box<dyn Write + Send>); 278 279impl Write for WriteWrapper { 280 fn write(&mut self, buf: &[u8]) -> io::Result<usize> { 281 self.0.write(buf) 282 } 283 284 fn flush(&mut self) -> io::Result<()> { 285 self.0.flush() 286 } 287} 288 289impl fmt::Debug for WriteWrapper { 290 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 291 write!(f, "WriteWrapper") 292 } 293} 294 295/// Defines the possible inputs of a compressor 296#[derive(Debug)] 297pub enum CmprssInput { 298 /// Path(s) to the input files. 299 Path(Vec<PathBuf>), 300 /// Input pipe 301 Pipe(std::io::Stdin), 302 /// In-memory reader (for piping between compressors) 303 Reader(ReadWrapper), 304} 305 306/// Defines the possible outputs of a compressor 307#[derive(Debug)] 308pub enum CmprssOutput { 309 Path(PathBuf), 310 Pipe(std::io::Stdout), 311 /// In-memory writer (for piping between compressors) 312 Writer(WriteWrapper), 313} 314 315#[cfg(test)] 316mod tests { 317 use super::*; 318 use std::path::Path; 319 320 /// A simple implementation of the Compressor trait for testing 321 #[derive(Clone)] 322 struct TestCompressor; 323 324 impl Compressor for TestCompressor { 325 fn name(&self) -> &str { 326 "test" 327 } 328 329 fn clone_boxed(&self) -> Box<dyn Compressor> { 330 Box::new(self.clone()) 331 } 332 333 // We'll use the default implementation for extension() and other methods 334 335 fn compress(&self, _: CmprssInput, _: CmprssOutput) -> Result { 336 Ok(()) 337 } 338 339 fn extract(&self, _: CmprssInput, _: CmprssOutput) -> Result { 340 Ok(()) 341 } 342 } 343 344 /// A compressor that overrides the default extension 345 #[derive(Clone)] 346 struct CustomExtensionCompressor; 347 348 impl Compressor for CustomExtensionCompressor { 349 fn name(&self) -> &str { 350 "custom" 351 } 352 353 fn extension(&self) -> &str { 354 "cst" 355 } 356 357 fn clone_boxed(&self) -> Box<dyn Compressor> { 358 Box::new(self.clone()) 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}