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,
#[arg(long, global = true, env = "GITHUB_TOKEN")]
token: Option<String>,
}
#[derive(Subcommand)]
enum Command {
Fetch {
fv: Option<String>,
#[arg(long)]
all: bool,
#[arg(long)]
data_dir: Option<String>,
#[arg(long)]
version: Option<String>,
},
List {
#[arg(long)]
data_dir: Option<String>,
},
Update {
#[arg(long)]
data_dir: Option<String>,
#[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);
}
}
}
}
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 {
download_private_asset(&client, token, version, &asset_name)?
} else {
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)
}
fn bundle_is_verifiable(requested_version: &str) -> bool {
requested_version == env!("CARGO_PKG_VERSION")
}
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()?)
}
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>> {
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()?;
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")?;
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"));
}
#[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"
);
}
#[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());
}
#[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());
}
}