//! This tool helps to capture the `Cargo.toml` files of a workspace.
//!
//! Run it by passing a list of workspaces to capture.
//! Use the `-f` flag to allow it to overwrite existing captures.
//! The workspace will be saved in a `.tgz` file in the `../workspaces` directory.

#![allow(clippy::disallowed_methods)]
#![allow(clippy::print_stderr)]

use flate2::{Compression, GzBuilder};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

fn main() {
    let force = std::env::args().any(|arg| arg == "-f");
    let dest = Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .unwrap()
        .join("workspaces");
    if !dest.exists() {
        panic!("expected {} to exist", dest.display());
    }
    for arg in std::env::args().skip(1).filter(|arg| !arg.starts_with("-")) {
        let source_root = fs::canonicalize(arg).unwrap();
        capture(&source_root, &dest, force);
    }
}

fn capture(source_root: &Path, dest: &Path, force: bool) {
    let name = Path::new(source_root.file_name().unwrap());
    let mut dest_gz = PathBuf::from(dest);
    dest_gz.push(name);
    dest_gz.set_extension("tgz");
    if dest_gz.exists() {
        if !force {
            panic!(
                "dest {:?} already exists, use -f to force overwriting",
                dest_gz
            );
        }
        fs::remove_file(&dest_gz).unwrap();
    }
    let vcs_info = capture_vcs_info(source_root, force);
    let dst = fs::File::create(&dest_gz).unwrap();
    let encoder = GzBuilder::new()
        .filename(format!("{}.tar", name.to_str().unwrap()))
        .write(dst, Compression::best());
    let mut ar = tar::Builder::new(encoder);
    ar.mode(tar::HeaderMode::Deterministic);
    if let Some(info) = &vcs_info {
        add_ar_file(&mut ar, &name.join(".cargo_vcs_info.json"), info);
    }

    // Gather all local packages.
    let metadata = cargo_metadata::MetadataCommand::new()
        .manifest_path(source_root.join("Cargo.toml"))
        .features(cargo_metadata::CargoOpt::AllFeatures)
        .exec()
        .expect("cargo_metadata failed");
    let mut found_root = false;
    for package in &metadata.packages {
        if package.source.is_some() {
            continue;
        }
        let manifest_path = package.manifest_path.as_std_path();
        copy_manifest(&manifest_path, &mut ar, name, &source_root);
        found_root |= manifest_path == source_root.join("Cargo.toml");
    }
    if !found_root {
        // A virtual workspace.
        let contents = fs::read_to_string(source_root.join("Cargo.toml")).unwrap();
        assert!(!contents.contains("[package]"));
        add_ar_file(&mut ar, &name.join("Cargo.toml"), &contents);
    }
    let lock = fs::read_to_string(source_root.join("Cargo.lock")).unwrap();
    add_ar_file(&mut ar, &name.join("Cargo.lock"), &lock);
    let encoder = ar.into_inner().unwrap();
    encoder.finish().unwrap();
    eprintln!("created {}", dest_gz.display());
}

fn copy_manifest<W: std::io::Write>(
    manifest_path: &Path,
    ar: &mut tar::Builder<W>,
    name: &Path,
    source_root: &Path,
) {
    let relative_path = manifest_path
        .parent()
        .unwrap()
        .strip_prefix(source_root)
        .expect("workspace member should be under workspace root");
    let relative_path = name.join(relative_path);
    let contents = fs::read_to_string(&manifest_path).unwrap();
    let mut manifest: toml::Value = toml::from_str(&contents).unwrap();
    let remove = |obj: &mut toml::Value, name| {
        let table = obj.as_table_mut().unwrap();
        if table.contains_key(name) {
            table.remove(name);
        }
    };
    remove(&mut manifest, "lib");
    remove(&mut manifest, "bin");
    remove(&mut manifest, "example");
    remove(&mut manifest, "test");
    remove(&mut manifest, "bench");
    remove(&mut manifest, "profile");
    if let Some(package) = manifest.get_mut("package") {
        remove(package, "default-run");
    }
    let contents = toml::to_string(&manifest).unwrap();
    add_ar_file(ar, &relative_path.join("Cargo.toml"), &contents);
    add_ar_file(ar, &relative_path.join("src").join("lib.rs"), "");
}

fn add_ar_file<W: std::io::Write>(ar: &mut tar::Builder<W>, path: &Path, contents: &str) {
    let mut header = tar::Header::new_gnu();
    header.set_entry_type(tar::EntryType::file());
    header.set_mode(0o644);
    header.set_size(contents.len() as u64);
    header.set_mtime(123456789);
    header.set_cksum();
    ar.append_data(&mut header, path, contents.as_bytes())
        .unwrap();
}

fn capture_vcs_info(ws_root: &Path, force: bool) -> Option<String> {
    let maybe_git = |command: &str| {
        Command::new("git")
            .current_dir(ws_root)
            .args(command.split_whitespace().collect::<Vec<_>>())
            .output()
            .expect("git should be installed")
    };
    assert!(ws_root.join("Cargo.toml").exists());
    let relative = maybe_git("ls-files --full-name Cargo.toml");
    if !relative.status.success() {
        if !force {
            panic!("git repository not detected, use -f to force");
        }
        return None;
    }
    let p = Path::new(std::str::from_utf8(&relative.stdout).unwrap().trim());
    let relative = p.parent().unwrap();
    if !force {
        let has_changes = !maybe_git("diff-index --quiet HEAD .").status.success();
        if has_changes {
            panic!("git repo appears to have changes, use -f to force, or clean the repo");
        }
    }
    let commit = maybe_git("rev-parse HEAD");
    assert!(commit.status.success());
    let commit = std::str::from_utf8(&commit.stdout).unwrap().trim();
    let remote = maybe_git("remote get-url origin");
    assert!(remote.status.success());
    let remote = std::str::from_utf8(&remote.stdout).unwrap().trim();
    let info = format!(
        "{{\n  \"git\": {{\n    \"sha1\": \"{}\",\n     \"remote\": \"{}\"\n  }},\
         \n  \"path_in_vcs\": \"{}\"\n}}\n",
        commit,
        remote,
        relative.display()
    );
    eprintln!("recording vcs info:\n{}", info);
    Some(info)
}
