this repo has no description
0
fork

Configure Feed

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

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