···22 progress::{copy_with_progress, ProgressArgs},
33 utils::{
44 cmprss_error, CmprssInput, CmprssOutput, CommonArgs, CompressionLevelValidator, Compressor,
55- LevelArgs,
55+ ExtractedTarget, LevelArgs,
66 },
77};
88use bzip2::write::{BzDecoder, BzEncoder};
···7777}
78787979impl Compressor for Bzip2 {
8080- /// The standard extension for the bz2 format.
8080+ /// Default extension for bzip2 files
8181 fn extension(&self) -> &str {
8282 "bz2"
8383 }
84848585- /// Full name for bz2.
8585+ /// Name of this compressor
8686 fn name(&self) -> &str {
8787 "bzip2"
8888 }
89899090+ /// Bzip2 extracts to a file by default
9191+ fn default_extracted_target(&self) -> ExtractedTarget {
9292+ ExtractedTarget::FILE
9393+ }
9494+9095 /// Compress an input file or pipe to a bz2 archive
9196 fn compress(&self, input: CmprssInput, output: CmprssOutput) -> Result<(), io::Error> {
9297 let mut file_size = None;
···163168#[cfg(test)]
164169mod tests {
165170 use super::*;
166166- use assert_fs::prelude::*;
167167- use predicates::prelude::*;
171171+ use crate::test_utils::*;
172172+173173+ /// Test the basic interface of the Bzip2 compressor
174174+ #[test]
175175+ fn test_bzip2_interface() {
176176+ let compressor = Bzip2::default();
177177+ test_compressor_interface(&compressor, "bzip2", Some("bz2"));
178178+ }
168179169180 #[test]
170181 fn test_bzip2_compression_validator() {
171182 let validator = Bzip2CompressionValidator;
172172-173173- // Test range
174174- assert_eq!(validator.min_level(), 1);
175175- assert_eq!(validator.max_level(), 9);
176176- assert_eq!(validator.default_level(), 9);
177177-178178- // Test validation
179179- assert!(validator.is_valid_level(1));
180180- assert!(validator.is_valid_level(5));
181181- assert!(validator.is_valid_level(9));
182182- assert!(!validator.is_valid_level(0));
183183- assert!(!validator.is_valid_level(10));
184184-185185- // Test clamping
186186- assert_eq!(validator.validate_and_clamp_level(0), 1);
187187- assert_eq!(validator.validate_and_clamp_level(5), 5);
188188- assert_eq!(validator.validate_and_clamp_level(10), 9);
189189-190190- // Test special names
191191- assert_eq!(validator.name_to_level("fast"), Some(1));
192192- assert_eq!(validator.name_to_level("best"), Some(9));
193193- assert_eq!(validator.name_to_level("none"), None);
194194- assert_eq!(validator.name_to_level("invalid"), None);
183183+ test_compression_validator_helper(
184184+ &validator,
185185+ 1, // min_level
186186+ 9, // max_level
187187+ 9, // default_level
188188+ Some(1), // fast_name_level
189189+ Some(9), // best_name_level
190190+ None, // none_name_level
191191+ );
195192 }
196193194194+ /// Test the default compression level
197195 #[test]
198198- fn roundtrip() -> Result<(), Box<dyn std::error::Error>> {
196196+ fn test_bzip2_default_compression() -> Result<(), io::Error> {
199197 let compressor = Bzip2::default();
200200-201201- let file = assert_fs::NamedTempFile::new("test.txt")?;
202202- file.write_str("garbage data for testing")?;
203203- let working_dir = assert_fs::TempDir::new()?;
204204- let archive = working_dir.child("archive.".to_owned() + compressor.extension());
205205- archive.assert(predicate::path::missing());
206206-207207- // Roundtrip compress/extract
208208- compressor.compress(
209209- CmprssInput::Path(vec![file.path().to_path_buf()]),
210210- CmprssOutput::Path(archive.path().to_path_buf()),
211211- )?;
212212- archive.assert(predicate::path::is_file());
213213- compressor.extract(
214214- CmprssInput::Path(vec![archive.path().to_path_buf()]),
215215- CmprssOutput::Path(working_dir.child("test.txt").path().to_path_buf()),
216216- )?;
217217-218218- // Assert the files are identical
219219- working_dir
220220- .child("test.txt")
221221- .assert(predicate::path::eq_file(file.path()));
222222-223223- Ok(())
198198+ test_compression(&compressor)
224199 }
225200226226- // Fail with a compression level of 0
201201+ /// Test fast compression level
227202 #[test]
228228- fn invalid_compression_level_0() {
229229- let compressor = Bzip2 {
230230- level: 0,
231231- ..Bzip2::default()
203203+ fn test_bzip2_fast_compression() -> Result<(), io::Error> {
204204+ let fast_compressor = Bzip2 {
205205+ level: 1,
206206+ progress_args: ProgressArgs::default(),
232207 };
233233- let file = assert_fs::NamedTempFile::new("test.txt").unwrap();
234234- let working_dir = assert_fs::TempDir::new().unwrap();
235235- let archive = working_dir.child("archive.".to_owned() + compressor.extension());
236236- let result = compressor.compress(
237237- CmprssInput::Path(vec![file.path().to_path_buf()]),
238238- CmprssOutput::Path(archive.path().to_path_buf()),
239239- );
240240- assert!(result.is_err());
208208+ test_compression(&fast_compressor)
241209 }
242210243243- // Fail with a compression level of 10
211211+ /// Test best compression level
244212 #[test]
245245- fn invalid_compression_level_10() {
246246- let compressor = Bzip2 {
247247- level: 10,
248248- ..Bzip2::default()
213213+ fn test_bzip2_best_compression() -> Result<(), io::Error> {
214214+ let best_compressor = Bzip2 {
215215+ level: 9,
216216+ progress_args: ProgressArgs::default(),
249217 };
250250- let file = assert_fs::NamedTempFile::new("test.txt").unwrap();
251251- let working_dir = assert_fs::TempDir::new().unwrap();
252252- let archive = working_dir.child("archive.".to_owned() + compressor.extension());
253253- let result = compressor.compress(
254254- CmprssInput::Path(vec![file.path().to_path_buf()]),
255255- CmprssOutput::Path(archive.path().to_path_buf()),
256256- );
257257- assert!(result.is_err());
218218+ test_compression(&best_compressor)
258219 }
259220}
+85-27
src/backends/gzip.rs
···5959 "gzip"
6060 }
61616262- /// Generate a default extracted filename
6363- /// gzip does not support extracting to a directory, so we return a default filename
6464- fn default_extracted_filename(&self, in_path: &std::path::Path) -> String {
6565- // If the file has no extension, return a default filename
6666- if in_path.extension().is_none() {
6767- return "archive".to_string();
6868- }
6969- // Otherwise, return the filename without the extension
7070- in_path.file_stem().unwrap().to_str().unwrap().to_string()
6262+ /// Gzip extracts to a file by default
6363+ fn default_extracted_target(&self) -> ExtractedTarget {
6464+ ExtractedTarget::FILE
7165 }
72667367 /// Compress an input file or pipe to a gzip archive
···169163#[cfg(test)]
170164mod tests {
171165 use super::*;
172172- use assert_fs::prelude::*;
173173- use predicates::prelude::*;
166166+ use crate::test_utils::*;
167167+ use std::fs;
168168+ use std::io::{Read, Write};
169169+ use tempfile::tempdir;
174170171171+ /// Test the basic interface of the Gzip compressor
175172 #[test]
176176- fn roundtrip() -> Result<(), Box<dyn std::error::Error>> {
173173+ fn test_gzip_interface() {
177174 let compressor = Gzip::default();
175175+ test_compressor_interface(&compressor, "gzip", Some("gz"));
176176+ }
178177179179- let file = assert_fs::NamedTempFile::new("test.txt")?;
180180- file.write_str("garbage data for testing")?;
181181- let working_dir = assert_fs::TempDir::new()?;
182182- let archive = working_dir.child("archive.".to_owned() + compressor.extension());
183183- archive.assert(predicate::path::missing());
178178+ /// Test the default compression level
179179+ #[test]
180180+ fn test_gzip_default_compression() -> Result<(), io::Error> {
181181+ let compressor = Gzip::default();
182182+ test_compression(&compressor)
183183+ }
184184185185- // Roundtrip compress/extract
185185+ /// Test fast compression level
186186+ #[test]
187187+ fn test_gzip_fast_compression() -> Result<(), io::Error> {
188188+ let fast_compressor = Gzip {
189189+ compression_level: 1,
190190+ progress_args: ProgressArgs::default(),
191191+ };
192192+ test_compression(&fast_compressor)
193193+ }
194194+195195+ /// Test best compression level
196196+ #[test]
197197+ fn test_gzip_best_compression() -> Result<(), io::Error> {
198198+ let best_compressor = Gzip {
199199+ compression_level: 9,
200200+ progress_args: ProgressArgs::default(),
201201+ };
202202+ test_compression(&best_compressor)
203203+ }
204204+205205+ /// Test for gzip-specific behavior: handling of concatenated gzip archives
206206+ #[test]
207207+ fn test_concatenated_gzip() -> Result<(), io::Error> {
208208+ let compressor = Gzip::default();
209209+ let temp_dir = tempdir().expect("Failed to create temp dir");
210210+211211+ // Create two test files
212212+ let input_path1 = temp_dir.path().join("input1.txt");
213213+ let input_path2 = temp_dir.path().join("input2.txt");
214214+ let test_data1 = "This is the first file";
215215+ let test_data2 = "This is the second file";
216216+ fs::write(&input_path1, test_data1)?;
217217+ fs::write(&input_path2, test_data2)?;
218218+219219+ // Compress each file separately
220220+ let archive_path1 = temp_dir.path().join("archive1.gz");
221221+ let archive_path2 = temp_dir.path().join("archive2.gz");
222222+186223 compressor.compress(
187187- CmprssInput::Path(vec![file.path().to_path_buf()]),
188188- CmprssOutput::Path(archive.path().to_path_buf()),
224224+ CmprssInput::Path(vec![input_path1.clone()]),
225225+ CmprssOutput::Path(archive_path1.clone()),
189226 )?;
190190- archive.assert(predicate::path::is_file());
227227+228228+ compressor.compress(
229229+ CmprssInput::Path(vec![input_path2.clone()]),
230230+ CmprssOutput::Path(archive_path2.clone()),
231231+ )?;
232232+233233+ // Create a concatenated archive
234234+ let concat_archive = temp_dir.path().join("concat.gz");
235235+ let mut concat_file = fs::File::create(&concat_archive)?;
236236+237237+ // Concat the two gzip files
238238+ let mut archive1_data = Vec::new();
239239+ let mut archive2_data = Vec::new();
240240+ fs::File::open(&archive_path1)?.read_to_end(&mut archive1_data)?;
241241+ fs::File::open(&archive_path2)?.read_to_end(&mut archive2_data)?;
242242+243243+ concat_file.write_all(&archive1_data)?;
244244+ concat_file.write_all(&archive2_data)?;
245245+ concat_file.flush()?;
246246+247247+ // Extract the concatenated archive - this should yield the first file's contents
248248+ let output_path = temp_dir.path().join("output.txt");
249249+191250 compressor.extract(
192192- CmprssInput::Path(vec![archive.path().to_path_buf()]),
193193- CmprssOutput::Path(working_dir.child("test.txt").path().to_path_buf()),
251251+ CmprssInput::Path(vec![concat_archive]),
252252+ CmprssOutput::Path(output_path.clone()),
194253 )?;
195254196196- // Assert the files are identical
197197- working_dir
198198- .child("test.txt")
199199- .assert(predicate::path::eq_file(file.path()));
255255+ // Verify the result is the first file's content
256256+ let output_data = fs::read_to_string(output_path)?;
257257+ assert_eq!(output_data, test_data1);
200258201259 Ok(())
202260 }
+11-39
src/backends/lz4.rs
···3838 "lz4"
3939 }
40404141- /// Generate a default extracted filename
4242- /// lz4 does not support extracting to a directory, so we return a default filename
4343- fn default_extracted_filename(&self, in_path: &std::path::Path) -> String {
4444- // If the file has no extension, return a default filename
4545- if in_path.extension().is_none() {
4646- return "archive".to_string();
4747- }
4848- // Otherwise, return the filename without the extension
4949- in_path.file_stem().unwrap().to_str().unwrap().to_string()
5050- }
5151-5241 /// Compress an input file or pipe to a lz4 archive
5342 fn compress(&self, input: CmprssInput, output: CmprssOutput) -> Result<(), io::Error> {
5443 if let CmprssOutput::Path(out_path) = &output {
···152141#[cfg(test)]
153142mod tests {
154143 use super::*;
155155- use tempfile::tempdir;
144144+ use crate::test_utils::*;
156145146146+ /// Test the basic interface of the Lz4 compressor
157147 #[test]
158158- fn roundtrip() -> Result<(), Box<dyn std::error::Error>> {
159159- let dir = tempdir()?;
160160- let input_path = dir.path().join("input.txt");
161161- let compressed_path = dir.path().join("input.txt.lz4");
162162- let output_path = dir.path().join("output.txt");
148148+ fn test_lz4_interface() {
149149+ let compressor = Lz4::default();
150150+ test_compressor_interface(&compressor, "lz4", Some("lz4"));
151151+ }
163152164164- // Create a test file
165165- let test_data = b"Hello, world! This is a test file for lz4 compression.";
166166- std::fs::write(&input_path, test_data)?;
167167-168168- // Compress the file
169169- let lz4 = Lz4::default();
170170- lz4.compress(
171171- CmprssInput::Path(vec![input_path.clone()]),
172172- CmprssOutput::Path(compressed_path.clone()),
173173- )?;
174174-175175- // Extract the file
176176- lz4.extract(
177177- CmprssInput::Path(vec![compressed_path]),
178178- CmprssOutput::Path(output_path.clone()),
179179- )?;
180180-181181- // Verify the contents
182182- let output_data = std::fs::read(output_path)?;
183183- assert_eq!(test_data.to_vec(), output_data);
184184-185185- Ok(())
153153+ /// Test the default compression level
154154+ #[test]
155155+ fn test_lz4_default_compression() -> Result<(), io::Error> {
156156+ let compressor = Lz4::default();
157157+ test_compression(&compressor)
186158 }
187159}
+15-29
src/backends/tar.rs
···33use clap::Args;
44use std::fs::File;
55use std::io::{self, Seek, SeekFrom, Write};
66-use std::path::Path;
76use tar::{Archive, Builder};
87use tempfile::tempfile;
98···3029 "tar"
3130 }
32313333- /// Tar extraction needs to specify the directory, so use the current directory
3434- fn default_extracted_filename(&self, _in_path: &Path) -> String {
3535- ".".to_string()
3232+ /// Tar extracts to a directory by default
3333+ fn default_extracted_target(&self) -> ExtractedTarget {
3434+ ExtractedTarget::DIRECTORY
3635 }
37363837 fn compress(&self, input: CmprssInput, output: CmprssOutput) -> Result<(), io::Error> {
···133132#[cfg(test)]
134133mod tests {
135134 use super::*;
135135+ use crate::test_utils::*;
136136 use assert_fs::prelude::*;
137137 use predicates::prelude::*;
138138 use std::path::PathBuf;
139139140140+ /// Test the basic interface of the Tar compressor
140141 #[test]
141141- fn roundtrip() -> Result<(), Box<dyn std::error::Error>> {
142142+ fn test_tar_interface() {
142143 let compressor = Tar::default();
144144+ test_compressor_interface(&compressor, "tar", Some("tar"));
145145+ }
143146144144- let file = assert_fs::NamedTempFile::new("test.txt")?;
145145- file.write_str("garbage data for testing")?;
146146- let working_dir = assert_fs::TempDir::new()?;
147147- let archive = working_dir.child("archive.".to_owned() + compressor.extension());
148148- archive.assert(predicate::path::missing());
149149-150150- // Roundtrip compress/extract
151151- compressor.compress(
152152- CmprssInput::Path(vec![file.path().to_path_buf()]),
153153- CmprssOutput::Path(archive.path().to_path_buf()),
154154- )?;
155155- archive.assert(predicate::path::is_file());
156156- compressor.extract(
157157- CmprssInput::Path(vec![archive.path().to_path_buf()]),
158158- CmprssOutput::Path(working_dir.path().to_path_buf()),
159159- )?;
160160-161161- // Assert the files are identical
162162- working_dir
163163- .child("test.txt")
164164- .assert(predicate::path::eq_file(file.path()));
165165-166166- Ok(())
147147+ /// Test the default compression level
148148+ #[test]
149149+ fn test_tar_default_compression() -> Result<(), io::Error> {
150150+ let compressor = Tar::default();
151151+ test_compression(&compressor)
167152 }
168153154154+ /// Test tar-specific functionality: directory handling
169155 #[test]
170170- fn roundtrip_directory() -> Result<(), Box<dyn std::error::Error>> {
156156+ fn test_directory_handling() -> Result<(), Box<dyn std::error::Error>> {
171157 let compressor = Tar::default();
172158 let dir = assert_fs::TempDir::new()?;
173159 let file_path = dir.child("file.txt");
+28-24
src/backends/xz.rs
···136136#[cfg(test)]
137137mod tests {
138138 use super::*;
139139- use assert_fs::prelude::*;
140140- use predicates::prelude::*;
139139+ use crate::test_utils::*;
141140141141+ /// Test the basic interface of the Xz compressor
142142 #[test]
143143- fn roundtrip() -> Result<(), Box<dyn std::error::Error>> {
143143+ fn test_xz_interface() {
144144 let compressor = Xz::default();
145145-146146- let file = assert_fs::NamedTempFile::new("test.txt")?;
147147- file.write_str("garbage data for testing")?;
148148- let working_dir = assert_fs::TempDir::new()?;
149149- let archive = working_dir.child("archive.".to_owned() + compressor.extension());
150150- archive.assert(predicate::path::missing());
145145+ test_compressor_interface(&compressor, "xz", Some("xz"));
146146+ }
151147152152- // Roundtrip compress/extract
153153- compressor.compress(
154154- CmprssInput::Path(vec![file.path().to_path_buf()]),
155155- CmprssOutput::Path(archive.path().to_path_buf()),
156156- )?;
157157- archive.assert(predicate::path::is_file());
158158- compressor.extract(
159159- CmprssInput::Path(vec![archive.path().to_path_buf()]),
160160- CmprssOutput::Path(working_dir.child("test.txt").path().to_path_buf()),
161161- )?;
148148+ /// Test the default compression level
149149+ #[test]
150150+ fn test_xz_default_compression() -> Result<(), io::Error> {
151151+ let compressor = Xz::default();
152152+ test_compression(&compressor)
153153+ }
162154163163- // Assert the files are identical
164164- working_dir
165165- .child("test.txt")
166166- .assert(predicate::path::eq_file(file.path()));
155155+ /// Test fast compression level
156156+ #[test]
157157+ fn test_xz_fast_compression() -> Result<(), io::Error> {
158158+ let fast_compressor = Xz {
159159+ level: 1,
160160+ progress_args: ProgressArgs::default(),
161161+ };
162162+ test_compression(&fast_compressor)
163163+ }
167164168168- Ok(())
165165+ /// Test best compression level
166166+ #[test]
167167+ fn test_xz_best_compression() -> Result<(), io::Error> {
168168+ let best_compressor = Xz {
169169+ level: 9,
170170+ progress_args: ProgressArgs::default(),
171171+ };
172172+ test_compression(&best_compressor)
169173 }
170174}
+15-29
src/backends/zip.rs
···6464 "zip"
6565 }
66666767- fn default_extracted_filename(&self, in_path: &Path) -> String {
6868- if let Some(stem) = in_path.file_stem() {
6969- stem.to_string_lossy().into_owned()
7070- } else {
7171- ".".to_string()
7272- }
6767+ /// Zip extracts to a directory by default
6868+ fn default_extracted_target(&self) -> ExtractedTarget {
6969+ ExtractedTarget::DIRECTORY
7370 }
74717572 fn compress(&self, input: CmprssInput, output: CmprssOutput) -> Result<(), io::Error> {
···172169#[cfg(test)]
173170mod tests {
174171 use super::*;
172172+ use crate::test_utils::*;
175173 use assert_fs::prelude::*;
176174 use predicates::prelude::*;
177175 use std::path::PathBuf;
178176177177+ /// Test the basic interface of the Zip compressor
179178 #[test]
180180- fn roundtrip_file() -> Result<(), Box<dyn std::error::Error>> {
179179+ fn test_zip_interface() {
181180 let compressor = Zip::default();
182182- let file = assert_fs::NamedTempFile::new("test.txt")?;
183183- file.write_str("test data for zip")?;
184184- let working_dir = assert_fs::TempDir::new()?;
185185- let archive = working_dir.child("archive.zip");
186186- archive.assert(predicate::path::missing());
181181+ test_compressor_interface(&compressor, "zip", Some("zip"));
182182+ }
187183188188- compressor.compress(
189189- CmprssInput::Path(vec![file.path().to_path_buf()]),
190190- CmprssOutput::Path(archive.path().to_path_buf()),
191191- )?;
192192- archive.assert(predicate::path::is_file());
193193-194194- let extract_dir = working_dir.child("out");
195195- std::fs::create_dir_all(extract_dir.path())?;
196196- compressor.extract(
197197- CmprssInput::Path(vec![archive.path().to_path_buf()]),
198198- CmprssOutput::Path(extract_dir.path().to_path_buf()),
199199- )?;
200200- extract_dir
201201- .child("test.txt")
202202- .assert(predicate::path::eq_file(file.path()));
203203- Ok(())
184184+ /// Test the default compression level
185185+ #[test]
186186+ fn test_zip_default_compression() -> Result<(), io::Error> {
187187+ let compressor = Zip::default();
188188+ test_compression(&compressor)
204189 }
205190191191+ /// Test zip-specific functionality: directory handling
206192 #[test]
207207- fn roundtrip_directory() -> Result<(), Box<dyn std::error::Error>> {
193193+ fn test_directory_handling() -> Result<(), Box<dyn std::error::Error>> {
208194 let compressor = Zip::default();
209195 let dir = assert_fs::TempDir::new()?;
210196 let file_path = dir.child("file.txt");
+38-60
src/backends/zstd.rs
···8686 "zstd"
8787 }
88888989- /// Generate a default extracted filename
9090- /// zstd does not support extracting to a directory, so we return a default filename
9191- fn default_extracted_filename(&self, in_path: &std::path::Path) -> String {
9292- // If the file has no extension, return a default filename
9393- if in_path.extension().is_none() {
9494- return "archive".to_string();
9595- }
9696- // Otherwise, return the filename without the extension
9797- in_path.file_stem().unwrap().to_str().unwrap().to_string()
9898- }
9999-10089 /// Compress an input file or pipe to a zstd archive
10190 fn compress(&self, input: CmprssInput, output: CmprssOutput) -> Result<(), io::Error> {
10291 if let CmprssOutput::Path(out_path) = &output {
···200189#[cfg(test)]
201190mod tests {
202191 use super::*;
203203- use tempfile::tempdir;
192192+ use crate::test_utils::*;
204193194194+ /// Test the basic interface of the Zstd compressor
205195 #[test]
206206- fn roundtrip() -> Result<(), Box<dyn std::error::Error>> {
207207- let dir = tempdir()?;
208208- let input_path = dir.path().join("input.txt");
209209- let compressed_path = dir.path().join("input.txt.zst");
210210- let output_path = dir.path().join("output.txt");
196196+ fn test_zstd_interface() {
197197+ let compressor = Zstd::default();
198198+ test_compressor_interface(&compressor, "zstd", Some("zst"));
199199+ }
211200212212- // Create a test file
213213- let test_data = b"Hello, world! This is a test file for zstd compression.";
214214- std::fs::write(&input_path, test_data)?;
201201+ /// Test the default compression level
202202+ #[test]
203203+ fn test_zstd_default_compression() -> Result<(), io::Error> {
204204+ let compressor = Zstd::default();
205205+ test_compression(&compressor)
206206+ }
215207216216- // Compress the file
217217- let zstd = Zstd::default();
218218- zstd.compress(
219219- CmprssInput::Path(vec![input_path.clone()]),
220220- CmprssOutput::Path(compressed_path.clone()),
221221- )?;
208208+ /// Test fast compression level
209209+ #[test]
210210+ fn test_zstd_fast_compression() -> Result<(), io::Error> {
211211+ let fast_compressor = Zstd {
212212+ compression_level: 1,
213213+ progress_args: ProgressArgs::default(),
214214+ };
215215+ test_compression(&fast_compressor)
216216+ }
222217223223- // Extract the file
224224- zstd.extract(
225225- CmprssInput::Path(vec![compressed_path]),
226226- CmprssOutput::Path(output_path.clone()),
227227- )?;
228228-229229- // Verify the contents
230230- let output_data = std::fs::read(output_path)?;
231231- assert_eq!(test_data.to_vec(), output_data);
232232-233233- Ok(())
218218+ /// Test best compression level
219219+ #[test]
220220+ fn test_zstd_best_compression() -> Result<(), io::Error> {
221221+ let best_compressor = Zstd {
222222+ compression_level: 22,
223223+ progress_args: ProgressArgs::default(),
224224+ };
225225+ test_compression(&best_compressor)
234226 }
235227236228 #[test]
237229 fn test_zstd_compression_validator() {
238230 let validator = ZstdCompressionValidator;
239239-240240- // Test range
241241- assert_eq!(validator.min_level(), -7);
242242- assert_eq!(validator.max_level(), 22);
243243- assert_eq!(validator.default_level(), 1);
244244-245245- // Test validation
246246- assert!(validator.is_valid_level(-7));
247247- assert!(validator.is_valid_level(0));
248248- assert!(validator.is_valid_level(22));
249249- assert!(!validator.is_valid_level(-8));
250250- assert!(!validator.is_valid_level(23));
251251-252252- // Test clamping
253253- assert_eq!(validator.validate_and_clamp_level(-8), -7);
254254- assert_eq!(validator.validate_and_clamp_level(0), 0);
255255- assert_eq!(validator.validate_and_clamp_level(23), 22);
256256-257257- // Test special names
258258- assert_eq!(validator.name_to_level("none"), Some(-7));
259259- assert_eq!(validator.name_to_level("fast"), Some(1));
260260- assert_eq!(validator.name_to_level("best"), Some(22));
261261- assert_eq!(validator.name_to_level("invalid"), None);
231231+ test_compression_validator_helper(
232232+ &validator,
233233+ -7, // min_level
234234+ 22, // max_level
235235+ 1, // default_level
236236+ Some(1), // fast_name_level
237237+ Some(22), // best_name_level
238238+ Some(-7), // none_name_level
239239+ );
262240 }
263241}
+4-3
src/main.rs
···11-mod backends;
22-mod progress;
33-mod utils;
11+pub mod backends;
22+pub mod progress;
33+pub mod test_utils;
44+pub mod utils;
4556use backends::*;
67use clap::{Parser, Subcommand};
+194
src/test_utils.rs
···11+use crate::utils::ExtractedTarget;
22+use std::fs;
33+use std::io;
44+use std::path::Path;
55+use tempfile::tempdir;
66+77+use crate::utils::{CmprssInput, CmprssOutput, CompressionLevelValidator, Compressor};
88+99+/// Test basic trait functionality that should be common across all compressors
1010+pub fn test_compressor_interface<T: Compressor>(
1111+ compressor: &T,
1212+ expected_name: &str,
1313+ expected_extension: Option<&str>,
1414+) {
1515+ let ext = expected_extension.unwrap_or(expected_name);
1616+1717+ // Test name() returns expected value
1818+ assert_eq!(compressor.name(), expected_name);
1919+2020+ // Test extension() returns expected value
2121+ assert_eq!(compressor.extension(), ext);
2222+2323+ // Test is_archive() detection logic
2424+ let temp_dir = tempdir().expect("Failed to create temp dir");
2525+2626+ // Test with matching extension
2727+ let archive_path = temp_dir.path().join(format!("test.{}", ext));
2828+ fs::File::create(&archive_path).expect("Failed to create test file");
2929+ assert!(compressor.is_archive(&archive_path));
3030+3131+ // Test with non-matching extension
3232+ let non_archive_path = temp_dir.path().join("test.txt");
3333+ fs::File::create(&non_archive_path).expect("Failed to create test file");
3434+ assert!(!compressor.is_archive(&non_archive_path));
3535+3636+ // Test default_compressed_filename
3737+ let test_path = Path::new("test.txt");
3838+ let expected = format!("test.txt.{}", ext);
3939+ assert_eq!(compressor.default_compressed_filename(test_path), expected);
4040+4141+ // Test default_extracted_filename
4242+ let formatted_name = format!("test.{}", ext);
4343+ let archive_path = Path::new(&formatted_name);
4444+ match compressor.default_extracted_target() {
4545+ ExtractedTarget::FILE => {
4646+ assert_eq!(compressor.default_extracted_filename(archive_path), "test");
4747+ }
4848+ ExtractedTarget::DIRECTORY => {
4949+ assert_eq!(compressor.default_extracted_filename(archive_path), ".");
5050+ }
5151+ }
5252+5353+ // Test default_extracted_filename with non-matching extension
5454+ let non_archive_path = Path::new("test.txt");
5555+ match compressor.default_extracted_target() {
5656+ ExtractedTarget::FILE => {
5757+ assert_eq!(
5858+ compressor.default_extracted_filename(non_archive_path),
5959+ "archive"
6060+ );
6161+ }
6262+ ExtractedTarget::DIRECTORY => {
6363+ assert_eq!(compressor.default_extracted_filename(non_archive_path), ".");
6464+ }
6565+ }
6666+}
6767+6868+/// Test compression and extraction functionality with a simple string
6969+pub fn test_compressor_roundtrip<T: Compressor>(
7070+ compressor: &T,
7171+ test_data: &str,
7272+) -> Result<(), io::Error> {
7373+ let temp_dir = tempdir().expect("Failed to create temp dir");
7474+7575+ // Create test file
7676+ let input_path = temp_dir.path().join("input.txt");
7777+ fs::write(&input_path, test_data)?;
7878+7979+ // Compress
8080+ let archive_path = temp_dir
8181+ .path()
8282+ .join(format!("archive.{}", compressor.extension()));
8383+ compressor.compress(
8484+ CmprssInput::Path(vec![input_path.clone()]),
8585+ CmprssOutput::Path(archive_path.clone()),
8686+ )?;
8787+8888+ // Extract
8989+ let output_path = match compressor.default_extracted_target() {
9090+ ExtractedTarget::FILE => temp_dir.path().join("output.txt"),
9191+ ExtractedTarget::DIRECTORY => temp_dir.path().join("output"),
9292+ };
9393+ compressor.extract(
9494+ CmprssInput::Path(vec![archive_path]),
9595+ CmprssOutput::Path(output_path.clone()),
9696+ )?;
9797+9898+ // Verify
9999+ let input_filename = "input.txt";
100100+ let output_data = match compressor.default_extracted_target() {
101101+ ExtractedTarget::FILE => fs::read_to_string(output_path)?,
102102+ ExtractedTarget::DIRECTORY => fs::read_to_string(output_path.join(input_filename))?,
103103+ };
104104+ assert_eq!(output_data, test_data);
105105+106106+ Ok(())
107107+}
108108+109109+/// Test compression and extraction with different content sizes
110110+pub fn test_compression<T: Compressor>(compressor: &T) -> Result<(), io::Error> {
111111+ // Test with empty content
112112+ test_compressor_roundtrip(compressor, "")?;
113113+114114+ // Test with small content
115115+ test_compressor_roundtrip(compressor, "Small test content")?;
116116+117117+ // Test with medium content (generate a 10KB string)
118118+ let medium_content = "0123456789".repeat(1024);
119119+ test_compressor_roundtrip(compressor, &medium_content)?;
120120+121121+ Ok(())
122122+}
123123+124124+/// Run a full suite of tests on a compressor implementation
125125+pub fn run_compressor_tests<T: Compressor>(
126126+ compressor: &T,
127127+ expected_name: &str,
128128+ expected_extension: Option<&str>,
129129+) -> Result<(), io::Error> {
130130+ // Test interface methods
131131+ test_compressor_interface(compressor, expected_name, expected_extension);
132132+133133+ // Test compression/extraction functionality
134134+ test_compression(compressor)?;
135135+136136+ Ok(())
137137+}
138138+139139+/// Helper function to test CompressionValidator implementations
140140+/// This avoids duplicating the same test pattern across multiple backends
141141+pub fn test_compression_validator_helper<V: CompressionLevelValidator>(
142142+ validator: &V,
143143+ min_level: i32,
144144+ max_level: i32,
145145+ default_level: i32,
146146+ fast_name_level: Option<i32>,
147147+ best_name_level: Option<i32>,
148148+ none_name_level: Option<i32>,
149149+) {
150150+ // Test range
151151+ assert_eq!(validator.min_level(), min_level);
152152+ assert_eq!(validator.max_level(), max_level);
153153+ assert_eq!(validator.default_level(), default_level);
154154+155155+ // Test validation
156156+ assert!(validator.is_valid_level(min_level));
157157+ assert!(validator.is_valid_level(max_level));
158158+ assert!(!validator.is_valid_level(min_level - 1));
159159+ assert!(!validator.is_valid_level(max_level + 1));
160160+161161+ // Test middle level if range is big enough
162162+ if max_level - min_level >= 2 {
163163+ let mid_level = (min_level + max_level) / 2;
164164+ assert!(validator.is_valid_level(mid_level));
165165+ }
166166+167167+ // Test clamping
168168+ assert_eq!(validator.validate_and_clamp_level(min_level - 1), min_level);
169169+ assert_eq!(validator.validate_and_clamp_level(min_level), min_level);
170170+ assert_eq!(validator.validate_and_clamp_level(max_level), max_level);
171171+ assert_eq!(validator.validate_and_clamp_level(max_level + 1), max_level);
172172+173173+ // Test special names
174174+ if let Some(level) = fast_name_level {
175175+ assert_eq!(validator.name_to_level("fast"), Some(level));
176176+ } else {
177177+ assert_eq!(validator.name_to_level("fast"), None);
178178+ }
179179+180180+ if let Some(level) = best_name_level {
181181+ assert_eq!(validator.name_to_level("best"), Some(level));
182182+ } else {
183183+ assert_eq!(validator.name_to_level("best"), None);
184184+ }
185185+186186+ if let Some(level) = none_name_level {
187187+ assert_eq!(validator.name_to_level("none"), Some(level));
188188+ } else {
189189+ assert_eq!(validator.name_to_level("none"), None);
190190+ }
191191+192192+ // Test invalid name
193193+ assert_eq!(validator.name_to_level("invalid"), None);
194194+}
+182-43
src/utils.rs
···55use std::path::{Path, PathBuf};
66use std::str::FromStr;
7788+/// Enum to represent whether a compressor extracts to a file or directory by default
99+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1010+pub enum ExtractedTarget {
1111+ /// Extract to a single file (e.g., gzip, bzip2, xz)
1212+ FILE,
1313+ /// Extract to a directory (e.g., zip, tar)
1414+ DIRECTORY,
1515+}
1616+817#[derive(Args, Debug)]
918pub struct CommonArgs {
1019 /// Input file/directory
···154163 self.name()
155164 }
156165166166+ /// Determine if this compressor extracts to a file or directory by default
167167+ /// FILE compressors (like gzip, bzip2, xz) extract to a single file
168168+ /// DIRECTORY compressors (like zip, tar) extract to a directory
169169+ fn default_extracted_target(&self) -> ExtractedTarget {
170170+ ExtractedTarget::FILE
171171+ }
172172+157173 /// Detect if the input is an archive of this type
158174 /// Just checks the extension by default
159175 /// Some compressors may overwrite this to do more advanced detection
···179195180196 /// Generate the default extracted filename
181197 fn default_extracted_filename(&self, in_path: &Path) -> String {
182182- // If the file has the extension for this type, return the filename without the extension
183183- if in_path.extension().unwrap() == self.extension() {
184184- return in_path.file_stem().unwrap().to_str().unwrap().to_string();
198198+ if self.default_extracted_target() == ExtractedTarget::DIRECTORY {
199199+ return ".".to_string();
185200 }
201201+186202 // If the file has no extension, return the current directory
187187- if in_path.extension().is_none() {
188188- return ".".to_string();
203203+ if let Some(ext) = in_path.extension() {
204204+ // If the file has the extension for this type, return the filename without the extension
205205+ if let Some(ext_str) = ext.to_str() {
206206+ if ext_str == self.extension() {
207207+ if let Some(stem) = in_path.file_stem() {
208208+ if let Some(stem_str) = stem.to_str() {
209209+ return stem_str.to_string();
210210+ }
211211+ }
212212+ }
213213+ }
189214 }
190190- // Otherwise, return the current directory and hope for the best
191191- ".".to_string()
215215+ "archive".to_string()
192216 }
193217194194- fn compress(&self, input: CmprssInput, output: CmprssOutput) -> Result<(), io::Error> {
195195- cmprss_error("compress_target unimplemented")
196196- }
218218+ fn compress(&self, input: CmprssInput, output: CmprssOutput) -> Result<(), io::Error>;
197219198198- fn extract(&self, input: CmprssInput, output: CmprssOutput) -> Result<(), io::Error> {
199199- cmprss_error("extract_target unimplemented")
200200- }
220220+ fn extract(&self, input: CmprssInput, output: CmprssOutput) -> Result<(), io::Error>;
201221}
202222203223impl fmt::Debug for dyn Compressor {
···229249#[cfg(test)]
230250mod tests {
231251 use super::*;
252252+ use std::io;
253253+ use std::path::Path;
254254+255255+ /// A simple implementation of the Compressor trait for testing
256256+ struct TestCompressor;
257257+258258+ impl Compressor for TestCompressor {
259259+ fn name(&self) -> &str {
260260+ "test"
261261+ }
262262+263263+ // We'll use the default implementation for extension() and other methods
264264+265265+ fn compress(&self, _: CmprssInput, _: CmprssOutput) -> Result<(), io::Error> {
266266+ // Return success for testing purposes
267267+ Ok(())
268268+ }
269269+270270+ fn extract(&self, _: CmprssInput, _: CmprssOutput) -> Result<(), io::Error> {
271271+ // Return success for testing purposes
272272+ Ok(())
273273+ }
274274+ }
275275+276276+ /// A compressor that overrides the default extension
277277+ struct CustomExtensionCompressor;
278278+279279+ impl Compressor for CustomExtensionCompressor {
280280+ fn name(&self) -> &str {
281281+ "custom"
282282+ }
283283+284284+ fn extension(&self) -> &str {
285285+ "cst"
286286+ }
287287+288288+ fn compress(&self, _: CmprssInput, _: CmprssOutput) -> Result<(), io::Error> {
289289+ Ok(())
290290+ }
291291+292292+ fn extract(&self, _: CmprssInput, _: CmprssOutput) -> Result<(), io::Error> {
293293+ Ok(())
294294+ }
295295+ }
296296+297297+ #[test]
298298+ fn test_default_name_extension() {
299299+ let compressor = TestCompressor;
300300+ assert_eq!(compressor.name(), "test");
301301+ assert_eq!(compressor.extension(), "test");
302302+ }
303303+304304+ #[test]
305305+ fn test_custom_extension() {
306306+ let compressor = CustomExtensionCompressor;
307307+ assert_eq!(compressor.name(), "custom");
308308+ assert_eq!(compressor.extension(), "cst");
309309+ }
310310+311311+ #[test]
312312+ fn test_is_archive_detection() {
313313+ use tempfile::tempdir;
314314+315315+ let compressor = TestCompressor;
316316+ let temp_dir = tempdir().expect("Failed to create temp dir");
317317+318318+ // Test with matching extension
319319+ let archive_path = temp_dir.path().join("archive.test");
320320+ std::fs::File::create(&archive_path).expect("Failed to create test file");
321321+ assert!(compressor.is_archive(&archive_path));
322322+323323+ // Test with non-matching extension
324324+ let non_archive_path = temp_dir.path().join("archive.txt");
325325+ std::fs::File::create(&non_archive_path).expect("Failed to create test file");
326326+ assert!(!compressor.is_archive(&non_archive_path));
327327+328328+ // Test with no extension
329329+ let no_ext_path = temp_dir.path().join("archive");
330330+ std::fs::File::create(&no_ext_path).expect("Failed to create test file");
331331+ assert!(!compressor.is_archive(&no_ext_path));
332332+ }
333333+334334+ #[test]
335335+ fn test_default_compressed_filename() {
336336+ let compressor = TestCompressor;
337337+338338+ // Test with normal filename
339339+ let path = Path::new("file.txt");
340340+ assert_eq!(
341341+ compressor.default_compressed_filename(path),
342342+ "file.txt.test"
343343+ );
344344+345345+ // Test with no extension
346346+ let path = Path::new("file");
347347+ assert_eq!(compressor.default_compressed_filename(path), "file.test");
348348+ }
349349+350350+ #[test]
351351+ fn test_default_extracted_filename() {
352352+ let compressor = TestCompressor;
353353+354354+ // Test with matching extension
355355+ let path = Path::new("archive.test");
356356+ assert_eq!(compressor.default_extracted_filename(path), "archive");
357357+358358+ // Test with non-matching extension
359359+ let path = Path::new("archive.txt");
360360+ assert_eq!(compressor.default_extracted_filename(path), "archive");
361361+362362+ // Test with no extension
363363+ let path = Path::new("archive");
364364+ assert_eq!(compressor.default_extracted_filename(path), "archive");
365365+ }
232366233367 #[test]
234368 fn test_compression_level_parsing() {
235235- // Test numeric values
236236- assert_eq!(CompressionLevel::from_str("-7").unwrap().level, -7);
237237- assert_eq!(CompressionLevel::from_str("0").unwrap().level, 0);
369369+ // Test numeric levels
238370 assert_eq!(CompressionLevel::from_str("1").unwrap().level, 1);
239371 assert_eq!(CompressionLevel::from_str("9").unwrap().level, 9);
240240- assert_eq!(CompressionLevel::from_str("22").unwrap().level, 22);
241372242242- // Test special names (these use DefaultCompressionValidator values)
243243- assert_eq!(CompressionLevel::from_str("none").unwrap().level, 0);
244244- assert_eq!(CompressionLevel::from_str("fast").unwrap().level, 1);
245245- assert_eq!(CompressionLevel::from_str("best").unwrap().level, 9);
373373+ // Test named levels
374374+ let validator = DefaultCompressionValidator;
375375+ assert_eq!(
376376+ CompressionLevel::from_str("fast").unwrap().level,
377377+ validator.name_to_level("fast").unwrap()
378378+ );
379379+ assert_eq!(
380380+ CompressionLevel::from_str("best").unwrap().level,
381381+ validator.name_to_level("best").unwrap()
382382+ );
246383247384 // Test invalid values
248248- assert!(CompressionLevel::from_str("foo").is_err());
385385+ assert!(CompressionLevel::from_str("invalid").is_err());
249386 }
250387251388 #[test]
252252- fn test_default_compression_validator() {
389389+ fn test_compression_level_defaults() {
390390+ let default_level = CompressionLevel::default();
253391 let validator = DefaultCompressionValidator;
254254-255255- // Test range
256256- assert_eq!(validator.min_level(), 0);
257257- assert_eq!(validator.max_level(), 9);
258258- assert_eq!(validator.default_level(), 6);
392392+ assert_eq!(default_level.level, validator.default_level());
393393+ }
259394260260- // Test validation
261261- assert!(validator.is_valid_level(0));
262262- assert!(validator.is_valid_level(5));
263263- assert!(validator.is_valid_level(9));
264264- assert!(!validator.is_valid_level(-1));
265265- assert!(!validator.is_valid_level(10));
395395+ #[test]
396396+ fn test_cmprss_error() {
397397+ let result = cmprss_error("test error");
398398+ assert!(result.is_err());
399399+ assert_eq!(result.unwrap_err().to_string(), "test error");
400400+ }
266401267267- // Test clamping
268268- assert_eq!(validator.validate_and_clamp_level(-1), 0);
269269- assert_eq!(validator.validate_and_clamp_level(5), 5);
270270- assert_eq!(validator.validate_and_clamp_level(10), 9);
402402+ #[test]
403403+ fn test_default_compression_validator() {
404404+ let validator = DefaultCompressionValidator;
271405272272- // Test special names
273273- assert_eq!(validator.name_to_level("none"), Some(0));
274274- assert_eq!(validator.name_to_level("fast"), Some(1));
275275- assert_eq!(validator.name_to_level("best"), Some(9));
276276- assert_eq!(validator.name_to_level("invalid"), None);
406406+ use crate::test_utils::test_compression_validator_helper;
407407+ test_compression_validator_helper(
408408+ &validator,
409409+ 0, // min_level
410410+ 9, // max_level
411411+ 6, // default_level
412412+ Some(1), // fast_name_level
413413+ Some(9), // best_name_level
414414+ Some(0), // none_name_level
415415+ );
277416 }
278417}