edifact-data 0.15.0

CLI tool for downloading EDIFACT mapping data bundles
use std::path::PathBuf;

use clap::{Parser, Subcommand};
use edifact_mapper::DataDir;
use mig_bo4e::engine::DataBundle;

#[derive(Parser)]
#[command(name = "edifact-data", about = "Manage EDIFACT mapping data bundles")]
struct Cli {
    #[command(subcommand)]
    command: Command,

    /// GitHub token for private repo access. Also reads GITHUB_TOKEN env var.
    #[arg(long, global = true, env = "GITHUB_TOKEN")]
    token: Option<String>,
}

#[derive(Subcommand)]
enum Command {
    /// Download data bundle(s) from GitHub releases.
    Fetch {
        /// Format version to fetch (e.g., FV2504). Omit for --all.
        fv: Option<String>,
        #[arg(long)]
        all: bool,
        #[arg(long)]
        data_dir: Option<String>,
        /// Bundle version to download (e.g., 0.5.0). Defaults to this binary's version.
        #[arg(long)]
        version: Option<String>,
    },
    /// List locally available data bundles.
    List {
        #[arg(long)]
        data_dir: Option<String>,
    },
    /// Update bundles to latest compatible version.
    Update {
        #[arg(long)]
        data_dir: Option<String>,
        /// Bundle version to update to (e.g., 0.5.0). Defaults to this binary's version.
        #[arg(long)]
        version: Option<String>,
    },
}

const GITHUB_REPO: &str = "Hochfrequenz/edifact_mapper";
const KNOWN_FVS: &[&str] = &["FV2504", "FV2510", "FV2604"];

fn resolve_data_dir(data_dir: Option<&str>) -> PathBuf {
    data_dir
        .map(PathBuf::from)
        .unwrap_or_else(|| DataDir::auto().data_path().to_path_buf())
}

fn main() {
    let cli = Cli::parse();
    let token = cli.token.clone().or_else(resolve_gh_token);
    let token = token.as_deref();

    match cli.command {
        Command::Fetch {
            fv,
            all,
            data_dir,
            version,
        } => {
            let dir = resolve_data_dir(data_dir.as_deref());
            std::fs::create_dir_all(&dir).expect("Failed to create data directory");
            let fvs: Vec<&str> = if all {
                KNOWN_FVS.to_vec()
            } else if let Some(ref fv) = fv {
                vec![fv.as_str()]
            } else {
                eprintln!("Specify a format version or use --all");
                std::process::exit(1);
            };
            let version = version.as_deref().unwrap_or(env!("CARGO_PKG_VERSION"));
            let requested = fvs.len();
            let mut failed = 0usize;
            for fv in fvs {
                match fetch_bundle(fv, version, &dir, token) {
                    Ok(size) => println!(
                        "Fetched {fv}: {:.1}MB -> {}",
                        size as f64 / 1_048_576.0,
                        dir.join(format!("edifact-data-{fv}.bin")).display()
                    ),
                    Err(e) => {
                        eprintln!("Failed to fetch {fv}: {e}");
                        failed += 1;
                    }
                }
            }
            if failed > 0 {
                eprintln!("Failed to fetch {failed} of {requested} bundle(s)");
                std::process::exit(1);
            }
        }
        Command::List { data_dir } => {
            let dir = resolve_data_dir(data_dir.as_deref());
            if !dir.exists() {
                println!("No data directory found at {}", dir.display());
                return;
            }
            let mut found = false;
            for fv in KNOWN_FVS {
                let path = dir.join(format!("edifact-data-{fv}.bin"));
                if path.exists() {
                    let meta = std::fs::metadata(&path).unwrap();
                    println!(
                        "  {fv}  {:.1}MB  {}",
                        meta.len() as f64 / 1_048_576.0,
                        path.display()
                    );
                    found = true;
                }
            }
            if !found {
                println!("No bundles found in {}", dir.display());
            }
        }
        Command::Update { data_dir, version } => {
            let dir = resolve_data_dir(data_dir.as_deref());
            std::fs::create_dir_all(&dir).expect("Failed to create data directory");
            let version = version.as_deref().unwrap_or(env!("CARGO_PKG_VERSION"));
            let mut failed = 0usize;
            for fv in KNOWN_FVS {
                let path = dir.join(format!("edifact-data-{fv}.bin"));
                if path.exists() {
                    match fetch_bundle(fv, version, &dir, token) {
                        Ok(size) => {
                            println!("Updated {fv}: {:.1}MB", size as f64 / 1_048_576.0)
                        }
                        Err(e) => {
                            eprintln!("Failed to update {fv}: {e}");
                            failed += 1;
                        }
                    }
                }
            }
            if failed > 0 {
                eprintln!("Failed to update {failed} bundle(s)");
                std::process::exit(1);
            }
        }
    }
}

