this repo has no description
0
fork

Configure Feed

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

feat: add multi-level compression support

Add MultiLevelCompressor that chains compressors together using
threaded pipelines for formats like tar.gz. Includes PipeReader/
PipeWriter for inter-stage streaming, Reader/Writer variants on
CmprssInput/CmprssOutput, and multi-level filename detection in
get_compressor_from_filename.

+1028 -84
+23 -4
README.md
··· 73 73 cmprss file.txt.gz > file.txt 74 74 ``` 75 75 76 - `cmprss` doesn't yet support multiple levels of archiving, like `.tar.gz`, but they are easy to work with using pipes 76 + ### Multi-level Compression 77 + 78 + `cmprss` supports multi-level archives like `.tar.gz` directly: 79 + 80 + ```bash 81 + # Compress a directory to a tar.gz file in one step 82 + cmprss uncompressed_dir out.tar.gz 83 + 84 + # Extract a tar.gz file to the current directory in one step 85 + cmprss out.tar.gz 86 + ``` 87 + 88 + Any combination of supported formats can be used together: 77 89 78 90 ```bash 79 - cmprss tar uncompressed_dir | cmprss gz > out.tar.gz 80 - cmprss gzip --extract out.tar.gz | cmprss tar -e output_dir 91 + # Create a zip.tar.xz archive (zip -> tar -> xz) 92 + cmprss directory archive.zip.tar.xz 93 + 94 + # Extract a zip.tar.xz archive (xz -> tar -> zip) 95 + cmprss archive.zip.tar.xz output_dir 96 + ``` 97 + 98 + Pipes can still be used if preferred: 81 99 82 - # Or a full roundtrip in one line 100 + ```bash 101 + # A full roundtrip in one line using pipes 83 102 cmprss tar dir | cmprss gz | cmprss gz -e | cmprss tar -e 84 103 ``` 85 104
+23 -19
src/backends/bzip2.rs
··· 10 10 use clap::Args; 11 11 use std::{ 12 12 fs::File, 13 - io::{self, Read, Write}, 13 + io::{self, BufReader, BufWriter, Read, Write}, 14 14 }; 15 15 16 16 /// BZip2-specific compression validator (1-9 range) ··· 98 98 let mut input_stream = match input { 99 99 CmprssInput::Path(paths) => { 100 100 if paths.len() > 1 { 101 - return cmprss_error("only 1 file can be compressed at a time"); 102 - } 103 - let file = Box::new(File::open(paths[0].as_path())?); 104 - // Get the file size for the progress bar 105 - if let Ok(metadata) = file.metadata() { 106 - file_size = Some(metadata.len()); 101 + return Err(io::Error::new( 102 + io::ErrorKind::InvalidInput, 103 + "Multiple input files not supported for bzip2", 104 + )); 107 105 } 108 - file 106 + let path = &paths[0]; 107 + file_size = Some(std::fs::metadata(path)?.len()); 108 + Box::new(BufReader::new(File::open(path)?)) as Box<dyn Read + Send> 109 109 } 110 110 CmprssInput::Pipe(pipe) => Box::new(pipe) as Box<dyn Read + Send>, 111 + CmprssInput::Reader(reader) => reader.0, 111 112 }; 112 113 let output_stream: Box<dyn Write + Send> = match &output { 113 - CmprssOutput::Path(path) => Box::new(File::create(path)?), 114 - CmprssOutput::Pipe(pipe) => Box::new(pipe) as Box<dyn Write + Send>, 114 + CmprssOutput::Path(path) => Box::new(BufWriter::new(File::create(path)?)), 115 + CmprssOutput::Pipe(pipe) => Box::new(pipe), 116 + CmprssOutput::Writer(writer) => panic!("Writer output not supported in this context"), 115 117 }; 116 118 let mut encoder = BzEncoder::new(output_stream, Compression::new(self.level as u32)); 117 119 ··· 134 136 let mut input_stream = match input { 135 137 CmprssInput::Path(paths) => { 136 138 if paths.len() > 1 { 137 - return cmprss_error("only 1 file can be extracted at a time"); 138 - } 139 - let file = Box::new(File::open(paths[0].as_path())?); 140 - // Get the file size for the progress bar 141 - if let Ok(metadata) = file.metadata() { 142 - file_size = Some(metadata.len()); 139 + return Err(io::Error::new( 140 + io::ErrorKind::InvalidInput, 141 + "Multiple input files not supported for bzip2 extraction", 142 + )); 143 143 } 144 - file 144 + let path = &paths[0]; 145 + file_size = Some(std::fs::metadata(path)?.len()); 146 + Box::new(BufReader::new(File::open(path)?)) as Box<dyn Read + Send> 145 147 } 146 148 CmprssInput::Pipe(pipe) => Box::new(pipe) as Box<dyn Read + Send>, 149 + CmprssInput::Reader(reader) => reader.0, 147 150 }; 148 151 let output_stream: Box<dyn Write + Send> = match &output { 149 - CmprssOutput::Path(path) => Box::new(File::create(path)?), 150 - CmprssOutput::Pipe(pipe) => Box::new(pipe) as Box<dyn Write + Send>, 152 + CmprssOutput::Path(path) => Box::new(BufWriter::new(File::create(path)?)), 153 + CmprssOutput::Pipe(pipe) => Box::new(pipe), 154 + CmprssOutput::Writer(_) => panic!("Writer output not supported in this context"), 151 155 }; 152 156 let mut decoder = BzDecoder::new(output_stream); 153 157
+4
src/backends/gzip.rs
··· 94 94 Box::new(BufReader::new(File::open(path)?)) 95 95 } 96 96 CmprssInput::Pipe(stdin) => Box::new(BufReader::new(stdin)), 97 + CmprssInput::Reader(reader) => reader.0, 97 98 }; 98 99 99 100 let output_stream: Box<dyn Write + Send> = match &output { 100 101 CmprssOutput::Path(path) => Box::new(BufWriter::new(File::create(path)?)), 101 102 CmprssOutput::Pipe(stdout) => Box::new(BufWriter::new(stdout)), 103 + CmprssOutput::Writer(_) => panic!("Writer output not supported in this context"), 102 104 }; 103 105 104 106 // Create a gzip encoder with the specified compression level ··· 137 139 Box::new(BufReader::new(File::open(path)?)) 138 140 } 139 141 CmprssInput::Pipe(stdin) => Box::new(BufReader::new(stdin)), 142 + CmprssInput::Reader(reader) => reader.0, 140 143 }; 141 144 142 145 let mut output_stream: Box<dyn Write + Send> = match &output { 143 146 CmprssOutput::Path(path) => Box::new(BufWriter::new(File::create(path)?)), 144 147 CmprssOutput::Pipe(stdout) => Box::new(BufWriter::new(stdout)), 148 + CmprssOutput::Writer(_) => panic!("Writer output not supported in this context"), 145 149 }; 146 150 147 151 let mut decoder = GzDecoder::new(input_stream);
+8 -2
src/backends/lz4.rs
··· 68 68 Box::new(BufReader::new(File::open(path)?)) 69 69 } 70 70 CmprssInput::Pipe(stdin) => Box::new(BufReader::new(stdin)), 71 + CmprssInput::Reader(reader) => reader.0, 71 72 }; 72 73 73 74 let output_stream: Box<dyn Write + Send> = match &output { 74 75 CmprssOutput::Path(path) => Box::new(BufWriter::new(File::create(path)?)), 75 76 CmprssOutput::Pipe(stdout) => Box::new(BufWriter::new(stdout)), 77 + CmprssOutput::Writer(_) => panic!("Writer output not supported in this context"), 76 78 }; 77 79 78 80 // Create a lz4 encoder ··· 102 104 } 103 105 } 104 106 107 + let mut file_size = None; 105 108 let input_stream: Box<dyn Read + Send> = match input { 106 109 CmprssInput::Path(paths) => { 107 110 if paths.len() > 1 { 108 111 return Err(io::Error::new( 109 112 io::ErrorKind::InvalidInput, 110 - "Multiple input files not supported for lz4", 113 + "Multiple input files not supported for lz4 extraction", 111 114 )); 112 115 } 113 116 let path = &paths[0]; 117 + file_size = Some(std::fs::metadata(path)?.len()); 114 118 Box::new(BufReader::new(File::open(path)?)) 115 119 } 116 120 CmprssInput::Pipe(stdin) => Box::new(BufReader::new(stdin)), 121 + CmprssInput::Reader(reader) => reader.0, 117 122 }; 118 123 119 124 // Create a lz4 decoder ··· 122 127 let mut output_stream: Box<dyn Write + Send> = match &output { 123 128 CmprssOutput::Path(path) => Box::new(BufWriter::new(File::create(path)?)), 124 129 CmprssOutput::Pipe(stdout) => Box::new(BufWriter::new(stdout)), 130 + CmprssOutput::Writer(_) => panic!("Writer output not supported in this context"), 125 131 }; 126 132 127 133 // Copy the decoded data to the output with progress reporting ··· 129 135 &mut decoder, 130 136 &mut output_stream, 131 137 self.progress_args.chunk_size.size_in_bytes, 132 - None, 138 + file_size, 133 139 self.progress_args.progress, 134 140 &output, 135 141 )?;
+2
src/backends/mod.rs
··· 1 1 mod bzip2; 2 2 mod gzip; 3 3 mod lz4; 4 + mod multi_level; 4 5 mod tar; 5 6 mod xz; 6 7 mod zip; ··· 9 10 pub use bzip2::{Bzip2, Bzip2Args}; 10 11 pub use gzip::{Gzip, GzipArgs}; 11 12 pub use lz4::{Lz4, Lz4Args}; 13 + pub use multi_level::MultiLevelCompressor; 12 14 pub use tar::{Tar, TarArgs}; 13 15 pub use xz::{Xz, XzArgs}; 14 16 pub use zip::{Zip, ZipArgs};
+397
src/backends/multi_level.rs
··· 1 + use crate::utils::*; 2 + use std::io::{self, Read, Write}; 3 + use std::path::Path; 4 + use std::sync::mpsc::{channel, Receiver, Sender}; 5 + use std::thread; 6 + 7 + /// A compressor that chains multiple compressors together 8 + /// This allows for multi-level compression formats like tar.gz 9 + pub struct MultiLevelCompressor { 10 + // The chain of compressors to apply in order (innermost to outermost) 11 + compressors: Vec<Box<dyn Compressor>>, 12 + } 13 + 14 + impl MultiLevelCompressor { 15 + /// Create a new MultiLevelCompressor with a chain of compressors 16 + pub fn new(compressors: Vec<Box<dyn Compressor>>) -> Self { 17 + MultiLevelCompressor { compressors } 18 + } 19 + 20 + /// Create a new MultiLevelCompressor from compressor type names 21 + pub fn from_names(compressor_names: &[String]) -> io::Result<Self> { 22 + let mut compressors: Vec<Box<dyn Compressor>> = Vec::new(); 23 + 24 + for name in compressor_names { 25 + let compressor: Box<dyn Compressor> = match name.as_str() { 26 + "tar" => Box::new(crate::backends::Tar::default()), 27 + "gzip" | "gz" => Box::new(crate::backends::Gzip::default()), 28 + "xz" => Box::new(crate::backends::Xz::default()), 29 + "bzip2" | "bz2" => Box::new(crate::backends::Bzip2::default()), 30 + "zip" => Box::new(crate::backends::Zip::default()), 31 + "zstd" | "zst" => Box::new(crate::backends::Zstd::default()), 32 + "lz4" => Box::new(crate::backends::Lz4::default()), 33 + _ => { 34 + return Err(io::Error::new( 35 + io::ErrorKind::InvalidInput, 36 + format!("Unknown compressor type: {}", name), 37 + )) 38 + } 39 + }; 40 + compressors.push(compressor); 41 + } 42 + 43 + Ok(Self { compressors }) 44 + } 45 + 46 + /// Get a string representation of the chained format (e.g., "tar.gz") 47 + fn format_chain(&self) -> String { 48 + // Create a format string like "tar.gz" from the chain of compressors 49 + self.compressors 50 + .iter() 51 + .map(|c| c.extension()) 52 + .rev() // Reverse to get innermost first 53 + .collect::<Vec<&str>>() 54 + .join(".") 55 + } 56 + 57 + /// Create a new compressor instance based on its name 58 + fn create_compressor(name: &str) -> io::Result<Box<dyn Compressor>> { 59 + match name { 60 + "tar" => Ok(Box::new(crate::backends::Tar::default())), 61 + "gzip" | "gz" => Ok(Box::new(crate::backends::Gzip::default())), 62 + "xz" => Ok(Box::new(crate::backends::Xz::default())), 63 + "bzip2" | "bz2" => Ok(Box::new(crate::backends::Bzip2::default())), 64 + "zip" => Ok(Box::new(crate::backends::Zip::default())), 65 + "zstd" | "zst" => Ok(Box::new(crate::backends::Zstd::default())), 66 + "lz4" => Ok(Box::new(crate::backends::Lz4::default())), 67 + _ => Err(io::Error::new( 68 + io::ErrorKind::Other, 69 + format!("Unknown compressor type: {}", name), 70 + )), 71 + } 72 + } 73 + } 74 + 75 + /// A reader that reads from a receiver channel 76 + struct PipeReader { 77 + receiver: Receiver<Vec<u8>>, 78 + buffer: Vec<u8>, 79 + position: usize, 80 + eof: bool, 81 + } 82 + 83 + impl PipeReader { 84 + fn new(receiver: Receiver<Vec<u8>>) -> Self { 85 + PipeReader { 86 + receiver, 87 + buffer: Vec::new(), 88 + position: 0, 89 + eof: false, 90 + } 91 + } 92 + } 93 + 94 + impl Read for PipeReader { 95 + fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { 96 + // If we've reached EOF, return 0 to signal that 97 + if self.eof && self.position >= self.buffer.len() { 98 + return Ok(0); 99 + } 100 + 101 + // If we've consumed the current buffer, try to get a new one 102 + if self.position >= self.buffer.len() { 103 + match self.receiver.recv() { 104 + Ok(data) => { 105 + // Empty data signals EOF from the writer 106 + if data.is_empty() { 107 + self.eof = true; 108 + return Ok(0); 109 + } 110 + self.buffer = data; 111 + self.position = 0; 112 + } 113 + Err(_) => { 114 + // Channel closed, this means EOF 115 + self.eof = true; 116 + return Ok(0); 117 + } 118 + } 119 + } 120 + 121 + // Copy data from our buffer to the output buffer 122 + let available = self.buffer.len() - self.position; 123 + let to_copy = available.min(buf.len()); 124 + buf[..to_copy].copy_from_slice(&self.buffer[self.position..self.position + to_copy]); 125 + self.position += to_copy; 126 + Ok(to_copy) 127 + } 128 + } 129 + 130 + /// A writer that writes to a sender channel 131 + struct PipeWriter { 132 + sender: Sender<Vec<u8>>, 133 + buffer_size: usize, 134 + } 135 + 136 + impl PipeWriter { 137 + fn new(sender: Sender<Vec<u8>>, buffer_size: usize) -> Self { 138 + PipeWriter { 139 + sender, 140 + buffer_size, 141 + } 142 + } 143 + } 144 + 145 + impl Write for PipeWriter { 146 + fn write(&mut self, buf: &[u8]) -> io::Result<usize> { 147 + // Split the input into chunks of buffer_size 148 + let mut start = 0; 149 + while start < buf.len() { 150 + let end = (start + self.buffer_size).min(buf.len()); 151 + let chunk = Vec::from(&buf[start..end]); 152 + 153 + // Send the chunk through the channel 154 + if self.sender.send(chunk).is_err() { 155 + // If the receiver is gone, report an error 156 + return Err(io::Error::new( 157 + io::ErrorKind::BrokenPipe, 158 + "Pipe receiver has been closed", 159 + )); 160 + } 161 + start = end; 162 + } 163 + Ok(buf.len()) 164 + } 165 + 166 + fn flush(&mut self) -> io::Result<()> { 167 + // No need to flush, the channel sends immediately 168 + Ok(()) 169 + } 170 + } 171 + 172 + impl Drop for PipeWriter { 173 + fn drop(&mut self) { 174 + // Send an empty buffer to signal EOF 175 + let _ = self.sender.send(Vec::new()); 176 + } 177 + } 178 + 179 + impl Compressor for MultiLevelCompressor { 180 + fn name(&self) -> &str { 181 + // Return the name of the first (outermost) compressor 182 + if let Some(comp) = self.compressors.first() { 183 + comp.name() 184 + } else { 185 + "multi" 186 + } 187 + } 188 + 189 + fn extension(&self) -> &str { 190 + // This is a bit of a hack since we can't return an owned String from this method 191 + // We'll just return the extension of the outermost compressor 192 + if let Some(comp) = self.compressors.first() { 193 + comp.extension() 194 + } else { 195 + "multi" 196 + } 197 + } 198 + 199 + fn default_extracted_target(&self) -> ExtractedTarget { 200 + // The extracted target depends on the innermost compressor 201 + if let Some(comp) = self.compressors.last() { 202 + // The innermost compressor's target is what matters 203 + comp.default_extracted_target() 204 + } else { 205 + // If there are no compressors (shouldn't happen), default to FILE 206 + ExtractedTarget::FILE 207 + } 208 + } 209 + 210 + fn is_archive(&self, in_path: &Path) -> bool { 211 + // Check if the path matches our format chain 212 + if let Some(filename) = in_path.to_str() { 213 + let format_chain = self.format_chain(); 214 + filename.ends_with(&format_chain) 215 + } else { 216 + false 217 + } 218 + } 219 + 220 + fn compress(&self, input: CmprssInput, output: CmprssOutput) -> Result<(), io::Error> { 221 + if self.compressors.is_empty() { 222 + return Err(io::Error::new( 223 + io::ErrorKind::Other, 224 + "No compressors in multi-level chain", 225 + )); 226 + } 227 + 228 + if self.compressors.len() == 1 { 229 + return self.compressors[0].compress(input, output); 230 + } 231 + 232 + let mut op_compressors: Vec<Box<dyn Compressor>> = self 233 + .compressors 234 + .iter() 235 + .map(|c| Self::create_compressor(c.name()).unwrap()) // TODO: Handle error properly 236 + .collect(); 237 + 238 + let mut handles = Vec::new(); 239 + let mut current_thread_input = input; // Consumed by the first (innermost) compressor 240 + let buffer_size = 64 * 1024; 241 + 242 + // Process all but the last (outermost) compressor in separate threads 243 + for i in 0..op_compressors.len() - 1 { 244 + let compressor_for_this_stage = op_compressors.remove(0); 245 + let (sender, receiver) = channel::<Vec<u8>>(); 246 + let pipe_writer = PipeWriter::new(sender, buffer_size); 247 + let input_for_next_stage = 248 + CmprssInput::Reader(ReadWrapper(Box::new(PipeReader::new(receiver)))); 249 + 250 + let actual_input_for_thread = current_thread_input; // Move current input to thread 251 + current_thread_input = input_for_next_stage; // Set up input for the *next* stage or final compressor 252 + 253 + let handle = thread::spawn(move || { 254 + compressor_for_this_stage.compress( 255 + actual_input_for_thread, 256 + CmprssOutput::Writer(WriteWrapper(Box::new(pipe_writer))), 257 + ) 258 + }); 259 + handles.push(handle); 260 + } 261 + 262 + // The last (outermost) compressor runs in the current thread and writes to the final output 263 + let last_compressor = op_compressors.remove(0); 264 + // current_thread_input here is the Reader from the penultimate stage 265 + last_compressor.compress(current_thread_input, output)?; 266 + 267 + for handle in handles { 268 + handle.join().unwrap()?; // TODO: Handle thread errors properly 269 + } 270 + Ok(()) 271 + } 272 + 273 + fn extract(&self, input: CmprssInput, output: CmprssOutput) -> Result<(), io::Error> { 274 + if self.compressors.is_empty() { 275 + return Err(io::Error::new( 276 + io::ErrorKind::Other, 277 + "No compressors in multi-level chain for extraction", 278 + )); 279 + } 280 + 281 + if self.compressors.len() == 1 { 282 + return self.compressors[0].extract(input, output); 283 + } 284 + 285 + let mut op_extractors: Vec<Box<dyn Compressor>> = self 286 + .compressors 287 + .iter() 288 + .rev() // Iterate from Outermost to Innermost 289 + .map(|c| Self::create_compressor(c.name()).unwrap()) // TODO: Handle error properly 290 + .collect(); 291 + 292 + let mut handles = Vec::new(); 293 + let mut current_thread_input = input; // Consumed by the first (outermost) extractor 294 + let buffer_size = 64 * 1024; 295 + 296 + // Process all but the last (innermost) extractor in separate threads. 297 + for i in 0..op_extractors.len() - 1 { 298 + let extractor_for_this_stage = op_extractors.remove(0); 299 + let (sender, receiver) = channel::<Vec<u8>>(); 300 + let pipe_writer = PipeWriter::new(sender, buffer_size); 301 + let intermediate_output_for_thread = 302 + CmprssOutput::Writer(WriteWrapper(Box::new(pipe_writer))); 303 + let input_for_next_stage = 304 + CmprssInput::Reader(ReadWrapper(Box::new(PipeReader::new(receiver)))); 305 + 306 + let actual_input_for_thread = current_thread_input; // Move current input to thread 307 + current_thread_input = input_for_next_stage; // Set up input for the *next* stage or final extractor 308 + 309 + let handle = thread::spawn(move || { 310 + extractor_for_this_stage 311 + .extract(actual_input_for_thread, intermediate_output_for_thread) 312 + }); 313 + handles.push(handle); 314 + } 315 + 316 + // The last (innermost) extractor runs in the current thread and writes to the final output 317 + let last_extractor = op_extractors.remove(0); 318 + // current_thread_input here is the Reader from the penultimate stage 319 + 320 + let final_output = match output { 321 + CmprssOutput::Path(ref p) => { 322 + if last_extractor.default_extracted_target() == ExtractedTarget::DIRECTORY { 323 + if !p.exists() { 324 + std::fs::create_dir_all(p)?; 325 + } 326 + // If it's a directory, the tar extractor (usually innermost) will handle it. 327 + // The path provided should be the target directory. 328 + } 329 + // Always pass the path; the backend decides how to use it. 330 + CmprssOutput::Path(p.clone()) 331 + } 332 + CmprssOutput::Pipe(_) => output, 333 + CmprssOutput::Writer(_) => output, 334 + }; 335 + 336 + last_extractor.extract(current_thread_input, final_output)?; 337 + 338 + for handle in handles { 339 + handle.join().unwrap()?; // TODO: Handle thread errors properly 340 + } 341 + Ok(()) 342 + } 343 + } 344 + 345 + #[cfg(test)] 346 + mod tests { 347 + use super::*; 348 + use std::fs; 349 + use std::io::{Read, Write}; 350 + use tempfile::tempdir; 351 + 352 + #[test] 353 + fn test_multi_level_compression() -> Result<(), io::Error> { 354 + // Create a temporary directory for our test 355 + let temp_dir = tempdir()?; 356 + 357 + // Create a test file 358 + let test_content = "This is a test file for multi-level compression"; 359 + let test_file_path = temp_dir.path().join("test.txt"); 360 + fs::write(&test_file_path, test_content)?; 361 + 362 + // Create a tar.gz compressor (tar first, then gzip) 363 + let compressors: Vec<Box<dyn Compressor>> = vec![ 364 + Box::new(crate::backends::Tar::default()), 365 + Box::new(crate::backends::Gzip::default()), 366 + ]; 367 + let multi_compressor = MultiLevelCompressor::new(compressors); 368 + 369 + // Compress the test file 370 + let archive_path = temp_dir.path().join("test.tar.gz"); 371 + multi_compressor.compress( 372 + CmprssInput::Path(vec![test_file_path.clone()]), 373 + CmprssOutput::Path(archive_path.clone()), 374 + )?; 375 + 376 + // Verify the archive was created 377 + assert!(archive_path.exists()); 378 + 379 + // Extract the archive 380 + let output_dir = temp_dir.path().join("extracted"); 381 + fs::create_dir(&output_dir)?; 382 + multi_compressor.extract( 383 + CmprssInput::Path(vec![archive_path.clone()]), 384 + CmprssOutput::Path(output_dir.clone()), 385 + )?; 386 + 387 + // Verify the file was extracted correctly 388 + let extracted_file = output_dir.join("test.txt"); 389 + assert!(extracted_file.exists()); 390 + 391 + // Verify the content is the same 392 + let extracted_content = fs::read_to_string(extracted_file)?; 393 + assert_eq!(extracted_content, test_content); 394 + 395 + Ok(()) 396 + } 397 + }
+10
src/backends/tar.rs
··· 52 52 io::copy(&mut temp_file, &mut pipe)?; 53 53 Ok(()) 54 54 } 55 + CmprssOutput::Writer(_) => panic!("Writer output not supported in tar compress"), 55 56 } 56 57 } 57 58 ··· 88 89 let mut archive = Archive::new(temp_file); 89 90 archive.unpack(out_dir) 90 91 } 92 + CmprssInput::Reader(reader) => { 93 + let mut archive = Archive::new(reader.0); 94 + archive.unpack(out_dir)?; 95 + Ok(()) 96 + } 91 97 } 92 98 } 93 99 CmprssOutput::Pipe(_) => cmprss_error("tar extraction to stdout is not supported"), 100 + CmprssOutput::Writer(_) => panic!("Writer output not supported in tar extract"), 94 101 } 95 102 } 96 103 } ··· 123 130 io::copy(&mut pipe, &mut temp_file)?; 124 131 temp_file.seek(SeekFrom::Start(0))?; 125 132 archive.append_file("archive", &mut temp_file)?; 133 + } 134 + CmprssInput::Reader(reader) => { 135 + return cmprss_error("Cannot tar a reader input directly"); 126 136 } 127 137 } 128 138 archive.finish()
+21 -17
src/backends/xz.rs
··· 5 5 use clap::Args; 6 6 use std::{ 7 7 fs::File, 8 - io::{self, Read, Write}, 8 + io::{self, BufReader, BufWriter, Read, Write}, 9 9 }; 10 10 use xz2::read::XzDecoder; 11 11 use xz2::write::XzEncoder; ··· 65 65 let mut input_stream = match input { 66 66 CmprssInput::Path(paths) => { 67 67 if paths.len() > 1 { 68 - return cmprss_error("only 1 file can be compressed at a time"); 69 - } 70 - let file = Box::new(File::open(paths[0].as_path())?); 71 - // Get the file size for the progress bar 72 - if let Ok(metadata) = file.metadata() { 73 - file_size = Some(metadata.len()); 68 + return Err(io::Error::new( 69 + io::ErrorKind::InvalidInput, 70 + "Multiple input files not supported for xz", 71 + )); 74 72 } 75 - file 73 + let path = &paths[0]; 74 + file_size = Some(std::fs::metadata(path)?.len()); 75 + Box::new(BufReader::new(File::open(path)?)) as Box<dyn Read + Send> 76 76 } 77 77 CmprssInput::Pipe(pipe) => Box::new(pipe) as Box<dyn Read + Send>, 78 + CmprssInput::Reader(reader) => reader.0, 78 79 }; 79 80 let output_stream: Box<dyn Write + Send> = match &output { 80 - CmprssOutput::Path(path) => Box::new(File::create(path)?), 81 + CmprssOutput::Path(path) => Box::new(BufWriter::new(File::create(path)?)), 81 82 CmprssOutput::Pipe(pipe) => Box::new(pipe) as Box<dyn Write + Send>, 83 + CmprssOutput::Writer(_) => panic!("Writer output not supported in this context"), 82 84 }; 83 85 let mut encoder = XzEncoder::new(output_stream, self.level as u32); 84 86 ··· 100 102 let input_stream: Box<dyn Read + Send> = match input { 101 103 CmprssInput::Path(paths) => { 102 104 if paths.len() > 1 { 103 - return cmprss_error("only 1 file can be extracted at a time"); 104 - } 105 - let file = Box::new(File::open(paths[0].as_path())?); 106 - // Get the file size for the progress bar 107 - if let Ok(metadata) = file.metadata() { 108 - file_size = Some(metadata.len()); 105 + return Err(io::Error::new( 106 + io::ErrorKind::InvalidInput, 107 + "Multiple input files not supported for xz extraction", 108 + )); 109 109 } 110 - file 110 + let path = &paths[0]; 111 + file_size = Some(std::fs::metadata(path)?.len()); 112 + Box::new(BufReader::new(File::open(path)?)) as Box<dyn Read + Send> 111 113 } 112 114 CmprssInput::Pipe(pipe) => Box::new(pipe) as Box<dyn Read + Send>, 115 + CmprssInput::Reader(reader) => reader.0, 113 116 }; 114 117 let mut output_stream: Box<dyn Write + Send> = match &output { 115 - CmprssOutput::Path(path) => Box::new(File::create(path)?), 118 + CmprssOutput::Path(path) => Box::new(BufWriter::new(File::create(path)?)), 116 119 CmprssOutput::Pipe(pipe) => Box::new(pipe) as Box<dyn Write + Send>, 120 + CmprssOutput::Writer(_) => panic!("Writer output not supported in this context"), 117 121 }; 118 122 119 123 // Create an XZ decoder to decompress the input
+12
src/backends/zip.rs
··· 52 52 zip_writer.start_file("archive", options)?; 53 53 io::copy(&mut pipe, &mut zip_writer)?; 54 54 } 55 + CmprssInput::Reader(_) => { 56 + return cmprss_error("Cannot zip a reader input"); 57 + } 55 58 } 56 59 57 60 zip_writer.finish()?; ··· 86 89 // Copy the temporary file to the pipe 87 90 io::copy(&mut temp_file, &mut pipe)?; 88 91 Ok(()) 92 + } 93 + CmprssOutput::Writer(_) => { 94 + panic!("Writer output not supported in zip compress"); 89 95 } 90 96 } 91 97 } ··· 127 133 .extract(out_dir) 128 134 .map_err(|e| io::Error::new(io::ErrorKind::Other, e)) 129 135 } 136 + CmprssInput::Reader(_) => { 137 + return cmprss_error( 138 + "Cannot extract from a reader input for zip (requires seekable input)", 139 + ); 140 + } 130 141 } 131 142 } 132 143 CmprssOutput::Pipe(_) => cmprss_error("zip extraction to stdout is not supported"), 144 + CmprssOutput::Writer(_) => panic!("Writer output not supported in zip extract"), 133 145 } 134 146 } 135 147 }
+11 -5
src/backends/zstd.rs
··· 116 116 Box::new(BufReader::new(File::open(path)?)) 117 117 } 118 118 CmprssInput::Pipe(stdin) => Box::new(BufReader::new(stdin)), 119 + CmprssInput::Reader(reader) => reader.0, 119 120 }; 120 121 121 122 let output_stream: Box<dyn Write + Send> = match &output { 122 123 CmprssOutput::Path(path) => Box::new(BufWriter::new(File::create(path)?)), 123 124 CmprssOutput::Pipe(stdout) => Box::new(BufWriter::new(stdout)), 125 + CmprssOutput::Writer(_) => panic!("Writer output not supported in this context"), 124 126 }; 125 127 126 128 // Create a zstd encoder with the specified compression level ··· 150 152 } 151 153 } 152 154 155 + let mut file_size = None; 153 156 let input_stream: Box<dyn Read + Send> = match input { 154 157 CmprssInput::Path(paths) => { 155 158 if paths.len() > 1 { 156 159 return Err(io::Error::new( 157 160 io::ErrorKind::InvalidInput, 158 - "Multiple input files not supported for zstd", 161 + "Multiple input files not supported for zstd extraction", 159 162 )); 160 163 } 161 164 let path = &paths[0]; 165 + file_size = Some(std::fs::metadata(path)?.len()); 162 166 Box::new(BufReader::new(File::open(path)?)) 163 167 } 164 168 CmprssInput::Pipe(stdin) => Box::new(BufReader::new(stdin)), 169 + CmprssInput::Reader(reader) => reader.0, 165 170 }; 166 171 167 - // Create a zstd decoder 168 - let mut decoder = Decoder::new(input_stream)?; 169 - 170 172 let mut output_stream: Box<dyn Write + Send> = match &output { 171 173 CmprssOutput::Path(path) => Box::new(BufWriter::new(File::create(path)?)), 172 174 CmprssOutput::Pipe(stdout) => Box::new(BufWriter::new(stdout)), 175 + CmprssOutput::Writer(_) => panic!("Writer output not supported in this context"), 173 176 }; 174 177 178 + // Create a zstd decoder 179 + let mut decoder = Decoder::new(input_stream)?; 180 + 175 181 // Copy the decoded data to the output with progress reporting 176 182 copy_with_progress( 177 183 &mut decoder, 178 184 &mut output_stream, 179 185 self.progress_args.chunk_size.size_in_bytes, 180 - None, 186 + file_size, 181 187 self.progress_args.progress, 182 188 &output, 183 189 )?;
+282 -36
src/main.rs
··· 6 6 use backends::*; 7 7 use clap::{Parser, Subcommand}; 8 8 use is_terminal::IsTerminal; 9 + use std::io; 9 10 use std::path::{Path, PathBuf}; 10 11 use std::{io, vec}; 11 12 use utils::*; ··· 53 54 /// This file will be used to generate the output filename 54 55 fn get_input_filename(input: &CmprssInput) -> Result<&Path, io::Error> { 55 56 match input { 56 - CmprssInput::Path(paths) => { 57 - if paths.is_empty() { 58 - return Err(io::Error::new( 59 - io::ErrorKind::Other, 60 - "error: no input specified", 61 - )); 62 - } 63 - Ok(paths.first().unwrap()) 64 - } 57 + CmprssInput::Path(paths) => match paths.first() { 58 + Some(path) => Ok(path), 59 + None => Err(io::Error::new( 60 + io::ErrorKind::Other, 61 + "error: no input specified", 62 + )), 63 + }, 65 64 CmprssInput::Pipe(_) => Ok(Path::new("archive")), 65 + CmprssInput::Reader(_) => Ok(Path::new("piped_data")), 66 66 } 67 67 } 68 68 ··· 84 84 85 85 /// Get a compressor from a filename 86 86 fn get_compressor_from_filename(filename: &Path) -> Option<Box<dyn Compressor>> { 87 - // TODO: Support multi-level files, like tar.gz 88 - let compressors: Vec<Box<dyn Compressor>> = vec![ 87 + // Prioritize checking for multi-level formats first 88 + if let Some(filename_str) = filename.to_str() { 89 + let parts: Vec<&str> = filename_str.split('.').collect(); 90 + // A potential multi-level format like "archive.tar.gz" will have at least 3 parts 91 + if parts.len() >= 3 { 92 + // Get all available single compressors for matching extensions 93 + let single_compressors: Vec<Box<dyn Compressor>> = vec![ 94 + Box::<Tar>::default(), 95 + Box::<Gzip>::default(), 96 + Box::<Xz>::default(), 97 + Box::<Bzip2>::default(), 98 + Box::<Zip>::default(), 99 + Box::<Zstd>::default(), 100 + Box::<Lz4>::default(), 101 + ]; 102 + 103 + // Get extensions in reverse order (from right to left, e.g., "gz", then "tar") 104 + let mut extensions: Vec<String> = Vec::new(); 105 + for i in 1..parts.len() { 106 + // Iterate from the last extension backwards 107 + // Stop before including the base filename part if it's just "filename.gz" (parts.len() would be 2) 108 + // This loop is for parts.len() >= 3, ensuring we look at actual extensions 109 + if parts.len() - i > 0 { 110 + // Ensure we don't go out of bounds for the base filename part 111 + extensions.push(parts[parts.len() - i].to_string()); 112 + } else { 113 + break; // Should not happen if parts.len() >=3 and i starts at 1 114 + } 115 + } 116 + 117 + let mut compressor_types: Vec<String> = Vec::new(); 118 + for ext_part in &extensions { 119 + // e.g., ext_part is "gz", then "tar" 120 + let mut found_match = false; 121 + for sc in &single_compressors { 122 + if sc.extension() == ext_part || sc.name() == ext_part { 123 + compressor_types.push(sc.name().to_string()); 124 + found_match = true; 125 + break; 126 + } 127 + } 128 + if !found_match { 129 + // If any extension part is not recognized, this is not a valid multi-level chain we know. 130 + // Clear types and break, so we can fall back to simple single extension check. 131 + compressor_types.clear(); 132 + break; 133 + } 134 + } 135 + 136 + // If we successfully identified a chain of known compressor types: 137 + // compressor_types would be e.g. ["gzip", "tar"] (outermost to innermost) 138 + if !compressor_types.is_empty() { 139 + // MultiLevelCompressor::from_names expects innermost to outermost. 140 + compressor_types.reverse(); // e.g., ["tar", "gzip"] 141 + return Some(create_multi_level_compressor(&compressor_types)); 142 + } 143 + // If compressor_types is empty here, it means the multi-level parse failed (e.g. "file.foo.bar" with unknown foo/bar) 144 + // or an unknown extension was found in the chain. We'll fall through to single extension check. 145 + } 146 + } 147 + 148 + // Fallback: If not a recognized multi-level format, or if fewer than 3 parts (e.g. "file.gz"), 149 + // try matching a single known compressor extension. 150 + let single_compressors: Vec<Box<dyn Compressor>> = vec![ 89 151 Box::<Tar>::default(), 90 152 Box::<Gzip>::default(), 91 153 Box::<Xz>::default(), ··· 94 156 Box::<Zstd>::default(), 95 157 Box::<Lz4>::default(), 96 158 ]; 97 - compressors.into_iter().find(|c| c.is_archive(filename)) 159 + 160 + // Check if file extension matches any known format 161 + // This is now a fallback. 162 + // Ensure this doesn't misinterpret "foo.tar.gz" as just "gz" if multi-level check failed for some reason 163 + // A more robust check here might be to see if filename *only* ends with .ext and not .something_else.ext 164 + // For now, the standard check is: 165 + if let Some(filename_str) = filename.to_str() { 166 + for sc in single_compressors { 167 + // A simple "ends_with" can be problematic for "file.tar.gz" vs "file.gz" 168 + // We need to be more specific. The extension should be exactly the compressor's extension. 169 + let expected_extension = format!(".{}", sc.extension()); 170 + if filename_str.ends_with(&expected_extension) { 171 + // Further check: ensure it's not something like ".tar.gz" being matched by ".gz" 172 + // if we want to be super sure, but the multi-level check should catch .tar.gz first. 173 + // A simple way: if it ends with ".tar.gz", Gzip (gz) should not match here IF Tar (tar) also exists. 174 + // The current structure relies on multi-level being caught first. 175 + // If multi-level parsing failed, then we check single extensions. 176 + // Example: "archive.gz" -> Gzip 177 + // Example: "archive.tar" -> Tar 178 + // Example: "archive.unknown.gz" -> Multi-level fails, then Gzip matches. 179 + return Some(sc); 180 + } 181 + } 182 + } 183 + None 184 + } 185 + 186 + /// Create a MultiLevelCompressor from a list of compressor types 187 + fn create_multi_level_compressor(compressor_types: &[String]) -> Box<dyn Compressor> { 188 + // Create a MultiLevelCompressor from the list of compressor types 189 + match MultiLevelCompressor::from_names(compressor_types) { 190 + Ok(multi) => Box::new(multi), 191 + Err(_) => { 192 + // Fallback to the first compressor if there's an error 193 + match compressor_types[0].as_str() { 194 + "tar" => Box::<Tar>::default(), 195 + "gzip" | "gz" => Box::<Gzip>::default(), 196 + "xz" => Box::<Xz>::default(), 197 + "bzip2" | "bz2" => Box::<Bzip2>::default(), 198 + "zip" => Box::<Zip>::default(), 199 + "zstd" | "zst" => Box::<Zstd>::default(), 200 + "lz4" => Box::<Lz4>::default(), 201 + _ => Box::<Tar>::default(), // Default to tar if unknown 202 + } 203 + } 204 + } 98 205 } 99 206 100 207 /// Convert an input path into a Path ··· 117 224 if let Some(guessed_compressor) = get_compressor_from_filename(output) { 118 225 return (Some(guessed_compressor), Action::Compress); 119 226 } 227 + 228 + // Check if output is a directory - this is likely an extraction 229 + if output.is_dir() { 230 + // Try to determine compressor from the input file's extension(s) 231 + if let Some(input_path) = input.first() { 232 + if let Some(guessed_compressor) = get_compressor_from_filename(input_path) { 233 + return (Some(guessed_compressor), Action::Extract); 234 + } 235 + } 236 + } 237 + 120 238 // In theory we could be extracting multiple files to a directory 121 239 // We'll fail somewhere else if that's not the case 122 240 return (compressor, Action::Extract); ··· 153 271 (None, Some(e)) => (Some(e), Action::Extract), 154 272 (Some(c), Some(e)) => { 155 273 if c.name() == e.name() { 274 + // Same format for input and output, can't decide 275 + if output.is_dir() { 276 + // If output is a directory, we're probably extracting 277 + return (Some(e), Action::Extract); 278 + } 156 279 return (Some(c), Action::Unknown); 157 280 } 281 + 158 282 // Compare the input and output extensions to see if one has an extra extension 159 283 let input_file = input.file_name().unwrap().to_str().unwrap(); 160 284 let input_ext = input.extension().unwrap_or_default(); ··· 162 286 let output_ext = output.extension().unwrap_or_default(); 163 287 let guessed_output = input_file.to_string() + "." + output_ext.to_str().unwrap(); 164 288 let guessed_input = output_file.to_string() + "." + input_ext.to_str().unwrap(); 289 + 165 290 if guessed_output == output_file { 166 291 (Some(c), Action::Compress) 167 292 } else if guessed_input == input_file { 293 + (Some(e), Action::Extract) 294 + } else if output.is_dir() { 295 + // If output is a directory, we're probably extracting 168 296 (Some(e), Action::Extract) 169 297 } else { 170 298 (None, Action::Unknown) ··· 379 507 match action { 380 508 Action::Compress => { 381 509 // Look at the output name 382 - // TODO: tar.gz ?? 383 510 if let CmprssOutput::Path(path) = &cmprss_output { 384 511 compressor = get_compressor_from_filename(path); 385 512 } ··· 388 515 // Look at the input name 389 516 if let CmprssInput::Path(paths) = &cmprss_input { 390 517 if paths.len() != 1 { 391 - // Can't guess if there are multiple inputs 518 + // When extracting, we expect a single input file 392 519 return Err(io::Error::new( 393 520 io::ErrorKind::Other, 394 - "Can't guess compressor with multiple inputs", 521 + "Expected a single archive to extract", 395 522 )); 396 523 } 397 524 compressor = get_compressor_from_filename(paths.first().unwrap()); 525 + 526 + // If we still couldn't guess the compressor, try harder with multi-level extraction 527 + if compressor.is_none() && paths.len() == 1 { 528 + if let Some(filename_str) = paths.first().unwrap().to_str() { 529 + // Try to parse multi-level formats (e.g., tar.gz) 530 + let parts: Vec<&str> = filename_str.split('.').collect(); 531 + if parts.len() >= 3 { 532 + // Get all available compressors 533 + let compressors: Vec<Box<dyn Compressor>> = vec![ 534 + Box::<Tar>::default(), 535 + Box::<Gzip>::default(), 536 + Box::<Xz>::default(), 537 + Box::<Bzip2>::default(), 538 + Box::<Zip>::default(), 539 + Box::<Zstd>::default(), 540 + Box::<Lz4>::default(), 541 + ]; 542 + 543 + // Get extensions in reverse order (from right to left) 544 + let mut extensions: Vec<String> = Vec::new(); 545 + for i in 1..parts.len() { 546 + extensions.push(parts[parts.len() - i].to_string()); 547 + } 548 + 549 + // Try to find a compressor for each extension 550 + let mut compressor_types: Vec<String> = Vec::new(); 551 + for ext in &extensions { 552 + for compressor in &compressors { 553 + if compressor.extension() == ext || compressor.name() == ext 554 + { 555 + compressor_types.push(compressor.name().to_string()); 556 + break; 557 + } 558 + } 559 + } 560 + 561 + // If we found compressor types, create a MultiLevelCompressor 562 + if !compressor_types.is_empty() { 563 + compressor = 564 + Some(create_multi_level_compressor(&compressor_types)); 565 + } 566 + } 567 + } 568 + } 398 569 } 399 570 } 400 571 Action::Unknown => match (&cmprss_input, &cmprss_output) { 401 - (CmprssInput::Pipe(_), CmprssOutput::Path(path)) => { 402 - if compressor.is_none() { 403 - compressor = get_compressor_from_filename(path); 404 - if compressor.is_some() { 405 - action = Action::Compress; 406 - } else { 407 - return Err(io::Error::new( 408 - io::ErrorKind::Other, 409 - "Can't guess compressor to use", 410 - )); 572 + (CmprssInput::Path(paths), CmprssOutput::Path(path)) => { 573 + // Special case: if output is a directory, assume we're extracting 574 + if path.is_dir() && paths.len() == 1 { 575 + // For extraction to directory, try to determine compressor from input file 576 + compressor = get_compressor_from_filename(paths.first().unwrap()); 577 + action = Action::Extract; 578 + 579 + // If no compressor was found, try harder with multi-level detection 580 + if compressor.is_none() { 581 + if let Some(filename_str) = paths.first().unwrap().to_str() { 582 + // Try to parse multi-level formats (e.g., tar.gz) 583 + let parts: Vec<&str> = filename_str.split('.').collect(); 584 + if parts.len() >= 3 { 585 + // Get all available compressors 586 + let compressors: Vec<Box<dyn Compressor>> = vec![ 587 + Box::<Tar>::default(), 588 + Box::<Gzip>::default(), 589 + Box::<Xz>::default(), 590 + Box::<Bzip2>::default(), 591 + Box::<Zip>::default(), 592 + Box::<Zstd>::default(), 593 + Box::<Lz4>::default(), 594 + ]; 595 + 596 + // Get extensions in reverse order (from right to left) 597 + let mut extensions: Vec<String> = Vec::new(); 598 + for i in 1..parts.len() { 599 + extensions.push(parts[parts.len() - i].to_string()); 600 + } 601 + 602 + // Try to find a compressor for each extension 603 + let mut compressor_types: Vec<String> = Vec::new(); 604 + for ext in &extensions { 605 + for compressor in &compressors { 606 + if compressor.extension() == ext 607 + || compressor.name() == ext 608 + { 609 + compressor_types 610 + .push(compressor.name().to_string()); 611 + break; 612 + } 613 + } 614 + } 615 + 616 + // If we found compressor types, create a MultiLevelCompressor 617 + if !compressor_types.is_empty() { 618 + compressor = 619 + Some(create_multi_level_compressor(&compressor_types)); 620 + } 621 + } 622 + } 623 + 624 + // If we still couldn't determine compressor, fail with a clear message 625 + if compressor.is_none() { 626 + return Err(io::Error::new( 627 + io::ErrorKind::Other, 628 + format!( 629 + "Couldn't determine how to extract {:?}", 630 + paths.first().unwrap() 631 + ), 632 + )); 633 + } 411 634 } 412 - } else if compressor.as_ref().unwrap().name() 413 - == get_compressor_from_filename(path).unwrap().name() 414 - { 415 - action = Action::Compress; 416 635 } else { 417 - action = Action::Extract; 636 + let (guessed_compressor, guessed_action) = 637 + guess_from_filenames(paths, path, compressor); 638 + compressor = guessed_compressor; 639 + action = guessed_action; 418 640 } 419 641 } 420 642 (CmprssInput::Path(paths), CmprssOutput::Pipe(_)) => { ··· 422 644 if paths.len() != 1 { 423 645 return Err(io::Error::new( 424 646 io::ErrorKind::Other, 425 - "Can't guess compressor with multiple inputs", 647 + "Expected a single input file for piping to stdout", 426 648 )); 427 649 } 428 650 compressor = get_compressor_from_filename(paths.first().unwrap()); ··· 444 666 action = Action::Compress; 445 667 } 446 668 } 669 + (CmprssInput::Pipe(_), CmprssOutput::Path(path)) => { 670 + if compressor.is_none() { 671 + compressor = get_compressor_from_filename(path); 672 + if compressor.is_some() { 673 + action = Action::Compress; 674 + } else { 675 + return Err(io::Error::new( 676 + io::ErrorKind::Other, 677 + "Can't guess compressor to use", 678 + )); 679 + } 680 + } else if compressor.as_ref().unwrap().name() 681 + == get_compressor_from_filename(path).unwrap().name() 682 + { 683 + action = Action::Compress; 684 + } else { 685 + action = Action::Extract; 686 + } 687 + } 447 688 (CmprssInput::Pipe(_), CmprssOutput::Pipe(_)) => { 448 689 action = Action::Compress; 449 690 } 450 - (CmprssInput::Path(paths), CmprssOutput::Path(path)) => { 451 - let (guessed_compressor, guessed_action) = 452 - guess_from_filenames(paths, path, compressor); 453 - compressor = guessed_compressor; 454 - action = guessed_action; 691 + // Handle all Writer output cases 692 + (_, CmprssOutput::Writer(_)) => { 693 + // Writer outputs are only supported in multi-level compression 694 + // In main.rs we'll assume compression 695 + action = Action::Compress; 696 + } 697 + // Handle all Reader input cases 698 + (&CmprssInput::Reader(_), _) => { 699 + // For Reader input, we'll assume extraction 700 + action = Action::Extract; 455 701 } 456 702 }, 457 703 }
+40 -1
src/utils.rs
··· 2 2 use std::ffi::OsStr; 3 3 use std::fmt; 4 4 use std::io; 5 + use std::io::{Read, Write}; 5 6 use std::path::{Path, PathBuf}; 6 7 use std::str::FromStr; 7 8 ··· 154 155 155 156 /// Common interface for all compressor implementations 156 157 #[allow(unused_variables)] 157 - pub trait Compressor { 158 + pub trait Compressor: Send + Sync { 158 159 /// Name of this Compressor 159 160 fn name(&self) -> &str; 160 161 ··· 230 231 Err(io::Error::new(io::ErrorKind::Other, message)) 231 232 } 232 233 234 + /// Wrapper for Read + Send to allow Debug 235 + pub struct ReadWrapper(pub Box<dyn Read + Send>); 236 + 237 + impl Read for ReadWrapper { 238 + fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { 239 + self.0.read(buf) 240 + } 241 + } 242 + 243 + impl fmt::Debug for ReadWrapper { 244 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 245 + write!(f, "ReadWrapper") 246 + } 247 + } 248 + 249 + /// Wrapper for Write + Send to allow Debug 250 + pub struct WriteWrapper(pub Box<dyn Write + Send>); 251 + 252 + impl Write for WriteWrapper { 253 + fn write(&mut self, buf: &[u8]) -> io::Result<usize> { 254 + self.0.write(buf) 255 + } 256 + 257 + fn flush(&mut self) -> io::Result<()> { 258 + self.0.flush() 259 + } 260 + } 261 + 262 + impl fmt::Debug for WriteWrapper { 263 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 264 + write!(f, "WriteWrapper") 265 + } 266 + } 267 + 233 268 /// Defines the possible inputs of a compressor 234 269 #[derive(Debug)] 235 270 pub enum CmprssInput { ··· 237 272 Path(Vec<PathBuf>), 238 273 /// Input pipe 239 274 Pipe(std::io::Stdin), 275 + /// In-memory reader (for piping between compressors) 276 + Reader(ReadWrapper), 240 277 } 241 278 242 279 /// Defines the possible outputs of a compressor ··· 244 281 pub enum CmprssOutput { 245 282 Path(PathBuf), 246 283 Pipe(std::io::Stdout), 284 + /// In-memory writer (for piping between compressors) 285 + Writer(WriteWrapper), 247 286 } 248 287 249 288 #[cfg(test)]
+195
tests/multi_level.rs
··· 1 + extern crate assert_cmd; 2 + extern crate assert_fs; 3 + extern crate predicates; 4 + 5 + use assert_cmd::prelude::*; 6 + use assert_fs::prelude::*; 7 + use assert_fs::TempDir; 8 + use predicates::prelude::*; 9 + use std::process::Command; 10 + 11 + // Test the most common multi-level compression case: tar.gz 12 + #[test] 13 + fn test_tar_gz_manual_roundtrip() -> Result<(), Box<dyn std::error::Error>> { 14 + let temp_dir = TempDir::new()?; 15 + let file = temp_dir.child("test.txt"); 16 + file.write_str("test content")?; 17 + 18 + // Step 1: Create a tar file 19 + let tar_file = temp_dir.child("test.tar"); 20 + Command::cargo_bin("cmprss")? 21 + .arg("tar") 22 + .arg(file.path()) 23 + .arg(tar_file.path()) 24 + .assert() 25 + .success(); 26 + 27 + // Step 2: Compress the tar file with gzip 28 + let tar_gz_file = temp_dir.child("test.tar.gz"); 29 + Command::cargo_bin("cmprss")? 30 + .arg("gzip") 31 + .arg(tar_file.path()) 32 + .arg(tar_gz_file.path()) 33 + .assert() 34 + .success(); 35 + 36 + // Step 3: Extract the gzip layer 37 + let extract_tar = temp_dir.child("extracted.tar"); 38 + Command::cargo_bin("cmprss")? 39 + .arg("gzip") 40 + .arg("--extract") 41 + .arg(tar_gz_file.path()) 42 + .arg(extract_tar.path()) 43 + .assert() 44 + .success(); 45 + 46 + // Step 4: Extract the tar layer to a directory 47 + let extract_dir = temp_dir.child("extracted"); 48 + extract_dir.create_dir_all()?; 49 + Command::cargo_bin("cmprss")? 50 + .arg("tar") 51 + .arg("--extract") 52 + .arg(extract_tar.path()) 53 + .arg(extract_dir.path()) 54 + .assert() 55 + .success(); 56 + 57 + // Verify the extracted content 58 + let extracted_file = extract_dir.child("test.txt"); 59 + extracted_file.assert(predicate::path::exists()); 60 + extracted_file.assert(predicate::str::contains("test content")); 61 + 62 + Ok(()) 63 + } 64 + 65 + // 66 + // Test the multi-level compression using tar.gz format 67 + // 68 + #[test] 69 + fn test_tar_gz_compress() -> Result<(), Box<dyn std::error::Error>> { 70 + let temp_dir = TempDir::new()?; 71 + 72 + // Create a file structure for testing 73 + let source_dir = temp_dir.child("source"); 74 + source_dir.create_dir_all()?; 75 + 76 + let test_file = source_dir.child("test_file.txt"); 77 + test_file.write_str("test content for tar.gz compression")?; 78 + 79 + // Create a tar.gz archive directly in one step 80 + let archive = temp_dir.child("direct.tar.gz"); 81 + Command::cargo_bin("cmprss")? 82 + .arg("--compress") // explicitly specify compression 83 + .arg(source_dir.path()) 84 + .arg(archive.path()) 85 + .assert() 86 + .success(); 87 + 88 + // Verify the archive was created 89 + archive.assert(predicate::path::exists()); 90 + 91 + Ok(()) 92 + } 93 + 94 + // 95 + // Test the multi-level extraction using tar.gz format 96 + // 97 + #[test] 98 + fn test_tar_gz_extract() -> Result<(), Box<dyn std::error::Error>> { 99 + let temp_dir = TempDir::new()?; 100 + 101 + // Create a file structure for testing 102 + let source_dir = temp_dir.child("source"); 103 + source_dir.create_dir_all()?; 104 + 105 + let test_file = source_dir.child("test_file.txt"); 106 + test_file.write_str("test content for tar.gz extraction")?; 107 + 108 + // Create a tar file first 109 + let tar_file = temp_dir.child("archive.tar"); 110 + Command::cargo_bin("cmprss")? 111 + .arg("tar") 112 + .arg(source_dir.path()) 113 + .arg(tar_file.path()) 114 + .assert() 115 + .success(); 116 + 117 + // Compress the tar with gzip 118 + let tar_gz_file = temp_dir.child("archive.tar.gz"); 119 + Command::cargo_bin("cmprss")? 120 + .arg("gzip") 121 + .arg(tar_file.path()) 122 + .arg(tar_gz_file.path()) 123 + .assert() 124 + .success(); 125 + 126 + // Create an extraction directory 127 + let extract_dir = temp_dir.child("extract"); 128 + extract_dir.create_dir_all()?; 129 + 130 + // Extract the tar.gz archive 131 + Command::cargo_bin("cmprss")? 132 + .arg("--extract") 133 + .arg(tar_gz_file.path()) 134 + .arg(extract_dir.path()) 135 + .assert() 136 + .success(); 137 + 138 + // Verify the file was extracted correctly 139 + let extracted_file = extract_dir.child("test_file.txt"); 140 + extracted_file.assert(predicate::path::exists()); 141 + extracted_file.assert(predicate::str::contains( 142 + "test content for tar.gz extraction", 143 + )); 144 + 145 + Ok(()) 146 + } 147 + 148 + // 149 + // Test the multi-level extraction using tar.gz format with explicit commands 150 + // 151 + #[test] 152 + fn test_tar_gz_explicit_then_extract() -> Result<(), Box<dyn std::error::Error>> { 153 + let temp_dir = TempDir::new()?; 154 + 155 + // Create a simple test file 156 + let test_file = temp_dir.child("test.txt"); 157 + test_file.write_str("test content for tar.gz")?; 158 + 159 + // Create a tar archive first (explicit command) 160 + let tar_file = temp_dir.child("test.tar"); 161 + Command::cargo_bin("cmprss")? 162 + .arg("tar") 163 + .arg(test_file.path()) 164 + .arg(tar_file.path()) 165 + .assert() 166 + .success(); 167 + 168 + // Compress the tar with gzip (explicit command) 169 + let tar_gz_file = temp_dir.child("test.tar.gz"); 170 + Command::cargo_bin("cmprss")? 171 + .arg("gzip") 172 + .arg(tar_file.path()) 173 + .arg(tar_gz_file.path()) 174 + .assert() 175 + .success(); 176 + 177 + // Create an extraction directory 178 + let extract_dir = temp_dir.child("extract"); 179 + extract_dir.create_dir_all()?; 180 + 181 + // Extract using the tar.gz auto-detection 182 + Command::cargo_bin("cmprss")? 183 + .arg("-e") // Use short form for extract 184 + .arg(tar_gz_file.path()) 185 + .arg(extract_dir.path()) 186 + .assert() 187 + .success(); 188 + 189 + // Verify the file was extracted correctly 190 + let extracted_file = extract_dir.child("test.txt"); 191 + extracted_file.assert(predicate::path::exists()); 192 + extracted_file.assert(predicate::str::contains("test content for tar.gz")); 193 + 194 + Ok(()) 195 + }