cargo-parcel 0.0.4

Experimental extended cargo installer
Documentation
use std::{
    ffi::OsString,
    path::{Path, PathBuf},
    process::Command,
    str::FromStr,
};

use anyhow::{bail, Context};
use pico_args::Arguments;
use tempfile::TempDir;

use crate::{install::InstallOpt, util, Parcel};

#[derive(Debug, Clone)]
pub struct Options {
    prefix_skip: usize,
    prefix: PathBuf,
    root: PathBuf,
    config: Parcel,
    strip: bool,
    target: Option<String>,
    output: Output,
    tar_command: String,
}

#[derive(Debug, Clone)]
enum Output {
    Path(String),
    Stdout,
}

impl FromStr for Output {
    type Err = anyhow::Error;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        if s == "-" {
            Ok(Output::Stdout)
        } else {
            Ok(Output::Path(s.into()))
        }
    }
}

impl Options {
    pub fn from_args(mut args: Arguments, config: &Parcel) -> anyhow::Result<Self> {
        let prefix = util::get_prefix_opt(&mut args)?;
        let opt = Options {
            prefix_skip: prefix.components().count(),
            prefix,
            root: args.opt_value_from_str("--root")?.unwrap_or_else(|| {
                format!("{}-{}", config.pkg_name(), config.pkg_version()).into()
            }),
            config: config.clone(),
            strip: !args.contains("--no-strip"),
            target: args.opt_value_from_str("--target")?,
            output: args.opt_value_from_str("-o")?.unwrap_or_else(|| {
                Output::Path(format!(
                    "{}-{}.tar.gz",
                    config.pkg_name(),
                    config.pkg_version()
                ))
            }),
            tar_command: args
                .opt_value_from_str("--tar")?
                .unwrap_or_else(|| "tar".to_owned()),
        };
        let remainder = args.finish();
        if !remainder.is_empty() {
            bail!("unconsumed arguments: {}", util::DisplayArgs(remainder.iter()));
        }
        Ok(opt)
    }
}

static TAR_COMPRESSION_FLAGS: &[(&str, Option<&str>)] = &[
    (".tar", None),
    (".tar.gz", Some("-z")),
    // The `-j` option is supported by both GNU and BSD tar.
    (".tar.bz2", Some("-j")),
    // The "--xz" option is a GNU extension.
    (".tar.xz", Some("--xz")),
    // As is `--zstd`.
    (".tar.zstd", Some("--zstd")),
];

pub fn create(opt: &Options) -> anyhow::Result<()> {
    let tmp_dir = TempDir::new()?;
    InstallOpt::new(opt.config.clone(), &opt.prefix)
        .dest_dir(tmp_dir.path())
        .strip_binaries(opt.strip)
        .cargo_target(opt.target.as_ref().map(String::as_str))
        .install()?;
    use std::path::Component;
    let skip_prefix: PathBuf = opt
        .prefix
        .components()
        .skip_while(|c| {
            if let Component::Normal(_) = c {
                false
            } else {
                true
            }
        })
        .take(opt.prefix_skip)
        .collect();
    let mut transform = OsString::new();
    // TODO: need to check for that `opt.root` is relative when validating the
    // options.
    transform.push("--transform=s,^.,./");
    // FIXME: escape commas
    transform.push(&opt.root);
    transform.push(",");
    let out_args = match &opt.output {
        Output::Path(o) => {
            let mut out_args = vec!["-f", o];
            if let Some(name) = Path::new(o).file_name().and_then(|f| f.to_str()) {
                let compression_flag = TAR_COMPRESSION_FLAGS
                    .iter()
                    .find_map(|(suffix, flag)| if name.ends_with(suffix) { Some(flag) } else { None })
                    .unwrap_or_else(|| {
                        eprintln!("warning: output file with unknown extension, creating an uncompressed tar archive");
                        &None
                    });
                out_args.extend(compression_flag.iter());
            } else {
                eprintln!(
                    "warning: not looking at extension in non-UTF8 output file name {}",
                    o
                );
            }
            out_args
        }
        Output::Stdout => vec![],
    };
    let tar_status = Command::new(&opt.tar_command)
        .arg("-C")
        .arg(tmp_dir.path().join(&skip_prefix))
        .args(&["-c", "."])
        .arg(&transform)
        .args(out_args)
        .status()
        .with_context(|| "running `tar` to create archive failed")?;
    if !tar_status.success() {
        bail!(
            "running `tar` to create archive failed with exit status {}",
            tar_status
        );
    }
    Ok(())
}