/// Download a bundle from GitHub releases and decompress it.
///
/// For private repos, uses the GitHub API to find the release asset ID,
/// then downloads via the asset API with auth. For public repos (no token),
/// uses the direct download URL.
fn fetch_bundle(
    fv: &str,
    version: &str,
    data_dir: &std::path::Path,
    token: Option<&str>,
) -> Result<u64, Box<dyn std::error::Error>> {
    let asset_name = format!("edifact-data-{fv}.bin.gz");
    let client = reqwest::blocking::Client::builder()
        .user_agent("edifact-data")
        .build()?;

    let gz_bytes = if let Some(token) = token {
        // Private repo: use GitHub API to resolve asset and download
        download_private_asset(&client, token, version, &asset_name)?
    } else {
        // Public repo: direct download URL
        download_public_asset(&client, version, &asset_name)?
    };

    eprintln!(
        "Downloaded {:.1}MB compressed",
        gz_bytes.len() as f64 / 1_048_576.0
    );

    let out_path = data_dir.join(format!("edifact-data-{fv}.bin"));
    let verify = bundle_is_verifiable(version);
    if !verify {
        eprintln!(
            "Bundle v{version} targets a different release than this CLI (v{}); \
             skipping the load check since its format may not be readable here",
            env!("CARGO_PKG_VERSION")
        );
    }
    install_bundle(&gz_bytes, &out_path, verify)
}

/// Whether a bundle of `requested_version` can be load-checked by this binary.
///
/// The bundle format tracks the crate version, so the reader compiled in here
/// only understands bundles from its own release. Pinning `--version` to another
/// release is a supported use (that is what the flag is for: fetching bundles
/// for the library version a *consumer* links), and a load failure there says
/// nothing about the download — so the check is only meaningful for our own
/// version.
fn bundle_is_verifiable(requested_version: &str) -> bool {
    requested_version == env!("CARGO_PKG_VERSION")
}

/// Decompress `gz_bytes` into `out_path`, optionally load-checking it first.
///
/// Writes to a sibling `.part` file and renames into place only after the
/// bundle is complete and (when verifiable) loadable. Decompressing straight
/// into `out_path` used to leave a truncated or unreadable `.bin` behind on
/// failure, which callers could not tell apart from a good bundle.
fn install_bundle(
    gz_bytes: &[u8],
    out_path: &std::path::Path,
    verify: bool,
) -> Result<u64, Box<dyn std::error::Error>> {
    let part_path = out_path.with_extension("bin.part");
    let staged = (|| -> Result<u64, Box<dyn std::error::Error>> {
        let mut decoder = flate2::read::GzDecoder::new(gz_bytes);
        let mut part_file = std::fs::File::create(&part_path)?;
        let size = std::io::copy(&mut decoder, &mut part_file)?;
        part_file.sync_all()?;
        drop(part_file);
        if verify {
            DataBundle::load(&part_path)?;
        }
        Ok(size)
    })();

    match staged {
        Ok(size) => {
            std::fs::rename(&part_path, out_path)?;
            Ok(size)
        }
        Err(e) => {
            let _ = std::fs::remove_file(&part_path);
            Err(e)
        }
    }
}

fn download_public_asset(
    client: &reqwest::blocking::Client,
    version: &str,
    asset_name: &str,
) -> Result<bytes::Bytes, Box<dyn std::error::Error>> {
    let url = format!("https://github.com/{GITHUB_REPO}/releases/download/v{version}/{asset_name}");
    eprintln!("Downloading {url} ...");
    let response = client
        .get(&url)
        .header("Accept", "application/octet-stream")
        .send()?;
    if !response.status().is_success() {
        return Err(format!(
            "HTTP {}: Is v{version} released with {asset_name}? \
             For private repos, set GITHUB_TOKEN or use --token.",
            response.status()
        )
        .into());
    }
    Ok(response.bytes()?)
}

