use std::fmt;
use std::fs::File;
use std::io::{self, Write};
use std::path::Path;
use std::str::FromStr;
use anyhow::{Context, Result, bail};
use serde::Deserialize;
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(try_from = "String")]
pub enum Format {
Tar,
#[default]
TarGz,
}
impl Format {
pub fn extension(self) -> &'static str {
match self {
Format::Tar => "tar",
Format::TarGz => "tar.gz",
}
}
}
impl FromStr for Format {
type Err = anyhow::Error;
fn from_str(name: &str) -> Result<Self, Self::Err> {
let format = match name {
"tar" => Format::Tar,
"tar.gz" | "tgz" | "gz" | "gzip" => Format::TarGz,
"tar.bz2" | "tbz2" | "tbz" | "bz2" | "bzip2" => {
bail!("archive format `{name}` is not currently supported");
},
"tar.xz" | "txz" | "xz" | "lzma" => {
bail!("archive format `{name}` is not currently supported");
},
"tar.zst" | "tzst" | "zst" | "zstd" => {
bail!("archive format `{name}` is not currently supported");
},
_ => {
bail!("unknown archive format `{name}` (expected one of: tar, tar.gz)");
},
};
Ok(format)
}
}
impl TryFrom<String> for Format {
type Error = anyhow::Error;
fn try_from(name: String) -> Result<Self, Self::Error> {
name.parse()
}
}
impl fmt::Display for Format {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.extension())
}
}
pub fn write_archive(tar: &Path, format: Format, level: u32, output: &Path) -> Result<u64> {
let mut source =
File::open(tar).with_context(|| format!("failed to read `{}`", tar.display()))?;
let file =
File::create(output).with_context(|| format!("failed to create `{}`", output.display()))?;
let mut writer = Encoder::open(file, format, level)?;
io::copy(&mut source, &mut writer)
.with_context(|| format!("failed to write `{}`", output.display()))?;
let file = writer
.finish()
.with_context(|| format!("failed to finish `{}`", output.display()))?;
file.sync_all()
.with_context(|| format!("failed to flush `{}`", output.display()))?;
let size = file
.metadata()
.with_context(|| format!("failed to inspect `{}`", output.display()))?
.len();
Ok(size)
}
#[allow(clippy::large_enum_variant)]
enum Encoder {
Plain(File),
Gzip(flate2::write::GzEncoder<File>),
}
impl Encoder {
fn open(file: File, format: Format, level: u32) -> Result<Self> {
let encoder = match format {
Format::Tar => Encoder::Plain(file),
Format::TarGz => {
Encoder::Gzip(flate2::write::GzEncoder::new(file, flate2::Compression::new(level)))
},
};
Ok(encoder)
}
}
impl Encoder {
fn finish(self) -> io::Result<File> {
match self {
Encoder::Plain(mut file) => {
file.flush()?;
Ok(file)
},
Encoder::Gzip(encoder) => encoder.finish(),
}
}
}
impl Write for Encoder {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
match self {
Encoder::Plain(file) => file.write(buf),
Encoder::Gzip(encoder) => encoder.write(buf),
}
}
fn flush(&mut self) -> io::Result<()> {
match self {
Encoder::Plain(file) => file.flush(),
Encoder::Gzip(encoder) => encoder.flush(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_spellings_round_trip() {
for (spelling, expected) in [
("tar", Format::Tar),
("tgz", Format::TarGz),
("tar.gz", Format::TarGz),
] {
assert_eq!(Format::from_str(spelling).unwrap(), expected, "{spelling}");
}
for format in [Format::Tar, Format::TarGz] {
assert_eq!(Format::from_str(format.extension()).unwrap(), format);
}
}
#[test]
fn test_unsupported_formats_explain_themselves() {
for spelling in ["tar.xz", "txz", "tar.zst", "zstd"] {
let message = Format::from_str(spelling).unwrap_err().to_string();
assert!(message.contains("not currently supported"), "{spelling}: {message}");
}
let message = Format::from_str("tar.lz4").unwrap_err().to_string();
assert!(message.contains("unknown archive format"), "{message}");
}
}