use std::fmt;
use camino::{Utf8Path, Utf8PathBuf};
use crate::cmd::Format;
use crate::error::{Error, Result};
impl fmt::Display for Format {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
Self::Zip => "zip",
Self::Tar => "tar",
Self::TarGz => "tar-gz",
Self::TarZst => "tar-zst",
Self::TarXz => "tar-xz",
Self::TarBz2 => "tar-bz2",
Self::SevenZ => "7z",
};
f.write_str(s)
}
}
fn ends_with_ci(s: &str, suffix: &str) -> bool {
let sb = s.as_bytes();
let tb = suffix.as_bytes();
sb.len() >= tb.len() && sb[sb.len() - tb.len()..].eq_ignore_ascii_case(tb)
}
impl Format {
pub fn from_path(path: &Utf8Path) -> Option<Self> {
let s = path.as_str();
if ends_with_ci(s, ".tar.gz") || ends_with_ci(s, ".tgz") {
Some(Self::TarGz)
} else if ends_with_ci(s, ".tar.zst") || ends_with_ci(s, ".tzst") {
Some(Self::TarZst)
} else if ends_with_ci(s, ".tar.xz") || ends_with_ci(s, ".txz") {
Some(Self::TarXz)
} else if ends_with_ci(s, ".tar.bz2") || ends_with_ci(s, ".tbz2") {
Some(Self::TarBz2)
} else if ends_with_ci(s, ".tar") {
Some(Self::Tar)
} else if ends_with_ci(s, ".zip") {
Some(Self::Zip)
} else if ends_with_ci(s, ".7z") {
Some(Self::SevenZ)
} else {
None
}
}
pub fn from_magic(path: &Utf8Path) -> Option<Self> {
let kind = infer::get_from_path(path.as_std_path()).ok()??;
match kind.mime_type() {
"application/gzip" => Some(Self::TarGz),
"application/zstd" => Some(Self::TarZst),
"application/x-xz" => Some(Self::TarXz),
"application/x-bzip2" => Some(Self::TarBz2),
"application/zip" => Some(Self::Zip),
"application/x-7z-compressed" => Some(Self::SevenZ),
_ => None,
}
}
pub fn from_magic_bytes(buf: &[u8]) -> Option<Self> {
let kind = infer::get(buf)?;
match kind.mime_type() {
"application/gzip" => Some(Self::TarGz),
"application/zstd" => Some(Self::TarZst),
"application/x-xz" => Some(Self::TarXz),
"application/x-bzip2" => Some(Self::TarBz2),
"application/zip" => Some(Self::Zip),
"application/x-7z-compressed" => Some(Self::SevenZ),
"application/x-tar" => Some(Self::Tar),
_ => None,
}
}
pub fn extension(&self) -> &'static str {
match self {
Self::Zip => ".zip",
Self::Tar => ".tar",
Self::TarGz => ".tar.gz",
Self::TarZst => ".tar.zst",
Self::TarXz => ".tar.xz",
Self::TarBz2 => ".tar.bz2",
Self::SevenZ => ".7z",
}
}
pub fn recognized_extensions(&self) -> &'static [&'static str] {
match self {
Self::Zip => &[".zip"],
Self::Tar => &[".tar"],
Self::TarGz => &[".tar.gz", ".tgz"],
Self::TarZst => &[".tar.zst", ".tzst"],
Self::TarXz => &[".tar.xz", ".txz"],
Self::TarBz2 => &[".tar.bz2", ".tbz2"],
Self::SevenZ => &[".7z"],
}
}
pub fn derive_output_dir(&self, input: &Utf8Path) -> Utf8PathBuf {
let name = input.file_name().unwrap_or("");
for ext in self.recognized_extensions() {
if ends_with_ci(name, ext) {
let stem = &name[..name.len() - ext.len()];
return if stem.is_empty() {
Utf8PathBuf::from("archive")
} else {
Utf8PathBuf::from(stem)
};
}
}
if name.is_empty() {
Utf8PathBuf::from("archive")
} else {
Utf8PathBuf::from(name)
}
}
pub fn default_output(&self, first_input: &Utf8Path) -> Utf8PathBuf {
let stem = first_input.file_name().unwrap_or("archive");
Utf8PathBuf::from(format!("{stem}{}", self.extension()))
}
}
pub fn ensure_format_enabled(fmt: &Format) -> Result<()> {
#[cfg(not(feature = "bzip2"))]
if matches!(fmt, Format::TarBz2) {
return Err(Error::FormatFeatureDisabled {
format: fmt.to_string(),
feature: "bzip2",
});
}
let _ = fmt;
Ok(())
}
pub fn resolve_compress_format(
explicit: Option<Format>,
output: Option<&Utf8Path>,
) -> Result<Format> {
if let Some(f) = explicit {
return Ok(f);
}
if let Some(out) = output {
if let Some(f) = Format::from_path(out) {
return Ok(f);
}
return Err(Error::CannotInferFormat(out.to_owned()));
}
Err(Error::CannotInferOutput)
}
pub fn resolve_input_format(explicit: Option<Format>, input: &Utf8Path) -> Result<Format> {
if let Some(f) = explicit {
return Ok(f);
}
if let Some(f) = Format::from_magic(input) {
return Ok(f);
}
Format::from_path(input).ok_or_else(|| Error::CannotInferFormat(input.to_owned()))
}
#[cfg(test)]
mod tests {
use camino::{Utf8Path, Utf8PathBuf};
use crate::cmd::Format;
use crate::format::{resolve_compress_format, resolve_input_format};
#[test]
fn from_path_tar_gz() {
assert_eq!(
Format::from_path(Utf8Path::new("a.tar.gz")),
Some(Format::TarGz)
);
}
#[test]
fn from_path_tgz() {
assert_eq!(
Format::from_path(Utf8Path::new("a.tgz")),
Some(Format::TarGz)
);
}
#[test]
fn from_path_tar_zst() {
assert_eq!(
Format::from_path(Utf8Path::new("a.tar.zst")),
Some(Format::TarZst)
);
}
#[test]
fn from_path_tzst() {
assert_eq!(
Format::from_path(Utf8Path::new("a.tzst")),
Some(Format::TarZst)
);
}
#[test]
fn from_path_tar_xz() {
assert_eq!(
Format::from_path(Utf8Path::new("a.tar.xz")),
Some(Format::TarXz)
);
}
#[test]
fn from_path_txz() {
assert_eq!(
Format::from_path(Utf8Path::new("a.txz")),
Some(Format::TarXz)
);
}
#[test]
fn from_path_tar_bz2() {
assert_eq!(
Format::from_path(Utf8Path::new("a.tar.bz2")),
Some(Format::TarBz2)
);
}
#[test]
fn from_path_tbz2() {
assert_eq!(
Format::from_path(Utf8Path::new("a.tbz2")),
Some(Format::TarBz2)
);
}
#[test]
fn from_path_tar() {
assert_eq!(Format::from_path(Utf8Path::new("a.tar")), Some(Format::Tar));
}
#[test]
fn from_path_zip() {
assert_eq!(Format::from_path(Utf8Path::new("a.zip")), Some(Format::Zip));
}
#[test]
fn from_path_seven_z() {
assert_eq!(
Format::from_path(Utf8Path::new("a.7z")),
Some(Format::SevenZ)
);
}
#[test]
fn from_path_unknown_returns_none() {
assert_eq!(Format::from_path(Utf8Path::new("a.rar")), None);
}
#[test]
fn from_path_no_extension_returns_none() {
assert_eq!(Format::from_path(Utf8Path::new("noext")), None);
}
#[test]
fn from_path_is_case_insensitive() {
assert_eq!(
Format::from_path(Utf8Path::new("A.TAR.GZ")),
Some(Format::TarGz)
);
assert_eq!(
Format::from_path(Utf8Path::new("B.Tar.Bz2")),
Some(Format::TarBz2)
);
assert_eq!(Format::from_path(Utf8Path::new("C.ZIP")), Some(Format::Zip));
}
#[test]
fn from_path_with_directory_prefix() {
assert_eq!(
Format::from_path(Utf8Path::new("/some/dir/archive.tar.gz")),
Some(Format::TarGz),
);
}
#[test]
fn extension_round_trips_with_from_path() {
let formats = [
Format::Zip,
Format::Tar,
Format::TarGz,
Format::TarZst,
Format::TarXz,
Format::TarBz2,
Format::SevenZ,
];
for fmt in &formats {
let name = format!("test{}", fmt.extension());
let detected = Format::from_path(Utf8Path::new(&name));
assert_eq!(
detected.as_ref(),
Some(fmt),
"round-trip failed for {}",
fmt.extension()
);
}
}
#[test]
fn default_output_appends_extension() {
let out = Format::TarGz.default_output(Utf8Path::new("mydir"));
assert_eq!(out, Utf8PathBuf::from("mydir.tar.gz"));
}
#[test]
fn default_output_strips_parent_directory() {
let out = Format::Zip.default_output(Utf8Path::new("/home/user/mydir"));
assert_eq!(out, Utf8PathBuf::from("mydir.zip"));
}
#[test]
fn default_output_root_falls_back_to_archive() {
let out = Format::TarGz.default_output(Utf8Path::new("/"));
assert_eq!(out, Utf8PathBuf::from("archive.tar.gz"));
}
#[test]
fn default_output_dot_falls_back_to_archive() {
let out = Format::Zip.default_output(Utf8Path::new("."));
assert_eq!(out, Utf8PathBuf::from("archive.zip"));
}
#[test]
fn default_output_parent_falls_back_to_archive() {
let out = Format::TarZst.default_output(Utf8Path::new(".."));
assert_eq!(out, Utf8PathBuf::from("archive.tar.zst"));
}
#[test]
fn default_output_empty_falls_back_to_archive() {
let out = Format::SevenZ.default_output(Utf8Path::new(""));
assert_eq!(out, Utf8PathBuf::from("archive.7z"));
}
#[test]
fn default_output_trailing_slash_uses_basename() {
let out = Format::TarBz2.default_output(Utf8Path::new("foo/"));
assert_eq!(out, Utf8PathBuf::from("foo.tar.bz2"));
}
#[test]
fn derive_output_dir_strips_canonical_extension() {
let out = Format::TarGz.derive_output_dir(Utf8Path::new("foo.tar.gz"));
assert_eq!(out, Utf8PathBuf::from("foo"));
}
#[test]
fn derive_output_dir_strips_short_alias() {
let out = Format::TarGz.derive_output_dir(Utf8Path::new("foo.tgz"));
assert_eq!(out, Utf8PathBuf::from("foo"));
}
#[test]
fn derive_output_dir_strips_directory_prefix() {
let out = Format::Zip.derive_output_dir(Utf8Path::new("/some/dir/bundle.zip"));
assert_eq!(out, Utf8PathBuf::from("bundle"));
}
#[test]
fn derive_output_dir_is_case_insensitive() {
let out = Format::TarGz.derive_output_dir(Utf8Path::new("Foo.TAR.GZ"));
assert_eq!(out, Utf8PathBuf::from("Foo"));
}
#[test]
fn derive_output_dir_extension_only_falls_back_to_archive() {
let out = Format::TarGz.derive_output_dir(Utf8Path::new(".tar.gz"));
assert_eq!(out, Utf8PathBuf::from("archive"));
}
#[test]
fn derive_output_dir_root_falls_back_to_archive() {
let out = Format::TarGz.derive_output_dir(Utf8Path::new("/"));
assert_eq!(out, Utf8PathBuf::from("archive"));
}
#[test]
fn derive_output_dir_keeps_filename_when_format_was_forced() {
let out = Format::Zip.derive_output_dir(Utf8Path::new("mystery"));
assert_eq!(out, Utf8PathBuf::from("mystery"));
}
#[test]
fn derive_output_dir_seven_z() {
let out = Format::SevenZ.derive_output_dir(Utf8Path::new("backup.7z"));
assert_eq!(out, Utf8PathBuf::from("backup"));
}
#[test]
fn derive_output_dir_round_trips_with_default_output() {
let formats = [
Format::Zip,
Format::Tar,
Format::TarGz,
Format::TarZst,
Format::TarXz,
Format::TarBz2,
Format::SevenZ,
];
for fmt in &formats {
let archive = fmt.default_output(Utf8Path::new("payload"));
let derived = fmt.derive_output_dir(&archive);
assert_eq!(
derived,
Utf8PathBuf::from("payload"),
"round-trip failed for {fmt}",
);
}
}
#[test]
fn resolve_compress_explicit_flag_wins() {
let result = resolve_compress_format(Some(Format::TarGz), Some(Utf8Path::new("out.zip")));
assert_eq!(result.ok(), Some(Format::TarGz));
}
#[test]
fn resolve_compress_infers_from_output_extension() {
let result = resolve_compress_format(None, Some(Utf8Path::new("out.tar.zst")));
assert_eq!(result.ok(), Some(Format::TarZst));
}
#[test]
fn resolve_compress_unknown_output_extension_errors() {
assert!(resolve_compress_format(None, Some(Utf8Path::new("out.rar"))).is_err());
}
#[test]
fn resolve_compress_no_format_no_output_errors() {
assert!(resolve_compress_format(None, None).is_err());
}
#[test]
fn resolve_input_explicit_flag_wins() {
let result = resolve_input_format(Some(Format::SevenZ), Utf8Path::new("a.tar.gz"));
assert_eq!(result.ok(), Some(Format::SevenZ));
}
#[test]
fn resolve_input_falls_back_to_extension() {
let result = resolve_input_format(None, Utf8Path::new("nonexistent.tar.bz2"));
assert_eq!(result.ok(), Some(Format::TarBz2));
}
#[test]
fn resolve_input_unknown_extension_errors() {
assert!(resolve_input_format(None, Utf8Path::new("nonexistent.rar")).is_err());
}
#[test]
fn from_magic_bytes_detects_gzip() {
assert_eq!(
Format::from_magic_bytes(&[0x1f, 0x8b, 0x08, 0x00]),
Some(Format::TarGz)
);
}
#[test]
fn from_magic_bytes_detects_tar() {
let mut buf = vec![0u8; 512];
buf[257..262].copy_from_slice(b"ustar");
assert_eq!(Format::from_magic_bytes(&buf), Some(Format::Tar));
}
#[test]
fn from_magic_bytes_short_prefix_returns_none() {
assert_eq!(Format::from_magic_bytes(&[0u8; 16]), None);
}
#[test]
fn from_magic_bytes_unknown_returns_none() {
assert_eq!(Format::from_magic_bytes(b"not an archive at all"), None);
}
}