this repo has no description
0
fork

Configure Feed

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

feat(cli): add --list / -l to print archive contents

Add a List action alongside Compress/Extract, wire --list / -l to
short-circuit through get_job (no output slot needed), and introduce
a default Compressor::list that bails for stream codecs.

Real implementations land on Tar (iterates Archive entries), Zip
(iterates ZipArchive::file_names — tempfiles non-seekable input),
and Pipeline (reuses the multi-stage pipe plumbing from extract:
outer layers decompress through an in-memory pipe into the innermost
container format, which lists).

Pipelines such as tar.gz therefore list correctly; single stream
codecs like gzip correctly fail with a specific message.

Covered by four integration tests in tests/list.rs.

+260 -1
+48
src/backends/pipeline.rs
··· 322 322 } 323 323 Ok(()) 324 324 } 325 + 326 + fn list(&self, input: CmprssInput) -> Result { 327 + debug_assert!(!self.compressors.is_empty(), "pipeline is never empty"); 328 + 329 + if self.compressors.len() == 1 { 330 + return self.compressors[0].list(input); 331 + } 332 + 333 + // Same plumbing as `extract`, except the innermost compressor lists 334 + // its entries to stdout instead of unpacking to an output path. Outer 335 + // layers still need to decompress into an in-memory pipe so that the 336 + // innermost container format sees plain archive bytes. 337 + let mut op_extractors: Vec<Box<dyn Compressor>> = self 338 + .compressors 339 + .iter() 340 + .rev() 341 + .map(|c| Self::create_compressor(c.name())) 342 + .collect::<Result<Vec<_>>>()?; 343 + 344 + let mut handles = Vec::new(); 345 + let mut current_thread_input = input; 346 + let buffer_size = 64 * 1024; 347 + 348 + for _ in 0..op_extractors.len() - 1 { 349 + let extractor = op_extractors.remove(0); 350 + let (sender, receiver) = channel::<Vec<u8>>(); 351 + let pipe_writer = PipeWriter::new(sender, buffer_size); 352 + let stage_output = CmprssOutput::Writer(WriteWrapper(Box::new(pipe_writer))); 353 + let next_stage_input = 354 + CmprssInput::Reader(ReadWrapper(Box::new(PipeReader::new(receiver)))); 355 + 356 + let stage_input = current_thread_input; 357 + current_thread_input = next_stage_input; 358 + 359 + let handle = thread::spawn(move || extractor.extract(stage_input, stage_output)); 360 + handles.push(handle); 361 + } 362 + 363 + let innermost = op_extractors.remove(0); 364 + innermost.list(current_thread_input)?; 365 + 366 + for handle in handles { 367 + handle 368 + .join() 369 + .map_err(|_| anyhow!("Extraction thread panicked"))??; 370 + } 371 + Ok(()) 372 + } 325 373 } 326 374 327 375 #[cfg(test)]
+23 -1
src/backends/tar.rs
··· 3 3 use anyhow::bail; 4 4 use clap::Args; 5 5 use std::fs::File; 6 - use std::io::{self, Seek, SeekFrom, Write}; 6 + use std::io::{self, Read, Seek, SeekFrom, Write}; 7 7 use tar::{Archive, Builder}; 8 8 use tempfile::tempfile; 9 9 ··· 123 123 } 124 124 }, 125 125 } 126 + } 127 + 128 + fn list(&self, input: CmprssInput) -> Result { 129 + let reader: Box<dyn Read> = match input { 130 + CmprssInput::Path(paths) => { 131 + if paths.len() != 1 { 132 + bail!("tar listing expects a single archive file"); 133 + } 134 + Box::new(File::open(&paths[0])?) 135 + } 136 + CmprssInput::Pipe(stdin) => Box::new(stdin), 137 + CmprssInput::Reader(reader) => reader.0, 138 + }; 139 + let mut archive = Archive::new(reader); 140 + let stdout = io::stdout(); 141 + let mut out = stdout.lock(); 142 + for entry in archive.entries()? { 143 + let entry = entry?; 144 + let path = entry.path()?; 145 + writeln!(out, "{}", path.display())?; 146 + } 147 + Ok(()) 126 148 } 127 149 } 128 150
+37
src/backends/zip.rs
··· 158 158 }, 159 159 } 160 160 } 161 + 162 + fn list(&self, input: CmprssInput) -> Result { 163 + // ZipArchive requires a seekable reader. For non-path inputs we must 164 + // buffer into a tempfile first. 165 + let stdout = io::stdout(); 166 + let mut out = stdout.lock(); 167 + match input { 168 + CmprssInput::Path(paths) => { 169 + if paths.len() != 1 { 170 + bail!("zip listing expects a single archive file"); 171 + } 172 + let archive = ZipArchive::new(File::open(&paths[0])?)?; 173 + for name in archive.file_names() { 174 + writeln!(out, "{}", name)?; 175 + } 176 + } 177 + CmprssInput::Pipe(mut pipe) => { 178 + let mut temp = tempfile()?; 179 + io::copy(&mut pipe, &mut temp)?; 180 + temp.seek(SeekFrom::Start(0))?; 181 + let archive = ZipArchive::new(temp)?; 182 + for name in archive.file_names() { 183 + writeln!(out, "{}", name)?; 184 + } 185 + } 186 + CmprssInput::Reader(mut reader) => { 187 + let mut temp = tempfile()?; 188 + io::copy(&mut reader, &mut temp)?; 189 + temp.seek(SeekFrom::Start(0))?; 190 + let archive = ZipArchive::new(temp)?; 191 + for name in archive.file_names() { 192 + writeln!(out, "{}", name)?; 193 + } 194 + } 195 + } 196 + Ok(()) 197 + } 161 198 } 162 199 163 200 fn add_directory<W: Write + Seek>(zip: &mut ZipWriter<W>, base: &Path, path: &Path) -> Result {
+28
src/job.rs
··· 119 119 pub enum Action { 120 120 Compress, 121 121 Extract, 122 + /// Print the archive's file listing to stdout. Only meaningful for 123 + /// container formats; stream codecs fall through to `Compressor::list`'s 124 + /// default bail. 125 + List, 122 126 } 123 127 124 128 /// Parse the common args and determine the details of the job requested. ··· 131 135 /// branches on how the output is determined: an explicit path, stdout pipe, 132 136 /// or a filename we invent from the resolved compressor + action. 133 137 pub fn get_job(compressor: Option<Box<dyn Compressor>>, common_args: &CommonArgs) -> Result<Job> { 138 + // --list short-circuits the output/action machinery: there's no output 139 + // file, the action is fixed, and we only need the input and compressor. 140 + if common_args.list { 141 + let (input_paths, _) = partition_paths(common_args, Some(Action::List))?; 142 + let input = resolve_input(input_paths, common_args)?; 143 + let compressor = compressor 144 + .or_else(|| get_compressor_from_filename(get_input_filename(&input).ok()?)) 145 + .ok_or_else(|| anyhow!("Could not determine compressor to use"))?; 146 + return Ok(Job { 147 + compressor, 148 + input, 149 + // List writes to stdout directly; this output slot is unused. 150 + output: CmprssOutput::Pipe(std::io::stdout()), 151 + action: Action::List, 152 + }); 153 + } 154 + 134 155 let action_hint = action_from_flags(common_args); 135 156 let (input_paths, output_path) = partition_paths(common_args, action_hint)?; 136 157 let input = resolve_input(input_paths, common_args)?; ··· 166 187 let default_name = match action { 167 188 Action::Compress => compressor.default_compressed_filename(get_input_filename(&input)?), 168 189 Action::Extract => compressor.default_extracted_filename(get_input_filename(&input)?), 190 + // List short-circuits above; finalize_without_output never returns it. 191 + Action::List => unreachable!("List is handled before Branch 3"), 169 192 }; 170 193 Ok(Job { 171 194 compressor, ··· 211 234 .ok_or_else(|| anyhow!("Must specify a compressor"))?; 212 235 Ok((c, Action::Extract)) 213 236 } 237 + // List is handled by the short-circuit at the top of get_job and 238 + // never flows into this helper. 239 + Some(Action::List) => unreachable!("List is handled before Branch 3"), 214 240 None => match compressor { 215 241 Some(c) => { 216 242 // Compare the compressor's extension against the input's. ··· 245 271 *compressor = get_compressor_from_filename(path); 246 272 } 247 273 } 274 + // List is handled by the short-circuit at the top of get_job. 275 + Some(Action::List) => unreachable!("List is handled before fill_missing_from_io"), 248 276 Some(Action::Extract) => { 249 277 if let CmprssInput::Path(paths) = input { 250 278 if paths.len() != 1 {
+1
src/main.rs
··· 90 90 match job.action { 91 91 Action::Compress => job.compressor.compress(job.input, job.output), 92 92 Action::Extract => job.compressor.extract(job.input, job.output), 93 + Action::List => job.compressor.list(job.input), 93 94 } 94 95 } 95 96
+17
src/utils.rs
··· 59 59 /// Overwrite the output path if it already exists. 60 60 #[arg(short, long)] 61 61 pub force: bool, 62 + 63 + /// List the contents of an archive (for container formats like tar and zip). 64 + #[arg(short, long)] 65 + pub list: bool, 62 66 } 63 67 64 68 /// Trait for validating compression levels for different compressors ··· 233 237 fn compress(&self, input: CmprssInput, output: CmprssOutput) -> Result; 234 238 235 239 fn extract(&self, input: CmprssInput, output: CmprssOutput) -> Result; 240 + 241 + /// List the contents of the archive to stdout. 242 + /// 243 + /// The default implementation bails: only container formats — `tar`, 244 + /// `zip`, and pipelines whose innermost layer is one of those — can 245 + /// meaningfully enumerate their contents. Stream codecs (gzip, xz, …) 246 + /// just compress a single byte stream and have nothing to list. 247 + fn list(&self, _input: CmprssInput) -> Result { 248 + anyhow::bail!( 249 + "{} archives cannot be listed; only container formats (tar, zip) support --list", 250 + self.name() 251 + ) 252 + } 236 253 } 237 254 238 255 impl fmt::Debug for dyn Compressor {
+106
tests/list.rs
··· 1 + use assert_cmd::prelude::*; 2 + use assert_fs::prelude::*; 3 + use predicates::prelude::*; 4 + use std::process::Command; 5 + 6 + mod common; 7 + use common::*; 8 + 9 + mod list { 10 + use super::*; 11 + 12 + /// `cmprss --list archive.tar` prints every entry's path, one per line. 13 + #[test] 14 + fn tar_archive() -> Result<(), Box<dyn std::error::Error>> { 15 + let working_dir = create_working_dir()?; 16 + let a = working_dir.child("alpha.txt"); 17 + a.write_str("alpha")?; 18 + let b = working_dir.child("beta.txt"); 19 + b.write_str("beta")?; 20 + 21 + let mut pack = Command::cargo_bin("cmprss")?; 22 + pack.current_dir(&working_dir) 23 + .args(["tar", "alpha.txt", "beta.txt", "out.tar"]); 24 + pack.assert().success(); 25 + 26 + let mut list = Command::cargo_bin("cmprss")?; 27 + list.current_dir(&working_dir).args(["--list", "out.tar"]); 28 + list.assert() 29 + .success() 30 + .stdout(predicate::str::contains("alpha.txt")) 31 + .stdout(predicate::str::contains("beta.txt")); 32 + Ok(()) 33 + } 34 + 35 + /// `cmprss --list archive.zip` enumerates file names via ZipArchive. 36 + #[test] 37 + fn zip_archive() -> Result<(), Box<dyn std::error::Error>> { 38 + let working_dir = create_working_dir()?; 39 + let a = working_dir.child("first.txt"); 40 + a.write_str("first")?; 41 + let b = working_dir.child("second.txt"); 42 + b.write_str("second")?; 43 + 44 + let mut pack = Command::cargo_bin("cmprss")?; 45 + pack.current_dir(&working_dir) 46 + .args(["zip", "first.txt", "second.txt", "out.zip"]); 47 + pack.assert().success(); 48 + 49 + let mut list = Command::cargo_bin("cmprss")?; 50 + list.current_dir(&working_dir).args(["--list", "out.zip"]); 51 + list.assert() 52 + .success() 53 + .stdout(predicate::str::contains("first.txt")) 54 + .stdout(predicate::str::contains("second.txt")); 55 + Ok(()) 56 + } 57 + 58 + /// Pipelines whose innermost layer is a container format list through 59 + /// the in-memory pipe plumbing shared with extract. 60 + #[test] 61 + fn tar_gz_archive() -> Result<(), Box<dyn std::error::Error>> { 62 + let working_dir = create_working_dir()?; 63 + let a = working_dir.child("inside.txt"); 64 + a.write_str("inside")?; 65 + 66 + let mut pack = Command::cargo_bin("cmprss")?; 67 + pack.current_dir(&working_dir) 68 + .args(["tar", "inside.txt", "out.tar"]); 69 + pack.assert().success(); 70 + 71 + let mut gz = Command::cargo_bin("cmprss")?; 72 + gz.current_dir(&working_dir) 73 + .args(["gzip", "out.tar", "out.tar.gz"]); 74 + gz.assert().success(); 75 + 76 + let mut list = Command::cargo_bin("cmprss")?; 77 + list.current_dir(&working_dir) 78 + .args(["--list", "out.tar.gz"]); 79 + list.assert() 80 + .success() 81 + .stdout(predicate::str::contains("inside.txt")); 82 + Ok(()) 83 + } 84 + 85 + /// Stream codecs (gzip, xz, …) don't carry multiple entries — listing 86 + /// must fail loudly rather than silently doing nothing. 87 + #[test] 88 + fn gzip_stream_rejects_list() -> Result<(), Box<dyn std::error::Error>> { 89 + let working_dir = create_working_dir()?; 90 + let a = working_dir.child("doc.txt"); 91 + a.write_str("doc")?; 92 + 93 + let mut pack = Command::cargo_bin("cmprss")?; 94 + pack.current_dir(&working_dir) 95 + .args(["gzip", "doc.txt", "doc.txt.gz"]); 96 + pack.assert().success(); 97 + 98 + let mut list = Command::cargo_bin("cmprss")?; 99 + list.current_dir(&working_dir) 100 + .args(["--list", "doc.txt.gz"]); 101 + list.assert() 102 + .failure() 103 + .stderr(predicate::str::contains("cannot be listed")); 104 + Ok(()) 105 + } 106 + }