this repo has no description
0
fork

Configure Feed

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

at f7bbb7638e8ed85d5c92d23cfdcf38ed665b3b21 645 lines 26 kB view raw
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}