this repo has no description
1//! Job inference — maps user-provided CLI args and filenames into a concrete
2//! `Compressor` + action + input/output triple.
3//!
4//! Most of the user-facing ergonomics of `cmprss` live here: guessing whether
5//! we're compressing or extracting, whether the input is piped from stdin,
6//! which compressor a filename implies, and how to dispatch compound extensions
7//! like `.tar.gz` or `.tgz` into a pipeline.
8
9use anyhow::{anyhow, bail};
10use is_terminal::IsTerminal;
11use std::path::{Path, PathBuf};
12
13use crate::backends::{self, Pipeline};
14use crate::utils::{CmprssInput, CmprssOutput, CommonArgs, Compressor, Result};
15
16/// Extract an action hint from the CLI flags. Returns `None` when the user
17/// hasn't specified `--compress`/`--extract`/`--decompress`, in which case
18/// the action will be inferred from filenames downstream.
19fn action_from_flags(args: &CommonArgs) -> Option<Action> {
20 if args.compress {
21 Some(Action::Compress)
22 } else if args.extract || args.decompress {
23 Some(Action::Extract)
24 } else {
25 None
26 }
27}
28
29/// Partition the CLI path arguments (`-i`, `-o`, and the positional `io_list`)
30/// into a list of input paths and an optional output path.
31///
32/// The heuristic for which trailing `io_list` entry becomes the output:
33/// * If it doesn't exist on disk → output (we'll create it).
34/// * If it exists and is a directory, and the action hint is `Extract` →
35/// output (extract into the directory).
36/// * Otherwise → treat as an input. This preserves the existing behavior for
37/// `cmprss tar file1.txt file2.txt existing_dir/`, where the trailing
38/// directory is treated as another input to archive.
39fn partition_paths(
40 args: &CommonArgs,
41 action_hint: Option<Action>,
42) -> Result<(Vec<PathBuf>, Option<PathBuf>)> {
43 let mut inputs = Vec::new();
44 if let Some(in_file) = &args.input {
45 inputs
46 .push(get_path(in_file).ok_or_else(|| anyhow!("Specified input path does not exist"))?);
47 }
48
49 let mut output: Option<PathBuf> = match &args.output {
50 Some(output) => {
51 let path = PathBuf::from(output);
52 if !args.force && path.try_exists()? && !path.is_dir() {
53 bail!("Specified output path already exists (use --force to overwrite)");
54 }
55 Some(path)
56 }
57 None => None,
58 };
59
60 let mut io_list = args.io_list.clone();
61 if output.is_none()
62 && let Some(possible_output) = io_list.last()
63 {
64 let path = PathBuf::from(possible_output);
65 if !path.try_exists()? {
66 output = Some(path);
67 io_list.pop();
68 } else if path.is_dir() && action_hint == Some(Action::Extract) {
69 // Only treat an existing directory as the output when the user
70 // hinted extraction. In Compress/Unknown, we keep it as another
71 // input — this matches e.g. `cmprss tar dir1/ dir2/`.
72 output = Some(path);
73 io_list.pop();
74 } else if !path.is_dir() && args.force {
75 // With --force, a trailing existing file is taken as the output
76 // (to overwrite). Without --force we fall through to treating it
77 // as another input.
78 output = Some(path);
79 io_list.pop();
80 }
81 // TODO: check for scenarios where we want to append to an existing archive.
82 }
83
84 for input in &io_list {
85 inputs.push(get_path(input).ok_or_else(|| anyhow!("Specified input path does not exist"))?);
86 }
87
88 Ok((inputs, output))
89}
90
91/// Turn the collected input paths into a `CmprssInput`, falling back to
92/// stdin when no paths were given and stdin is piped.
93fn resolve_input(inputs: Vec<PathBuf>, args: &CommonArgs) -> Result<CmprssInput> {
94 if !inputs.is_empty() {
95 return Ok(CmprssInput::Path(inputs));
96 }
97 if !std::io::stdin().is_terminal() && !args.ignore_pipes && !args.ignore_stdin {
98 return Ok(CmprssInput::Pipe(std::io::stdin()));
99 }
100 bail!("No specified input");
101}
102
103/// Whether we can send the output to stdout (piped, and the user hasn't
104/// suppressed pipe inference).
105fn stdout_pipe_usable(args: &CommonArgs) -> bool {
106 !std::io::stdout().is_terminal() && !args.ignore_pipes && !args.ignore_stdout
107}
108
109/// Defines a single compress/extract action to take.
110#[derive(Debug)]
111pub struct Job {
112 pub compressor: Box<dyn Compressor>,
113 pub input: CmprssInput,
114 pub output: CmprssOutput,
115 pub action: Action,
116}
117
118#[derive(Debug, PartialEq, Clone, Copy)]
119pub enum Action {
120 Compress,
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,
126}
127
128/// Parse the common args and determine the details of the job requested.
129///
130/// The resolution has three phases:
131/// 1. Collect explicit signals from CLI flags (action hint, `-i`/`-o`, the
132/// positional `io_list`) into an input list and an optional output path.
133/// 2. Build the final `CmprssInput` (falling back to stdin if no paths).
134/// 3. Decide the output and the missing pieces of (compressor, action). This
135/// branches on how the output is determined: an explicit path, stdout pipe,
136/// or a filename we invent from the resolved compressor + action.
137pub 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
155 let action_hint = action_from_flags(common_args);
156 let (input_paths, output_path) = partition_paths(common_args, action_hint)?;
157 let input = resolve_input(input_paths, common_args)?;
158
159 // Branch 1: user gave us an output path. Resolve compressor + action
160 // using both sides' extensions.
161 if let Some(path) = output_path {
162 let output = CmprssOutput::Path(path);
163 let (compressor, action) = finalize_with_output(compressor, action_hint, &input, &output)?;
164 return Ok(Job {
165 compressor,
166 input,
167 output,
168 action,
169 });
170 }
171
172 // Branch 2: stdout is a pipe. Same resolution, but the output has no path.
173 if stdout_pipe_usable(common_args) {
174 let output = CmprssOutput::Pipe(std::io::stdout());
175 let (compressor, action) = finalize_with_output(compressor, action_hint, &input, &output)?;
176 return Ok(Job {
177 compressor,
178 input,
179 output,
180 action,
181 });
182 }
183
184 // Branch 3: no output and stdout is a terminal. We must invent a filename,
185 // which requires the compressor and action up front.
186 let (compressor, action) = finalize_without_output(compressor, action_hint, &input)?;
187 let default_name = match action {
188 Action::Compress => compressor.default_compressed_filename(get_input_filename(&input)?),
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"),
192 };
193 Ok(Job {
194 compressor,
195 input,
196 output: CmprssOutput::Path(PathBuf::from(default_name)),
197 action,
198 })
199}
200
201/// Finalize compressor + action when the output is already materialized
202/// (either `CmprssOutput::Path` or `CmprssOutput::Pipe`).
203fn finalize_with_output(
204 mut compressor: Option<Box<dyn Compressor>>,
205 mut action: Option<Action>,
206 input: &CmprssInput,
207 output: &CmprssOutput,
208) -> Result<(Box<dyn Compressor>, Action)> {
209 if compressor.is_none() || action.is_none() {
210 fill_missing_from_io(&mut compressor, &mut action, input, output)?;
211 }
212 let compressor = compressor.ok_or_else(|| anyhow!("Could not determine compressor to use"))?;
213 let action = action.ok_or_else(|| anyhow!("Could not determine action to take"))?;
214 Ok((compressor, action))
215}
216
217/// Finalize compressor + action when no output path is known (stdout is a
218/// terminal and we'll invent a filename next). All inference must come from
219/// the input side.
220fn finalize_without_output(
221 compressor: Option<Box<dyn Compressor>>,
222 action: Option<Action>,
223 input: &CmprssInput,
224) -> Result<(Box<dyn Compressor>, Action)> {
225 let input_path = get_input_filename(input)?;
226 match action {
227 Some(Action::Compress) => {
228 let c = compressor.ok_or_else(|| anyhow!("Must specify a compressor"))?;
229 Ok((c, Action::Compress))
230 }
231 Some(Action::Extract) => {
232 let c = compressor
233 .or_else(|| get_compressor_from_filename(input_path))
234 .ok_or_else(|| anyhow!("Must specify a compressor"))?;
235 Ok((c, Action::Extract))
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"),
240 None => match compressor {
241 Some(c) => {
242 // Compare the compressor's extension against the input's.
243 let action = match get_compressor_from_filename(input_path) {
244 Some(ic) if ic.name() == c.name() => Action::Extract,
245 _ => Action::Compress,
246 };
247 Ok((c, action))
248 }
249 None => {
250 // The input has to be something we can identify as an archive.
251 let c = get_compressor_from_filename(input_path)
252 .ok_or_else(|| anyhow!("Must specify a compressor"))?;
253 Ok((c, Action::Extract))
254 }
255 },
256 }
257}
258
259/// Fill in a missing compressor and/or action by inspecting the input and
260/// output shapes. Called after the output is known; covers every combination
261/// of (Path, Pipe) input × (Path, Pipe) output.
262fn fill_missing_from_io(
263 compressor: &mut Option<Box<dyn Compressor>>,
264 action: &mut Option<Action>,
265 input: &CmprssInput,
266 output: &CmprssOutput,
267) -> Result {
268 match *action {
269 Some(Action::Compress) => {
270 if let CmprssOutput::Path(path) = output {
271 *compressor = get_compressor_from_filename(path);
272 }
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"),
276 Some(Action::Extract) => {
277 if let CmprssInput::Path(paths) = input {
278 if paths.len() != 1 {
279 bail!("Expected a single archive to extract");
280 }
281 *compressor = get_compressor_from_filename(paths.first().unwrap());
282 }
283 }
284 None => match (input, output) {
285 (CmprssInput::Path(paths), CmprssOutput::Path(path)) => {
286 if path.is_dir() && paths.len() == 1 {
287 *compressor = get_compressor_from_filename(paths.first().unwrap());
288 *action = Some(Action::Extract);
289 if compressor.is_none() {
290 bail!(
291 "Couldn't determine how to extract {:?}",
292 paths.first().unwrap()
293 );
294 }
295 } else {
296 let (c, a) = guess_from_filenames(paths, path, compressor.take())?;
297 *compressor = Some(c);
298 *action = Some(a);
299 }
300 }
301 (CmprssInput::Path(paths), CmprssOutput::Pipe(_)) => {
302 if let Some(c) = compressor.as_deref() {
303 *action = Some(match get_compressor_from_filename(paths.first().unwrap()) {
304 Some(ic) if ic.name() == c.name() => Action::Extract,
305 _ => Action::Compress,
306 });
307 } else {
308 if paths.len() != 1 {
309 bail!("Expected a single input file for piping to stdout");
310 }
311 *compressor = get_compressor_from_filename(paths.first().unwrap());
312 if compressor.is_some() {
313 *action = Some(Action::Extract);
314 } else {
315 bail!("Can't guess compressor to use");
316 }
317 }
318 }
319 (CmprssInput::Pipe(_), CmprssOutput::Path(path)) => {
320 if let Some(c) = compressor.as_deref() {
321 *action = Some(
322 if get_compressor_from_filename(path)
323 .is_some_and(|pc| c.name() == pc.name())
324 {
325 Action::Compress
326 } else {
327 Action::Extract
328 },
329 );
330 } else {
331 *compressor = get_compressor_from_filename(path);
332 if compressor.is_some() {
333 *action = Some(Action::Compress);
334 } else {
335 bail!("Can't guess compressor to use");
336 }
337 }
338 }
339 (CmprssInput::Pipe(_), CmprssOutput::Pipe(_)) => {
340 *action = Some(Action::Compress);
341 }
342 // Writer output and Reader input are only constructed internally
343 // by the Pipeline compressor; they don't reach get_job from main.
344 (_, CmprssOutput::Writer(_)) => *action = Some(Action::Compress),
345 (CmprssInput::Reader(_), _) => *action = Some(Action::Extract),
346 },
347 }
348 Ok(())
349}
350
351/// Get the input filename or return a default file
352/// This file will be used to generate the output filename
353fn get_input_filename(input: &CmprssInput) -> Result<&Path> {
354 match input {
355 CmprssInput::Path(paths) => match paths.first() {
356 Some(path) => Ok(path),
357 None => bail!("error: no input specified"),
358 },
359 CmprssInput::Pipe(_) => Ok(Path::new("archive")),
360 CmprssInput::Reader(_) => Ok(Path::new("piped_data")),
361 }
362}
363
364/// Expand a compound shortcut extension like `.tgz` into its equivalent
365/// compressor chain, in innermost-to-outermost order. Returns `None` for
366/// extensions that aren't a known shortcut.
367fn expand_shortcut_ext(ext: &str) -> Option<&'static [&'static str]> {
368 match ext {
369 "tgz" => Some(&["tar", "gz"]),
370 "tbz" | "tbz2" => Some(&["tar", "bz2"]),
371 "txz" => Some(&["tar", "xz"]),
372 "tzst" => Some(&["tar", "zst"]),
373 _ => None,
374 }
375}
376
377/// Get a compressor pipeline from a filename by scanning extensions right-to-left
378pub fn get_compressor_from_filename(filename: &Path) -> Option<Box<dyn Compressor>> {
379 let file_name = filename.file_name()?.to_str()?;
380 let parts: Vec<&str> = file_name.split('.').collect();
381
382 if parts.len() < 2 {
383 return None;
384 }
385
386 // Scan extensions right-to-left, collecting known compressors
387 // until hitting an unknown extension or the base name.
388 // e.g., "a.b.tar.gz" → gz ✓, tar ✓, b ✗ stop → [gz, tar]
389 // Compound shortcuts like "tgz" expand to their component chain, so
390 // "archive.tgz" behaves identically to "archive.tar.gz".
391 let mut compressor_names: Vec<String> = Vec::new();
392 for ext in parts[1..].iter().rev() {
393 if let Some(chain) = expand_shortcut_ext(ext) {
394 // chain is innermost→outermost; we push in right-to-left order
395 // (outermost first) to match how we're walking the filename.
396 for name in chain.iter().rev() {
397 compressor_names.push((*name).to_string());
398 }
399 } else if let Some(c) = backends::compressor_from_str(ext) {
400 compressor_names.push(c.name().to_string());
401 } else {
402 break;
403 }
404 }
405
406 if compressor_names.is_empty() {
407 return None;
408 }
409
410 // Reverse to innermost-to-outermost order
411 compressor_names.reverse();
412 Pipeline::from_names(&compressor_names)
413 .ok()
414 .map(|m| Box::new(m) as Box<dyn Compressor>)
415}
416
417/// Convert an input path into a Path
418fn get_path(input: &str) -> Option<PathBuf> {
419 let path = PathBuf::from(input);
420 if !path.try_exists().unwrap_or(false) {
421 return None;
422 }
423 Some(path)
424}
425
426/// Guess compressor/action from the two filenames. The compressor may already
427/// be given via the subcommand.
428///
429/// Returns an error when the two filenames don't give enough information to
430/// pick an action (e.g. the same format on both sides and the output isn't a
431/// directory).
432fn guess_from_filenames(
433 input: &[PathBuf],
434 output: &Path,
435 compressor: Option<Box<dyn Compressor>>,
436) -> Result<(Box<dyn Compressor>, Action)> {
437 if input.len() != 1 {
438 if let Some(c) = get_compressor_from_filename(output) {
439 return Ok((c, Action::Compress));
440 }
441 if output.is_dir()
442 && let Some(first) = input.first()
443 && let Some(c) = get_compressor_from_filename(first)
444 {
445 return Ok((c, Action::Extract));
446 }
447 // No extension hint anywhere, but we were given a compressor —
448 // assume the user wants to extract multiple archives to a directory.
449 let c = compressor.ok_or_else(|| anyhow!("Could not determine compressor to use"))?;
450 return Ok((c, Action::Extract));
451 }
452 let input = input.first().unwrap();
453
454 let output_guess = get_compressor_from_filename(output);
455 let input_guess = get_compressor_from_filename(input);
456
457 // If the user supplied a compressor via subcommand, pick the action by
458 // matching its name against the input/output extensions.
459 if let Some(c) = compressor {
460 let action = if output_guess
461 .as_ref()
462 .is_some_and(|og| og.name() == c.name())
463 {
464 Action::Compress
465 } else if input_guess.as_ref().is_some_and(|ig| ig.name() == c.name()) {
466 Action::Extract
467 } else {
468 // Extensions don't match on either side; default to compressing.
469 Action::Compress
470 };
471 return Ok((c, action));
472 }
473
474 match (output_guess, input_guess) {
475 (None, None) => bail!("Could not determine compressor to use"),
476 (Some(c), None) => Ok((c, Action::Compress)),
477 (None, Some(e)) => Ok((e, Action::Extract)),
478 (Some(c), Some(e)) => {
479 // Both sides carry a known extension — decide whether this is
480 // adding or stripping a single outer layer (e.g. tar → tar.gz).
481 let input_file = input.file_name().unwrap().to_str().unwrap();
482 let input_ext = input.extension().unwrap_or_default();
483 let output_file = output.file_name().unwrap().to_str().unwrap();
484 let output_ext = output.extension().unwrap_or_default();
485 let layer_added = input_file.to_string() + "." + output_ext.to_str().unwrap();
486 let layer_stripped = output_file.to_string() + "." + input_ext.to_str().unwrap();
487
488 if layer_added == output_file {
489 // input="archive.tar", output="archive.tar.gz" — add the outer layer only.
490 let single =
491 backends::compressor_from_str(output_ext.to_str().unwrap_or("")).unwrap_or(c);
492 Ok((single, Action::Compress))
493 } else if layer_stripped == input_file {
494 // input="archive.tar.gz", output="archive.tar" — strip the outer layer only.
495 let single =
496 backends::compressor_from_str(input_ext.to_str().unwrap_or("")).unwrap_or(e);
497 Ok((single, Action::Extract))
498 } else if c.name() == e.name() {
499 // Same format on both sides: only meaningful when the output
500 // is a directory (extracting in place).
501 if output.is_dir() {
502 Ok((e, Action::Extract))
503 } else {
504 bail!("Could not determine action to take");
505 }
506 } else if output.is_dir() {
507 Ok((e, Action::Extract))
508 } else {
509 bail!("Could not determine action to take");
510 }
511 }
512 }
513}
514
515#[cfg(test)]
516mod tests {
517 use super::*;
518 use crate::utils::ExtractedTarget;
519 use std::path::Path;
520
521 fn compressor_name(path: &str) -> Option<String> {
522 get_compressor_from_filename(Path::new(path)).map(|c| c.name().to_string())
523 }
524
525 fn compressor_extension(path: &str) -> Option<String> {
526 get_compressor_from_filename(Path::new(path)).map(|c| c.extension().to_string())
527 }
528
529 #[test]
530 fn test_single_extension() {
531 assert_eq!(compressor_name("file.gz"), Some("gzip".into()));
532 assert_eq!(compressor_name("file.xz"), Some("xz".into()));
533 assert_eq!(compressor_name("file.bz2"), Some("bzip2".into()));
534 assert_eq!(compressor_name("file.zst"), Some("zstd".into()));
535 assert_eq!(compressor_name("file.lz4"), Some("lz4".into()));
536 assert_eq!(compressor_name("file.br"), Some("brotli".into()));
537 assert_eq!(compressor_name("file.sz"), Some("snappy".into()));
538 assert_eq!(compressor_name("file.lzma"), Some("lzma".into()));
539 assert_eq!(compressor_name("file.tar"), Some("tar".into()));
540 assert_eq!(compressor_name("file.zip"), Some("zip".into()));
541 }
542
543 #[test]
544 fn test_multi_extension() {
545 assert_eq!(compressor_name("archive.tar.gz"), Some("gzip".into()));
546 assert_eq!(compressor_name("archive.tar.xz"), Some("xz".into()));
547 assert_eq!(compressor_name("archive.tar.bz2"), Some("bzip2".into()));
548 assert_eq!(compressor_name("archive.tar.zst"), Some("zstd".into()));
549 }
550
551 #[test]
552 fn test_shortcut_extensions() {
553 // Shortcut extensions resolve to a tar + outer compressor pipeline,
554 // so the reported name is the outer compressor (same as the long form).
555 assert_eq!(compressor_name("archive.tgz"), Some("gzip".into()));
556 assert_eq!(compressor_name("archive.tbz"), Some("bzip2".into()));
557 assert_eq!(compressor_name("archive.tbz2"), Some("bzip2".into()));
558 assert_eq!(compressor_name("archive.txz"), Some("xz".into()));
559 assert_eq!(compressor_name("archive.tzst"), Some("zstd".into()));
560 }
561
562 #[test]
563 fn test_shortcut_extensions_extract_to_directory() {
564 // Shortcuts are tar-based, so they must extract to a directory.
565 for path in ["a.tgz", "a.tbz", "a.tbz2", "a.txz", "a.tzst"] {
566 let c = get_compressor_from_filename(Path::new(path)).unwrap();
567 assert_eq!(
568 c.default_extracted_target(),
569 ExtractedTarget::Directory,
570 "{path} should extract to a directory",
571 );
572 }
573 }
574
575 #[test]
576 fn test_unknown_middle_extension() {
577 // "b" is not a compressor, so only tar.gz should be detected
578 assert_eq!(compressor_name("a.b.tar.gz"), Some("gzip".into()));
579 assert_eq!(compressor_name("report.2024.tar.gz"), Some("gzip".into()));
580 }
581
582 #[test]
583 fn test_no_recognized_extension() {
584 assert_eq!(compressor_name("file.txt"), None);
585 assert_eq!(compressor_name("file.pdf"), None);
586 assert_eq!(compressor_name("file"), None);
587 }
588
589 #[test]
590 fn test_default_filenames_single_pipeline() {
591 let c = get_compressor_from_filename(Path::new("file.gz")).unwrap();
592 assert_eq!(
593 c.default_compressed_filename(Path::new("data.txt")),
594 "data.txt.gz"
595 );
596 assert_eq!(c.default_extracted_filename(Path::new("data.gz")), "data");
597 }
598
599 #[test]
600 fn test_default_filenames_multi_pipeline() {
601 let c = get_compressor_from_filename(Path::new("archive.tar.gz")).unwrap();
602 assert_eq!(
603 c.default_compressed_filename(Path::new("data")),
604 "data.tar.gz"
605 );
606 // tar.gz extracts to a directory, so extracted filename is "."
607 assert_eq!(c.default_extracted_filename(Path::new("data.tar.gz")), ".");
608 }
609
610 #[test]
611 fn test_is_archive_single_pipeline() {
612 let c = get_compressor_from_filename(Path::new("file.gz")).unwrap();
613 assert!(c.is_archive(Path::new("test.gz")));
614 assert!(!c.is_archive(Path::new("test.xz")));
615 }
616
617 #[test]
618 fn test_is_archive_multi_pipeline() {
619 let c = get_compressor_from_filename(Path::new("archive.tar.gz")).unwrap();
620 assert!(c.is_archive(Path::new("foo.tar.gz")));
621 assert!(!c.is_archive(Path::new("foo.gz")));
622 }
623
624 #[test]
625 fn test_extracted_target_single_pipeline() {
626 let gz = get_compressor_from_filename(Path::new("file.gz")).unwrap();
627 assert_eq!(gz.default_extracted_target(), ExtractedTarget::File);
628
629 let tar = get_compressor_from_filename(Path::new("file.tar")).unwrap();
630 assert_eq!(tar.default_extracted_target(), ExtractedTarget::Directory);
631 }
632
633 #[test]
634 fn test_extracted_target_multi_pipeline() {
635 // tar.gz: innermost is tar, which extracts to directory
636 let c = get_compressor_from_filename(Path::new("archive.tar.gz")).unwrap();
637 assert_eq!(c.default_extracted_target(), ExtractedTarget::Directory);
638 }
639
640 #[test]
641 fn test_single_extension_returns_correct_extension() {
642 assert_eq!(compressor_extension("file.gz"), Some("gz".into()));
643 assert_eq!(compressor_extension("file.tar"), Some("tar".into()));
644 }
645}