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