/// Try to resolve a GitHub token from the `gh` CLI (GitHub CLI).
/// Returns None silently if `gh` is not installed or not authenticated.
fn resolve_gh_token() -> Option<String> {
    std::process::Command::new("gh")
        .args(["auth", "token"])
        .output()
        .ok()
        .filter(|o| o.status.success())
        .and_then(|o| {
            let token = String::from_utf8(o.stdout).ok()?.trim().to_string();
            if token.is_empty() {
                None
            } else {
                eprintln!("Using GitHub token from `gh auth token`");
                Some(token)
            }
        })
}

fn download_private_asset(
    client: &reqwest::blocking::Client,
    token: &str,
    version: &str,
    asset_name: &str,
) -> Result<bytes::Bytes, Box<dyn std::error::Error>> {
    // Step 1: Get release by tag to find asset ID
    let release_url =
        format!("https://api.github.com/repos/{GITHUB_REPO}/releases/tags/v{version}");
    eprintln!("Fetching release v{version} from GitHub API ...");
    let release: serde_json::Value = client
        .get(&release_url)
        .header("Authorization", format!("Bearer {token}"))
        .header("Accept", "application/vnd.github+json")
        .send()?
        .error_for_status()?
        .json()?;

    // Step 2: Find the asset by name
    let assets = release["assets"].as_array().ok_or("No assets in release")?;
    let asset = assets
        .iter()
        .find(|a| a["name"].as_str() == Some(asset_name))
        .ok_or_else(|| format!("Asset {asset_name} not found in release v{version}"))?;
    let asset_url = asset["url"].as_str().ok_or("Asset missing url field")?;

    // Step 3: Download the asset binary
    eprintln!("Downloading {asset_name} ...");
    let response = client
        .get(asset_url)
        .header("Authorization", format!("Bearer {token}"))
        .header("Accept", "application/octet-stream")
        .send()?
        .error_for_status()?;
    Ok(response.bytes()?)
}
#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;

    fn gzipped(bytes: &[u8]) -> Vec<u8> {
        let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
        encoder.write_all(bytes).unwrap();
        encoder.finish().unwrap()
    }

    #[test]
    fn only_our_own_version_is_verifiable() {
        assert!(bundle_is_verifiable(env!("CARGO_PKG_VERSION")));
        assert!(!bundle_is_verifiable("0.0.1-not-us"));
    }

    /// Regression: a bundle that decompresses but does not load left a bogus
    /// `.bin` in the data dir, indistinguishable from a good one.
    #[test]
    fn failed_verification_leaves_no_bundle_behind() {
        let dir = tempfile::tempdir().unwrap();
        let out_path = dir.path().join("edifact-data-FV2504.bin");

        let err = install_bundle(&gzipped(b"not a data bundle"), &out_path, true)
            .expect_err("a non-loadable bundle must not install");

        assert!(!out_path.exists(), "bundle left behind: {err}");
        assert!(
            !out_path.with_extension("bin.part").exists(),
            "staging file left behind"
        );
    }

    /// The `--version` skew case: nothing here can load a bundle built for
    /// another release, so it installs unverified rather than failing.
    #[test]
    fn unverifiable_bundle_installs_when_the_check_is_skipped() {
        let dir = tempfile::tempdir().unwrap();
        let out_path = dir.path().join("edifact-data-FV2504.bin");
        let payload = b"bundle for another release";

        let size = install_bundle(&gzipped(payload), &out_path, false)
            .expect("skipping verification must install the bundle");

        assert_eq!(size, payload.len() as u64);
        assert_eq!(std::fs::read(&out_path).unwrap(), payload);
        assert!(!out_path.with_extension("bin.part").exists());
    }

    /// Regression: a truncated download used to leave a partial `.bin` behind.
    #[test]
    fn corrupt_download_leaves_no_bundle_behind() {
        let dir = tempfile::tempdir().unwrap();
        let out_path = dir.path().join("edifact-data-FV2504.bin");
        let mut truncated = gzipped(b"a payload long enough to matter");
        truncated.truncate(truncated.len() / 2);

        install_bundle(&truncated, &out_path, false)
            .expect_err("a truncated download must not install");

        assert!(!out_path.exists());
        assert!(!out_path.with_extension("bin.part").exists());
    }
}