···9898cmprss archive.tar.xz archive.tar
9999```
100100101101+The common compound shortcut extensions also work and behave identically to their long forms:
102102+103103+| Shortcut | Equivalent to |
104104+| -------------- | ------------- |
105105+| `.tgz` | `.tar.gz` |
106106+| `.tbz`/`.tbz2` | `.tar.bz2` |
107107+| `.txz` | `.tar.xz` |
108108+| `.tzst` | `.tar.zst` |
109109+101110Pipes can still be used if preferred:
102111103112```bash
+46-1
src/main.rs
···8989 action: Action,
9090}
91919292+/// Expand a compound shortcut extension like `.tgz` into its equivalent
9393+/// compressor chain, in innermost-to-outermost order. Returns `None` for
9494+/// extensions that aren't a known shortcut.
9595+fn expand_shortcut_ext(ext: &str) -> Option<&'static [&'static str]> {
9696+ match ext {
9797+ "tgz" => Some(&["tar", "gz"]),
9898+ "tbz" | "tbz2" => Some(&["tar", "bz2"]),
9999+ "txz" => Some(&["tar", "xz"]),
100100+ "tzst" => Some(&["tar", "zst"]),
101101+ _ => None,
102102+ }
103103+}
104104+92105/// Get a compressor pipeline from a filename by scanning extensions right-to-left
93106fn get_compressor_from_filename(filename: &Path) -> Option<Box<dyn Compressor>> {
94107 let file_name = filename.file_name()?.to_str()?;
···101114 // Scan extensions right-to-left, collecting known compressors
102115 // until hitting an unknown extension or the base name.
103116 // e.g., "a.b.tar.gz" → gz ✓, tar ✓, b ✗ stop → [gz, tar]
117117+ // Compound shortcuts like "tgz" expand to their component chain, so
118118+ // "archive.tgz" behaves identically to "archive.tar.gz".
104119 let mut compressor_names: Vec<String> = Vec::new();
105120 for ext in parts[1..].iter().rev() {
106106- if let Some(c) = backends::compressor_from_str(ext) {
121121+ if let Some(chain) = expand_shortcut_ext(ext) {
122122+ // chain is innermost→outermost; we push in right-to-left order
123123+ // (outermost first) to match how we're walking the filename.
124124+ for name in chain.iter().rev() {
125125+ compressor_names.push((*name).to_string());
126126+ }
127127+ } else if let Some(c) = backends::compressor_from_str(ext) {
107128 compressor_names.push(c.name().to_string());
108129 } else {
109130 break;
···563584 assert_eq!(compressor_name("archive.tar.xz"), Some("xz".into()));
564585 assert_eq!(compressor_name("archive.tar.bz2"), Some("bzip2".into()));
565586 assert_eq!(compressor_name("archive.tar.zst"), Some("zstd".into()));
587587+ }
588588+589589+ #[test]
590590+ fn test_shortcut_extensions() {
591591+ // Shortcut extensions resolve to a tar + outer compressor pipeline,
592592+ // so the reported name is the outer compressor (same as the long form).
593593+ assert_eq!(compressor_name("archive.tgz"), Some("gzip".into()));
594594+ assert_eq!(compressor_name("archive.tbz"), Some("bzip2".into()));
595595+ assert_eq!(compressor_name("archive.tbz2"), Some("bzip2".into()));
596596+ assert_eq!(compressor_name("archive.txz"), Some("xz".into()));
597597+ assert_eq!(compressor_name("archive.tzst"), Some("zstd".into()));
598598+ }
599599+600600+ #[test]
601601+ fn test_shortcut_extensions_extract_to_directory() {
602602+ // Shortcuts are tar-based, so they must extract to a directory.
603603+ for path in ["a.tgz", "a.tbz", "a.tbz2", "a.txz", "a.tzst"] {
604604+ let c = get_compressor_from_filename(Path::new(path)).unwrap();
605605+ assert_eq!(
606606+ c.default_extracted_target(),
607607+ ExtractedTarget::DIRECTORY,
608608+ "{path} should extract to a directory",
609609+ );
610610+ }
566611 }
567612568613 #[test]
+70
tests/shortcuts.rs
···11+use assert_cmd::prelude::*;
22+use assert_fs::prelude::*;
33+use predicates::prelude::*;
44+use std::process::Command;
55+66+mod common;
77+use common::*;
88+99+/// Roundtrip helper: compress two files into `archive.<ext>` and extract into
1010+/// a fresh directory, asserting that the original files come back identical.
1111+fn shortcut_roundtrip(ext: &str) -> Result<(), Box<dyn std::error::Error>> {
1212+ let file = create_test_file("test.txt", "garbage data for testing")?;
1313+ let file2 = create_test_file("test2.txt", "more garbage data for testing")?;
1414+ let working_dir = create_working_dir()?;
1515+ let archive_name = format!("archive.{ext}");
1616+ let archive = working_dir.child(&archive_name);
1717+ archive.assert(predicate::path::missing());
1818+1919+ let mut compress = Command::cargo_bin("cmprss")?;
2020+ compress
2121+ .current_dir(&working_dir)
2222+ .arg("--ignore-pipes")
2323+ .arg(file.path())
2424+ .arg(file2.path())
2525+ .arg(&archive_name);
2626+ compress.assert().success();
2727+ archive.assert(predicate::path::is_file());
2828+2929+ let extract_dir = create_working_dir()?;
3030+ let mut extract = Command::cargo_bin("cmprss")?;
3131+ extract
3232+ .current_dir(&extract_dir)
3333+ .arg("--ignore-pipes")
3434+ .arg(archive.path());
3535+ extract.assert().success();
3636+3737+ assert_files_equal(file.path(), &extract_dir.child("test.txt"));
3838+ assert_files_equal(file2.path(), &extract_dir.child("test2.txt"));
3939+4040+ Ok(())
4141+}
4242+4343+mod shortcuts {
4444+ use super::*;
4545+4646+ #[test]
4747+ fn tgz() -> Result<(), Box<dyn std::error::Error>> {
4848+ shortcut_roundtrip("tgz")
4949+ }
5050+5151+ #[test]
5252+ fn tbz() -> Result<(), Box<dyn std::error::Error>> {
5353+ shortcut_roundtrip("tbz")
5454+ }
5555+5656+ #[test]
5757+ fn tbz2() -> Result<(), Box<dyn std::error::Error>> {
5858+ shortcut_roundtrip("tbz2")
5959+ }
6060+6161+ #[test]
6262+ fn txz() -> Result<(), Box<dyn std::error::Error>> {
6363+ shortcut_roundtrip("txz")
6464+ }
6565+6666+ #[test]
6767+ fn tzst() -> Result<(), Box<dyn std::error::Error>> {
6868+ shortcut_roundtrip("tzst")
6969+ }
7070+}