this repo has no description
0
fork

Configure Feed

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

at f7bbb7638e8ed85d5c92d23cfdcf38ed665b3b21 473 lines 14 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 69#[allow(dead_code)] 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 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 177#[allow(unused_variables)] 178pub trait Compressor: Send + Sync { 179 /// Name of this Compressor 180 fn name(&self) -> &str; 181 182 /// Default extension for this Compressor 183 fn extension(&self) -> &str { 184 self.name() 185 } 186 187 /// Determine if this compressor extracts to a file or directory by default 188 /// FILE compressors (like gzip, bzip2, xz) extract to a single file 189 /// DIRECTORY compressors (like zip, tar) extract to a directory 190 fn default_extracted_target(&self) -> ExtractedTarget { 191 ExtractedTarget::File 192 } 193 194 /// Detect if the input is an archive of this type 195 /// Just checks the extension by default 196 /// Some compressors may overwrite this to do more advanced detection 197 fn is_archive(&self, in_path: &Path) -> bool { 198 if in_path.extension().is_none() { 199 return false; 200 } 201 in_path.extension().unwrap() == self.extension() 202 } 203 204 /// Generate the default name for the compressed file 205 fn default_compressed_filename(&self, in_path: &Path) -> String { 206 format!( 207 "{}.{}", 208 in_path 209 .file_name() 210 .unwrap_or_else(|| OsStr::new("archive")) 211 .to_str() 212 .unwrap(), 213 self.extension() 214 ) 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 struct TestCompressor; 322 323 impl Compressor for TestCompressor { 324 fn name(&self) -> &str { 325 "test" 326 } 327 328 // We'll use the default implementation for extension() and other methods 329 330 fn compress(&self, _: CmprssInput, _: CmprssOutput) -> Result { 331 Ok(()) 332 } 333 334 fn extract(&self, _: CmprssInput, _: CmprssOutput) -> Result { 335 Ok(()) 336 } 337 } 338 339 /// A compressor that overrides the default extension 340 struct CustomExtensionCompressor; 341 342 impl Compressor for CustomExtensionCompressor { 343 fn name(&self) -> &str { 344 "custom" 345 } 346 347 fn extension(&self) -> &str { 348 "cst" 349 } 350 351 fn compress(&self, _: CmprssInput, _: CmprssOutput) -> Result { 352 Ok(()) 353 } 354 355 fn extract(&self, _: CmprssInput, _: CmprssOutput) -> Result { 356 Ok(()) 357 } 358 } 359 360 #[test] 361 fn test_default_name_extension() { 362 let compressor = TestCompressor; 363 assert_eq!(compressor.name(), "test"); 364 assert_eq!(compressor.extension(), "test"); 365 } 366 367 #[test] 368 fn test_custom_extension() { 369 let compressor = CustomExtensionCompressor; 370 assert_eq!(compressor.name(), "custom"); 371 assert_eq!(compressor.extension(), "cst"); 372 } 373 374 #[test] 375 fn test_is_archive_detection() { 376 use tempfile::tempdir; 377 378 let compressor = TestCompressor; 379 let temp_dir = tempdir().expect("Failed to create temp dir"); 380 381 // Test with matching extension 382 let archive_path = temp_dir.path().join("archive.test"); 383 std::fs::File::create(&archive_path).expect("Failed to create test file"); 384 assert!(compressor.is_archive(&archive_path)); 385 386 // Test with non-matching extension 387 let non_archive_path = temp_dir.path().join("archive.txt"); 388 std::fs::File::create(&non_archive_path).expect("Failed to create test file"); 389 assert!(!compressor.is_archive(&non_archive_path)); 390 391 // Test with no extension 392 let no_ext_path = temp_dir.path().join("archive"); 393 std::fs::File::create(&no_ext_path).expect("Failed to create test file"); 394 assert!(!compressor.is_archive(&no_ext_path)); 395 } 396 397 #[test] 398 fn test_default_compressed_filename() { 399 let compressor = TestCompressor; 400 401 // Test with normal filename 402 let path = Path::new("file.txt"); 403 assert_eq!( 404 compressor.default_compressed_filename(path), 405 "file.txt.test" 406 ); 407 408 // Test with no extension 409 let path = Path::new("file"); 410 assert_eq!(compressor.default_compressed_filename(path), "file.test"); 411 } 412 413 #[test] 414 fn test_default_extracted_filename() { 415 let compressor = TestCompressor; 416 417 // Test with matching extension 418 let path = Path::new("archive.test"); 419 assert_eq!(compressor.default_extracted_filename(path), "archive"); 420 421 // Test with non-matching extension 422 let path = Path::new("archive.txt"); 423 assert_eq!(compressor.default_extracted_filename(path), "archive"); 424 425 // Test with no extension 426 let path = Path::new("archive"); 427 assert_eq!(compressor.default_extracted_filename(path), "archive"); 428 } 429 430 #[test] 431 fn test_compression_level_parsing() { 432 // Test numeric levels 433 assert_eq!(CompressionLevel::from_str("1").unwrap().level, 1); 434 assert_eq!(CompressionLevel::from_str("9").unwrap().level, 9); 435 436 // Test named levels 437 let validator = DefaultCompressionValidator; 438 assert_eq!( 439 CompressionLevel::from_str("fast").unwrap().level, 440 validator.name_to_level("fast").unwrap() 441 ); 442 assert_eq!( 443 CompressionLevel::from_str("best").unwrap().level, 444 validator.name_to_level("best").unwrap() 445 ); 446 447 // Test invalid values 448 assert!(CompressionLevel::from_str("invalid").is_err()); 449 } 450 451 #[test] 452 fn test_compression_level_defaults() { 453 let default_level = CompressionLevel::default(); 454 let validator = DefaultCompressionValidator; 455 assert_eq!(default_level.level, validator.default_level()); 456 } 457 458 #[test] 459 fn test_default_compression_validator() { 460 let validator = DefaultCompressionValidator; 461 462 use crate::test_utils::test_compression_validator_helper; 463 test_compression_validator_helper( 464 &validator, 465 0, // min_level 466 9, // max_level 467 6, // default_level 468 Some(1), // fast_name_level 469 Some(9), // best_name_level 470 Some(0), // none_name_level 471 ); 472 } 473}