···75757676### Multi-level Compression
77777878-`cmprss` supports multi-level archives like `.tar.gz` directly:
7878+`cmprss` supports multi-level archives like `.tar.gz`, `.tar.xz`, or `.zstd.bz2` directly:
79798080```bash
8181-# Compress a directory to a tar.gz file in one step
8282-cmprss uncompressed_dir out.tar.gz
8181+# Compress a directory to a tar.gz file
8282+cmprss directory out.tar.gz
83838484-# Extract a tar.gz file to the current directory in one step
8585-cmprss out.tar.gz
8686-```
8484+# Extract a tar.xz file to a directory
8585+cmprss --extract archive.tar.xz output_dir
87868888-Any combination of supported formats can be used together:
8787+# Gzip an existing tar archive
8888+cmprss archive.tar archive.tar.gz
89899090-```bash
9191-# Create a zip.tar.xz archive (zip -> tar -> xz)
9292-cmprss directory archive.zip.tar.xz
9393-9494-# Extract a zip.tar.xz archive (xz -> tar -> zip)
9595-cmprss archive.zip.tar.xz output_dir
9090+# Extract just the xz layer
9191+cmprss archive.tar.xz archive.tar
9692```
97939894Pipes can still be used if preferred:
999510096```bash
101101-# A full roundtrip in one line using pipes
10297cmprss tar dir | cmprss gz | cmprss gz -e | cmprss tar -e
10398```
10499
+39-33
src/backends/bzip2.rs
···11use crate::{
22 progress::{copy_with_progress, ProgressArgs},
33 utils::{
44- cmprss_error, CmprssInput, CmprssOutput, CommonArgs, CompressionLevelValidator, Compressor,
44+ CmprssInput, CmprssOutput, CommonArgs, CompressionLevelValidator, Compressor,
55 ExtractedTarget, LevelArgs,
66 },
77};
···110110 CmprssInput::Pipe(pipe) => Box::new(pipe) as Box<dyn Read + Send>,
111111 CmprssInput::Reader(reader) => reader.0,
112112 };
113113- let output_stream: Box<dyn Write + Send> = match &output {
114114- CmprssOutput::Path(path) => Box::new(BufWriter::new(File::create(path)?)),
115115- CmprssOutput::Pipe(pipe) => Box::new(pipe),
116116- CmprssOutput::Writer(writer) => panic!("Writer output not supported in this context"),
117117- };
118118- let mut encoder = BzEncoder::new(output_stream, Compression::new(self.level as u32));
119119-120120- // Use the custom output function to handle progress bar updates
121121- copy_with_progress(
122122- &mut input_stream,
123123- &mut encoder,
124124- self.progress_args.chunk_size.size_in_bytes,
125125- file_size,
126126- self.progress_args.progress,
127127- &output,
128128- )?;
113113+ if let CmprssOutput::Writer(writer) = output {
114114+ let mut encoder = BzEncoder::new(writer, Compression::new(self.level as u32));
115115+ io::copy(&mut input_stream, &mut encoder)?;
116116+ } else {
117117+ let output_stream: Box<dyn Write + Send> = match &output {
118118+ CmprssOutput::Path(path) => Box::new(BufWriter::new(File::create(path)?)),
119119+ CmprssOutput::Pipe(pipe) => Box::new(pipe),
120120+ CmprssOutput::Writer(_) => unreachable!(),
121121+ };
122122+ let mut encoder = BzEncoder::new(output_stream, Compression::new(self.level as u32));
123123+ copy_with_progress(
124124+ &mut input_stream,
125125+ &mut encoder,
126126+ self.progress_args.chunk_size.size_in_bytes,
127127+ file_size,
128128+ self.progress_args.progress,
129129+ &output,
130130+ )?;
131131+ }
129132130133 Ok(())
131134 }
···148151 CmprssInput::Pipe(pipe) => Box::new(pipe) as Box<dyn Read + Send>,
149152 CmprssInput::Reader(reader) => reader.0,
150153 };
151151- let output_stream: Box<dyn Write + Send> = match &output {
152152- CmprssOutput::Path(path) => Box::new(BufWriter::new(File::create(path)?)),
153153- CmprssOutput::Pipe(pipe) => Box::new(pipe),
154154- CmprssOutput::Writer(_) => panic!("Writer output not supported in this context"),
155155- };
156156- let mut decoder = BzDecoder::new(output_stream);
157157-158158- // Use the custom output function to handle progress bar updates
159159- copy_with_progress(
160160- &mut input_stream,
161161- &mut decoder,
162162- self.progress_args.chunk_size.size_in_bytes,
163163- file_size,
164164- self.progress_args.progress,
165165- &output,
166166- )?;
154154+ if let CmprssOutput::Writer(writer) = output {
155155+ let mut decoder = BzDecoder::new(writer);
156156+ io::copy(&mut input_stream, &mut decoder)?;
157157+ } else {
158158+ let output_stream: Box<dyn Write + Send> = match &output {
159159+ CmprssOutput::Path(path) => Box::new(BufWriter::new(File::create(path)?)),
160160+ CmprssOutput::Pipe(pipe) => Box::new(pipe),
161161+ CmprssOutput::Writer(_) => unreachable!(),
162162+ };
163163+ let mut decoder = BzDecoder::new(output_stream);
164164+ copy_with_progress(
165165+ &mut input_stream,
166166+ &mut decoder,
167167+ self.progress_args.chunk_size.size_in_bytes,
168168+ file_size,
169169+ self.progress_args.progress,
170170+ &output,
171171+ )?;
172172+ }
167173168174 Ok(())
169175 }
+42-38
src/backends/gzip.rs
···9797 CmprssInput::Reader(reader) => reader.0,
9898 };
9999100100- let output_stream: Box<dyn Write + Send> = match &output {
101101- CmprssOutput::Path(path) => Box::new(BufWriter::new(File::create(path)?)),
102102- CmprssOutput::Pipe(stdout) => Box::new(BufWriter::new(stdout)),
103103- CmprssOutput::Writer(_) => panic!("Writer output not supported in this context"),
104104- };
105105-106106- // Create a gzip encoder with the specified compression level
107107- let mut encoder = GzEncoder::new(
108108- output_stream,
109109- Compression::new(self.compression_level as u32),
110110- );
111111-112112- // Use the custom output function to handle progress bar updates with CountingWriter
113113- copy_with_progress(
114114- &mut input_stream,
115115- &mut encoder,
116116- self.progress_args.chunk_size.size_in_bytes,
117117- file_size,
118118- self.progress_args.progress,
119119- &output,
120120- )?;
121121-122122- encoder.finish()?;
100100+ if let CmprssOutput::Writer(writer) = output {
101101+ let mut encoder =
102102+ GzEncoder::new(writer, Compression::new(self.compression_level as u32));
103103+ io::copy(&mut input_stream, &mut encoder)?;
104104+ encoder.finish()?;
105105+ } else {
106106+ let output_stream: Box<dyn Write + Send> = match &output {
107107+ CmprssOutput::Path(path) => Box::new(BufWriter::new(File::create(path)?)),
108108+ CmprssOutput::Pipe(stdout) => Box::new(BufWriter::new(stdout)),
109109+ CmprssOutput::Writer(_) => unreachable!(),
110110+ };
111111+ let mut encoder = GzEncoder::new(
112112+ output_stream,
113113+ Compression::new(self.compression_level as u32),
114114+ );
115115+ copy_with_progress(
116116+ &mut input_stream,
117117+ &mut encoder,
118118+ self.progress_args.chunk_size.size_in_bytes,
119119+ file_size,
120120+ self.progress_args.progress,
121121+ &output,
122122+ )?;
123123+ encoder.finish()?;
124124+ }
123125 Ok(())
124126 }
125127···142144 CmprssInput::Reader(reader) => reader.0,
143145 };
144146145145- let mut output_stream: Box<dyn Write + Send> = match &output {
146146- CmprssOutput::Path(path) => Box::new(BufWriter::new(File::create(path)?)),
147147- CmprssOutput::Pipe(stdout) => Box::new(BufWriter::new(stdout)),
148148- CmprssOutput::Writer(_) => panic!("Writer output not supported in this context"),
149149- };
150150-151147 let mut decoder = GzDecoder::new(input_stream);
152148153153- // Use the utility function to handle progress bar updates
154154- copy_with_progress(
155155- &mut decoder,
156156- &mut output_stream,
157157- self.progress_args.chunk_size.size_in_bytes,
158158- file_size,
159159- self.progress_args.progress,
160160- &output,
161161- )?;
149149+ if let CmprssOutput::Writer(mut writer) = output {
150150+ io::copy(&mut decoder, &mut writer)?;
151151+ } else {
152152+ let mut output_stream: Box<dyn Write + Send> = match &output {
153153+ CmprssOutput::Path(path) => Box::new(BufWriter::new(File::create(path)?)),
154154+ CmprssOutput::Pipe(stdout) => Box::new(BufWriter::new(stdout)),
155155+ CmprssOutput::Writer(_) => unreachable!(),
156156+ };
157157+ copy_with_progress(
158158+ &mut decoder,
159159+ &mut output_stream,
160160+ self.progress_args.chunk_size.size_in_bytes,
161161+ file_size,
162162+ self.progress_args.progress,
163163+ &output,
164164+ )?;
165165+ }
162166163167 Ok(())
164168 }
+38-36
src/backends/lz4.rs
···7171 CmprssInput::Reader(reader) => reader.0,
7272 };
73737474- let output_stream: Box<dyn Write + Send> = match &output {
7575- CmprssOutput::Path(path) => Box::new(BufWriter::new(File::create(path)?)),
7676- CmprssOutput::Pipe(stdout) => Box::new(BufWriter::new(stdout)),
7777- CmprssOutput::Writer(_) => panic!("Writer output not supported in this context"),
7878- };
7979-8080- // Create a lz4 encoder
8181- let mut encoder = FrameEncoder::new(output_stream);
8282-8383- // Copy the input to the encoder with progress reporting
8484- copy_with_progress(
8585- &mut input_stream,
8686- &mut encoder,
8787- self.progress_args.chunk_size.size_in_bytes,
8888- file_size,
8989- self.progress_args.progress,
9090- &output,
9191- )?;
9292-9393- // Finish the encoder to ensure all data is written
9494- encoder.finish()?;
7474+ if let CmprssOutput::Writer(writer) = output {
7575+ let mut encoder = FrameEncoder::new(writer);
7676+ io::copy(&mut input_stream, &mut encoder)?;
7777+ encoder.finish()?;
7878+ } else {
7979+ let output_stream: Box<dyn Write + Send> = match &output {
8080+ CmprssOutput::Path(path) => Box::new(BufWriter::new(File::create(path)?)),
8181+ CmprssOutput::Pipe(stdout) => Box::new(BufWriter::new(stdout)),
8282+ CmprssOutput::Writer(_) => unreachable!(),
8383+ };
8484+ let mut encoder = FrameEncoder::new(output_stream);
8585+ copy_with_progress(
8686+ &mut input_stream,
8787+ &mut encoder,
8888+ self.progress_args.chunk_size.size_in_bytes,
8989+ file_size,
9090+ self.progress_args.progress,
9191+ &output,
9292+ )?;
9393+ encoder.finish()?;
9494+ }
95959696 Ok(())
9797 }
···124124 // Create a lz4 decoder
125125 let mut decoder = FrameDecoder::new(input_stream);
126126127127- let mut output_stream: Box<dyn Write + Send> = match &output {
128128- CmprssOutput::Path(path) => Box::new(BufWriter::new(File::create(path)?)),
129129- CmprssOutput::Pipe(stdout) => Box::new(BufWriter::new(stdout)),
130130- CmprssOutput::Writer(_) => panic!("Writer output not supported in this context"),
131131- };
132132-133133- // Copy the decoded data to the output with progress reporting
134134- copy_with_progress(
135135- &mut decoder,
136136- &mut output_stream,
137137- self.progress_args.chunk_size.size_in_bytes,
138138- file_size,
139139- self.progress_args.progress,
140140- &output,
141141- )?;
127127+ if let CmprssOutput::Writer(mut writer) = output {
128128+ io::copy(&mut decoder, &mut writer)?;
129129+ } else {
130130+ let mut output_stream: Box<dyn Write + Send> = match &output {
131131+ CmprssOutput::Path(path) => Box::new(BufWriter::new(File::create(path)?)),
132132+ CmprssOutput::Pipe(stdout) => Box::new(BufWriter::new(stdout)),
133133+ CmprssOutput::Writer(_) => unreachable!(),
134134+ };
135135+ copy_with_progress(
136136+ &mut decoder,
137137+ &mut output_stream,
138138+ self.progress_args.chunk_size.size_in_bytes,
139139+ file_size,
140140+ self.progress_args.progress,
141141+ &output,
142142+ )?;
143143+ }
142144143145 Ok(())
144146 }
+57-50
src/backends/multi_level.rs
···19192020 /// Create a new MultiLevelCompressor from compressor type names
2121 pub fn from_names(compressor_names: &[String]) -> io::Result<Self> {
2222- let mut compressors: Vec<Box<dyn Compressor>> = Vec::new();
2323-2424- for name in compressor_names {
2525- let compressor: Box<dyn Compressor> = match name.as_str() {
2626- "tar" => Box::new(crate::backends::Tar::default()),
2727- "gzip" | "gz" => Box::new(crate::backends::Gzip::default()),
2828- "xz" => Box::new(crate::backends::Xz::default()),
2929- "bzip2" | "bz2" => Box::new(crate::backends::Bzip2::default()),
3030- "zip" => Box::new(crate::backends::Zip::default()),
3131- "zstd" | "zst" => Box::new(crate::backends::Zstd::default()),
3232- "lz4" => Box::new(crate::backends::Lz4::default()),
3333- _ => {
3434- return Err(io::Error::new(
3535- io::ErrorKind::InvalidInput,
3636- format!("Unknown compressor type: {}", name),
3737- ))
3838- }
3939- };
4040- compressors.push(compressor);
4141- }
4242-2222+ let compressors = compressor_names
2323+ .iter()
2424+ .map(|name| Self::create_compressor(name))
2525+ .collect::<io::Result<Vec<_>>>()?;
4326 Ok(Self { compressors })
4427 }
45284629 /// Get a string representation of the chained format (e.g., "tar.gz")
4730 fn format_chain(&self) -> String {
4848- // Create a format string like "tar.gz" from the chain of compressors
4931 self.compressors
5032 .iter()
5133 .map(|c| c.extension())
5252- .rev() // Reverse to get innermost first
5334 .collect::<Vec<&str>>()
5435 .join(".")
5536 }
···6546 "zstd" | "zst" => Ok(Box::new(crate::backends::Zstd::default())),
6647 "lz4" => Ok(Box::new(crate::backends::Lz4::default())),
6748 _ => Err(io::Error::new(
6868- io::ErrorKind::Other,
4949+ io::ErrorKind::InvalidInput,
6950 format!("Unknown compressor type: {}", name),
7051 )),
7152 }
···178159179160impl Compressor for MultiLevelCompressor {
180161 fn name(&self) -> &str {
181181- // Return the name of the first (outermost) compressor
182182- if let Some(comp) = self.compressors.first() {
162162+ if let Some(comp) = self.compressors.last() {
183163 comp.name()
184164 } else {
185165 "multi"
···187167 }
188168189169 fn extension(&self) -> &str {
190190- // This is a bit of a hack since we can't return an owned String from this method
191191- // We'll just return the extension of the outermost compressor
192192- if let Some(comp) = self.compressors.first() {
170170+ if let Some(comp) = self.compressors.last() {
193171 comp.extension()
194172 } else {
195173 "multi"
···197175 }
198176199177 fn default_extracted_target(&self) -> ExtractedTarget {
200200- // The extracted target depends on the innermost compressor
201201- if let Some(comp) = self.compressors.last() {
202202- // The innermost compressor's target is what matters
178178+ // After full extraction, the result is what the innermost compressor produces
179179+ if let Some(comp) = self.compressors.first() {
203180 comp.default_extracted_target()
204181 } else {
205205- // If there are no compressors (shouldn't happen), default to FILE
206182 ExtractedTarget::FILE
207183 }
208184 }
209185186186+ fn default_compressed_filename(&self, in_path: &Path) -> String {
187187+ // Add all extensions: input.txt → input.txt.tar.gz
188188+ let base = in_path
189189+ .file_name()
190190+ .unwrap_or_else(|| std::ffi::OsStr::new("archive"))
191191+ .to_str()
192192+ .unwrap();
193193+ format!("{}.{}", base, self.format_chain())
194194+ }
195195+196196+ fn default_extracted_filename(&self, in_path: &Path) -> String {
197197+ if self.default_extracted_target() == ExtractedTarget::DIRECTORY {
198198+ return ".".to_string();
199199+ }
200200+ // Strip all known extensions: input.tar.gz → input
201201+ let mut name = in_path
202202+ .file_name()
203203+ .unwrap_or_else(|| std::ffi::OsStr::new("archive"))
204204+ .to_str()
205205+ .unwrap()
206206+ .to_string();
207207+ for comp in self.compressors.iter().rev() {
208208+ let ext = format!(".{}", comp.extension());
209209+ if let Some(stripped) = name.strip_suffix(&ext) {
210210+ name = stripped.to_string();
211211+ }
212212+ }
213213+ name
214214+ }
215215+210216 fn is_archive(&self, in_path: &Path) -> bool {
211211- // Check if the path matches our format chain
212212- if let Some(filename) = in_path.to_str() {
213213- let format_chain = self.format_chain();
214214- filename.ends_with(&format_chain)
215215- } else {
216216- false
217217- }
217217+ let file_name = match in_path.file_name().and_then(|f| f.to_str()) {
218218+ Some(f) => f,
219219+ None => return false,
220220+ };
221221+ file_name.ends_with(&format!(".{}", self.format_chain()))
218222 }
219223220224 fn compress(&self, input: CmprssInput, output: CmprssOutput) -> Result<(), io::Error> {
···232236 let mut op_compressors: Vec<Box<dyn Compressor>> = self
233237 .compressors
234238 .iter()
235235- .map(|c| Self::create_compressor(c.name()).unwrap()) // TODO: Handle error properly
236236- .collect();
239239+ .map(|c| Self::create_compressor(c.name()))
240240+ .collect::<io::Result<Vec<_>>>()?;
237241238242 let mut handles = Vec::new();
239243 let mut current_thread_input = input; // Consumed by the first (innermost) compressor
240244 let buffer_size = 64 * 1024;
241245242246 // Process all but the last (outermost) compressor in separate threads
243243- for i in 0..op_compressors.len() - 1 {
247247+ for _ in 0..op_compressors.len() - 1 {
244248 let compressor_for_this_stage = op_compressors.remove(0);
245249 let (sender, receiver) = channel::<Vec<u8>>();
246250 let pipe_writer = PipeWriter::new(sender, buffer_size);
···265269 last_compressor.compress(current_thread_input, output)?;
266270267271 for handle in handles {
268268- handle.join().unwrap()?; // TODO: Handle thread errors properly
272272+ handle.join().map_err(|_| {
273273+ io::Error::new(io::ErrorKind::Other, "Compression thread panicked")
274274+ })??;
269275 }
270276 Ok(())
271277 }
···285291 let mut op_extractors: Vec<Box<dyn Compressor>> = self
286292 .compressors
287293 .iter()
288288- .rev() // Iterate from Outermost to Innermost
289289- .map(|c| Self::create_compressor(c.name()).unwrap()) // TODO: Handle error properly
290290- .collect();
294294+ .rev()
295295+ .map(|c| Self::create_compressor(c.name()))
296296+ .collect::<io::Result<Vec<_>>>()?;
291297292298 let mut handles = Vec::new();
293299 let mut current_thread_input = input; // Consumed by the first (outermost) extractor
294300 let buffer_size = 64 * 1024;
295301296302 // Process all but the last (innermost) extractor in separate threads.
297297- for i in 0..op_extractors.len() - 1 {
303303+ for _ in 0..op_extractors.len() - 1 {
298304 let extractor_for_this_stage = op_extractors.remove(0);
299305 let (sender, receiver) = channel::<Vec<u8>>();
300306 let pipe_writer = PipeWriter::new(sender, buffer_size);
···336342 last_extractor.extract(current_thread_input, final_output)?;
337343338344 for handle in handles {
339339- handle.join().unwrap()?; // TODO: Handle thread errors properly
345345+ handle.join().map_err(|_| {
346346+ io::Error::new(io::ErrorKind::Other, "Extraction thread panicked")
347347+ })??;
340348 }
341349 Ok(())
342350 }
···346354mod tests {
347355 use super::*;
348356 use std::fs;
349349- use std::io::{Read, Write};
350357 use tempfile::tempdir;
351358352359 #[test]
···119119 CmprssInput::Reader(reader) => reader.0,
120120 };
121121122122- let output_stream: Box<dyn Write + Send> = match &output {
123123- CmprssOutput::Path(path) => Box::new(BufWriter::new(File::create(path)?)),
124124- CmprssOutput::Pipe(stdout) => Box::new(BufWriter::new(stdout)),
125125- CmprssOutput::Writer(_) => panic!("Writer output not supported in this context"),
126126- };
127127-128128- // Create a zstd encoder with the specified compression level
129129- let mut encoder = Encoder::new(output_stream, self.compression_level)?;
130130-131131- // Copy the input to the encoder with progress reporting
132132- copy_with_progress(
133133- &mut input_stream,
134134- &mut encoder,
135135- self.progress_args.chunk_size.size_in_bytes,
136136- file_size,
137137- self.progress_args.progress,
138138- &output,
139139- )?;
140140-141141- // Finish the encoder to ensure all data is written
142142- encoder.finish()?;
122122+ if let CmprssOutput::Writer(writer) = output {
123123+ let mut encoder = Encoder::new(writer, self.compression_level)?;
124124+ io::copy(&mut input_stream, &mut encoder)?;
125125+ encoder.finish()?;
126126+ } else {
127127+ let output_stream: Box<dyn Write + Send> = match &output {
128128+ CmprssOutput::Path(path) => Box::new(BufWriter::new(File::create(path)?)),
129129+ CmprssOutput::Pipe(stdout) => Box::new(BufWriter::new(stdout)),
130130+ CmprssOutput::Writer(_) => unreachable!(),
131131+ };
132132+ let mut encoder = Encoder::new(output_stream, self.compression_level)?;
133133+ copy_with_progress(
134134+ &mut input_stream,
135135+ &mut encoder,
136136+ self.progress_args.chunk_size.size_in_bytes,
137137+ file_size,
138138+ self.progress_args.progress,
139139+ &output,
140140+ )?;
141141+ encoder.finish()?;
142142+ }
143143144144 Ok(())
145145 }
···169169 CmprssInput::Reader(reader) => reader.0,
170170 };
171171172172- let mut output_stream: Box<dyn Write + Send> = match &output {
173173- CmprssOutput::Path(path) => Box::new(BufWriter::new(File::create(path)?)),
174174- CmprssOutput::Pipe(stdout) => Box::new(BufWriter::new(stdout)),
175175- CmprssOutput::Writer(_) => panic!("Writer output not supported in this context"),
176176- };
177177-178178- // Create a zstd decoder
179172 let mut decoder = Decoder::new(input_stream)?;
180173181181- // Copy the decoded data to the output with progress reporting
182182- copy_with_progress(
183183- &mut decoder,
184184- &mut output_stream,
185185- self.progress_args.chunk_size.size_in_bytes,
186186- file_size,
187187- self.progress_args.progress,
188188- &output,
189189- )?;
174174+ if let CmprssOutput::Writer(mut writer) = output {
175175+ io::copy(&mut decoder, &mut writer)?;
176176+ } else {
177177+ let mut output_stream: Box<dyn Write + Send> = match &output {
178178+ CmprssOutput::Path(path) => Box::new(BufWriter::new(File::create(path)?)),
179179+ CmprssOutput::Pipe(stdout) => Box::new(BufWriter::new(stdout)),
180180+ CmprssOutput::Writer(_) => unreachable!(),
181181+ };
182182+ copy_with_progress(
183183+ &mut decoder,
184184+ &mut output_stream,
185185+ self.progress_args.chunk_size.size_in_bytes,
186186+ file_size,
187187+ self.progress_args.progress,
188188+ &output,
189189+ )?;
190190+ }
190191191192 Ok(())
192193 }
+46-145
src/main.rs
···84848585/// Get a compressor from a filename
8686fn get_compressor_from_filename(filename: &Path) -> Option<Box<dyn Compressor>> {
8787+ // Use just the filename component to avoid dots in directory names
8888+ let file_name = filename.file_name()?.to_str()?;
8989+8790 // Prioritize checking for multi-level formats first
8888- if let Some(filename_str) = filename.to_str() {
8989- let parts: Vec<&str> = filename_str.split('.').collect();
9191+ {
9292+ let parts: Vec<&str> = file_name.split('.').collect();
9093 // A potential multi-level format like "archive.tar.gz" will have at least 3 parts
9194 if parts.len() >= 3 {
9295 // Get all available single compressors for matching extensions
···140143 compressor_types.reverse(); // e.g., ["tar", "gzip"]
141144 return Some(create_multi_level_compressor(&compressor_types));
142145 }
143143- // If compressor_types is empty here, it means the multi-level parse failed (e.g. "file.foo.bar" with unknown foo/bar)
144144- // or an unknown extension was found in the chain. We'll fall through to single extension check.
145146 }
146147 }
147148148148- // Fallback: If not a recognized multi-level format, or if fewer than 3 parts (e.g. "file.gz"),
149149- // try matching a single known compressor extension.
150150- let single_compressors: Vec<Box<dyn Compressor>> = vec![
151151- Box::<Tar>::default(),
149149+ // Fallback: try matching a single known compressor extension
150150+ for sc in [
151151+ Box::<Tar>::default() as Box<dyn Compressor>,
152152 Box::<Gzip>::default(),
153153 Box::<Xz>::default(),
154154 Box::<Bzip2>::default(),
155155 Box::<Zip>::default(),
156156 Box::<Zstd>::default(),
157157 Box::<Lz4>::default(),
158158- ];
159159-160160- // Check if file extension matches any known format
161161- // This is now a fallback.
162162- // Ensure this doesn't misinterpret "foo.tar.gz" as just "gz" if multi-level check failed for some reason
163163- // A more robust check here might be to see if filename *only* ends with .ext and not .something_else.ext
164164- // For now, the standard check is:
165165- if let Some(filename_str) = filename.to_str() {
166166- for sc in single_compressors {
167167- // A simple "ends_with" can be problematic for "file.tar.gz" vs "file.gz"
168168- // We need to be more specific. The extension should be exactly the compressor's extension.
169169- let expected_extension = format!(".{}", sc.extension());
170170- if filename_str.ends_with(&expected_extension) {
171171- // Further check: ensure it's not something like ".tar.gz" being matched by ".gz"
172172- // if we want to be super sure, but the multi-level check should catch .tar.gz first.
173173- // A simple way: if it ends with ".tar.gz", Gzip (gz) should not match here IF Tar (tar) also exists.
174174- // The current structure relies on multi-level being caught first.
175175- // If multi-level parsing failed, then we check single extensions.
176176- // Example: "archive.gz" -> Gzip
177177- // Example: "archive.tar" -> Tar
178178- // Example: "archive.unknown.gz" -> Multi-level fails, then Gzip matches.
179179- return Some(sc);
180180- }
158158+ ] {
159159+ let expected_extension = format!(".{}", sc.extension());
160160+ if file_name.ends_with(&expected_extension) {
161161+ return Some(sc);
181162 }
182163 }
183164 None
184165}
185166167167+/// Get a single compressor from an extension string (no multi-level detection)
168168+fn get_compressor_from_extension(ext: &str) -> Option<Box<dyn Compressor>> {
169169+ match ext {
170170+ "tar" => Some(Box::<Tar>::default()),
171171+ "gz" => Some(Box::<Gzip>::default()),
172172+ "xz" => Some(Box::<Xz>::default()),
173173+ "bz2" => Some(Box::<Bzip2>::default()),
174174+ "zip" => Some(Box::<Zip>::default()),
175175+ "zst" => Some(Box::<Zstd>::default()),
176176+ "lz4" => Some(Box::<Lz4>::default()),
177177+ _ => None,
178178+ }
179179+}
180180+186181/// Create a MultiLevelCompressor from a list of compressor types
187182fn create_multi_level_compressor(compressor_types: &[String]) -> Box<dyn Compressor> {
188183 // Create a MultiLevelCompressor from the list of compressor types
···270265 (Some(c), None) => (Some(c), Action::Compress),
271266 (None, Some(e)) => (Some(e), Action::Extract),
272267 (Some(c), Some(e)) => {
273273- if c.name() == e.name() {
274274- // Same format for input and output, can't decide
275275- if output.is_dir() {
276276- // If output is a directory, we're probably extracting
277277- return (Some(e), Action::Extract);
278278- }
279279- return (Some(c), Action::Unknown);
280280- }
281281-282268 // Compare the input and output extensions to see if one has an extra extension
283269 let input_file = input.file_name().unwrap().to_str().unwrap();
284270 let input_ext = input.extension().unwrap_or_default();
···288274 let guessed_input = output_file.to_string() + "." + input_ext.to_str().unwrap();
289275290276 if guessed_output == output_file {
291291- (Some(c), Action::Compress)
277277+ // Input is "archive.tar", output is "archive.tar.gz" — only add the outer layer
278278+ let single_compressor = get_compressor_from_extension(output_ext.to_str().unwrap_or(""));
279279+ (single_compressor.or(Some(c)), Action::Compress)
292280 } else if guessed_input == input_file {
293293- (Some(e), Action::Extract)
281281+ // Output is "archive.tar", input is "archive.tar.gz" — only strip the outer layer
282282+ let single_compressor = get_compressor_from_extension(input_ext.to_str().unwrap_or(""));
283283+ (single_compressor.or(Some(e)), Action::Extract)
284284+ } else if c.name() == e.name() {
285285+ // Same format for input and output, can't decide
286286+ if output.is_dir() {
287287+ (Some(e), Action::Extract)
288288+ } else {
289289+ (Some(c), Action::Unknown)
290290+ }
294291 } else if output.is_dir() {
295295- // If output is a directory, we're probably extracting
296292 (Some(e), Action::Extract)
297293 } else {
298294 (None, Action::Unknown)
···512508 }
513509 }
514510 Action::Extract => {
515515- // Look at the input name
516511 if let CmprssInput::Path(paths) = &cmprss_input {
517512 if paths.len() != 1 {
518518- // When extracting, we expect a single input file
519513 return Err(io::Error::new(
520514 io::ErrorKind::Other,
521515 "Expected a single archive to extract",
522516 ));
523517 }
524518 compressor = get_compressor_from_filename(paths.first().unwrap());
525525-526526- // If we still couldn't guess the compressor, try harder with multi-level extraction
527527- if compressor.is_none() && paths.len() == 1 {
528528- if let Some(filename_str) = paths.first().unwrap().to_str() {
529529- // Try to parse multi-level formats (e.g., tar.gz)
530530- let parts: Vec<&str> = filename_str.split('.').collect();
531531- if parts.len() >= 3 {
532532- // Get all available compressors
533533- let compressors: Vec<Box<dyn Compressor>> = vec![
534534- Box::<Tar>::default(),
535535- Box::<Gzip>::default(),
536536- Box::<Xz>::default(),
537537- Box::<Bzip2>::default(),
538538- Box::<Zip>::default(),
539539- Box::<Zstd>::default(),
540540- Box::<Lz4>::default(),
541541- ];
542542-543543- // Get extensions in reverse order (from right to left)
544544- let mut extensions: Vec<String> = Vec::new();
545545- for i in 1..parts.len() {
546546- extensions.push(parts[parts.len() - i].to_string());
547547- }
548548-549549- // Try to find a compressor for each extension
550550- let mut compressor_types: Vec<String> = Vec::new();
551551- for ext in &extensions {
552552- for compressor in &compressors {
553553- if compressor.extension() == ext || compressor.name() == ext
554554- {
555555- compressor_types.push(compressor.name().to_string());
556556- break;
557557- }
558558- }
559559- }
560560-561561- // If we found compressor types, create a MultiLevelCompressor
562562- if !compressor_types.is_empty() {
563563- compressor =
564564- Some(create_multi_level_compressor(&compressor_types));
565565- }
566566- }
567567- }
568568- }
569519 }
570520 }
571521 Action::Unknown => match (&cmprss_input, &cmprss_output) {
572522 (CmprssInput::Path(paths), CmprssOutput::Path(path)) => {
573573- // Special case: if output is a directory, assume we're extracting
574523 if path.is_dir() && paths.len() == 1 {
575575- // For extraction to directory, try to determine compressor from input file
576524 compressor = get_compressor_from_filename(paths.first().unwrap());
577525 action = Action::Extract;
578526579579- // If no compressor was found, try harder with multi-level detection
580527 if compressor.is_none() {
581581- if let Some(filename_str) = paths.first().unwrap().to_str() {
582582- // Try to parse multi-level formats (e.g., tar.gz)
583583- let parts: Vec<&str> = filename_str.split('.').collect();
584584- if parts.len() >= 3 {
585585- // Get all available compressors
586586- let compressors: Vec<Box<dyn Compressor>> = vec![
587587- Box::<Tar>::default(),
588588- Box::<Gzip>::default(),
589589- Box::<Xz>::default(),
590590- Box::<Bzip2>::default(),
591591- Box::<Zip>::default(),
592592- Box::<Zstd>::default(),
593593- Box::<Lz4>::default(),
594594- ];
595595-596596- // Get extensions in reverse order (from right to left)
597597- let mut extensions: Vec<String> = Vec::new();
598598- for i in 1..parts.len() {
599599- extensions.push(parts[parts.len() - i].to_string());
600600- }
601601-602602- // Try to find a compressor for each extension
603603- let mut compressor_types: Vec<String> = Vec::new();
604604- for ext in &extensions {
605605- for compressor in &compressors {
606606- if compressor.extension() == ext
607607- || compressor.name() == ext
608608- {
609609- compressor_types
610610- .push(compressor.name().to_string());
611611- break;
612612- }
613613- }
614614- }
615615-616616- // If we found compressor types, create a MultiLevelCompressor
617617- if !compressor_types.is_empty() {
618618- compressor =
619619- Some(create_multi_level_compressor(&compressor_types));
620620- }
621621- }
622622- }
623623-624624- // If we still couldn't determine compressor, fail with a clear message
625625- if compressor.is_none() {
626626- return Err(io::Error::new(
627627- io::ErrorKind::Other,
628628- format!(
629629- "Couldn't determine how to extract {:?}",
630630- paths.first().unwrap()
631631- ),
632632- ));
633633- }
528528+ return Err(io::Error::new(
529529+ io::ErrorKind::Other,
530530+ format!(
531531+ "Couldn't determine how to extract {:?}",
532532+ paths.first().unwrap()
533533+ ),
534534+ ));
634535 }
635536 } else {
636537 let (guessed_compressor, guessed_action) =
+36-2
tests/multi_level.rs
···135135 .assert()
136136 .success();
137137138138- // Verify the file was extracted correctly
139139- let extracted_file = extract_dir.child("test_file.txt");
138138+ // Verify the file was extracted correctly (tar preserves the directory structure)
139139+ let extracted_file = extract_dir.child("source").child("test_file.txt");
140140 extracted_file.assert(predicate::path::exists());
141141 extracted_file.assert(predicate::str::contains(
142142 "test content for tar.gz extraction",
143143 ));
144144+145145+ Ok(())
146146+}
147147+148148+/// Full roundtrip: directory -> tar.xz -> directory
149149+#[test]
150150+fn test_tar_xz_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
151151+ let temp_dir = TempDir::new()?;
152152+153153+ let source_dir = temp_dir.child("source");
154154+ source_dir.create_dir_all()?;
155155+ let test_file = source_dir.child("data.txt");
156156+ test_file.write_str("tar.xz roundtrip content")?;
157157+158158+ let archive = temp_dir.child("archive.tar.xz");
159159+ Command::cargo_bin("cmprss")?
160160+ .arg("--compress")
161161+ .arg(source_dir.path())
162162+ .arg(archive.path())
163163+ .assert()
164164+ .success();
165165+166166+ let extract_dir = temp_dir.child("extract");
167167+ extract_dir.create_dir_all()?;
168168+ Command::cargo_bin("cmprss")?
169169+ .arg("--extract")
170170+ .arg(archive.path())
171171+ .arg(extract_dir.path())
172172+ .assert()
173173+ .success();
174174+175175+ let extracted_file = extract_dir.child("source").child("data.txt");
176176+ extracted_file.assert(predicate::path::exists());
177177+ extracted_file.assert(predicate::str::contains("tar.xz roundtrip content"));
144178145179 Ok(())
146180}