cargo-equip 0.13.0

A Cargo subcommand to bundle your code into one `.rs` file for competitive programming.
Documentation
use crate::workspace::PackageExt as _;
use anyhow::{anyhow, bail, Context as _};
use cargo_metadata as cm;
use serde::Deserialize;
use std::path::Path;

pub(super) fn license_file(package: &cm::Package) -> anyhow::Result<Option<String>> {
    let license = package
        .license
        .as_deref()
        .with_context(|| format!("`{}`: missing `license`", package.id))?;

    let license = normalize(license);

    let license = spdx::Expression::parse(license).map_err(|e| {
        anyhow!("{}", e).context(format!("`{}`: could not parse `license`", package.id))
    })?;

    let is = |name| license.evaluate(|r| r.license.id() == spdx::license_id(name));

    if is("CC0-1.0") || is("Unlicense") {
        return Ok(None);
    }

    let file_names = if is("MIT") {
        &["LICENSE-MIT", "LICENSE"]
    } else if is("Apache-2.0") {
        &["LICENSE-APACHE", "LICENSE"]
    } else {
        bail!("`{}`: unsupported license: `{}`", package.id, license);
    };

    find(package.manifest_dir().as_ref(), file_names)
        .unwrap_or_else(|| {
            if !matches!(&package.source, Some(s) if s.is_crates_io()) {
                todo!("TODO: find license files from `git` sources and `path` sources");
            }

            let repository = package.repository.as_ref().with_context(|| {
                format!(
                    "could not retrieve the license file of `{}`: missing `repository` field",
                    package.id,
                )
            })?;

            let sha1 = &read_git_sha1(package)?;

            let cache_path = &dirs_next::cache_dir()
                .with_context(|| "could not find the cache directory")?
                .join("cargo-equip")
                .join("license-files")
                .join(&package.name)
                .join(sha1);

            if cache_path.exists() {
                return xshell::read_file(cache_path).map_err(Into::into);
            }

            let content = find_in_git_repos(repository, sha1, file_names)?.with_context(|| {
                format!(
                    "could not retrieve the license file of `{}`: could not find {:?}",
                    package.id, file_names,
                )
            })?;

            xshell::mkdir_p(cache_path.with_file_name(""))?;
            xshell::write_file(cache_path, &content)?;

            Ok(content)
        })
        .map(Some)
}

fn normalize(license: &str) -> &str {
    if license == "MIT/Apache-2.0" {
        "MIT OR Apache-2.0"
    } else if license == "Apache-2.0/MIT" {
        "Apache-2.0 OR MIT"
    } else {
        license
    }
}

fn read_git_sha1(package: &cm::Package) -> anyhow::Result<String> {
    let json = &xshell::read_file(package.manifest_dir().join(".cargo_vcs_info.json"))?;
    let CargoVcsInfo {
        git: CargoVcsInfoGit { sha1 },
    } = serde_json::from_str(json).with_context(|| {
        format!(
            "could not retrieve the license file of `{}`: this package does not seem to come from \
             a Git repository",
            package.id,
        )
    })?;
    return Ok(sha1);

    #[derive(Deserialize)]
    struct CargoVcsInfo {
        git: CargoVcsInfoGit,
    }

    #[derive(Deserialize)]
    struct CargoVcsInfoGit {
        sha1: String,
    }
}

fn find_in_git_repos(url: &str, sha1: &str, file_names: &[&str]) -> anyhow::Result<Option<String>> {
    let tempdir = tempfile::Builder::new()
        .prefix("cargo-equip-git-clone-")
        .tempdir()?;

    crate::process::process("git")
        .args(&["clone", "--no-checkout", "--filter", "blob:none", url, "."])
        .cwd(tempdir.path())
        .exec()?;

    crate::process::process("git")
        .args(&["switch", "-d", sha1])
        .cwd(tempdir.path())
        .exec()?;

    let result = find(tempdir.path(), file_names).transpose();
    tempdir.close()?;
    result
}

fn find(dir: &Path, file_names: &[&str]) -> Option<anyhow::Result<String>> {
    let path = file_names
        .iter()
        .map(|name| dir.join(name))
        .find(|path| path.exists())?;
    Some(xshell::read_file(path).map_err(Into::into))
}