runx-contracts 0.6.19

Shared Rust contract types for runx JSON and host protocol boundaries.
Documentation
use std::collections::BTreeSet;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};

use runx_contracts::generated_schema_artifacts;

struct Options {
    out_dir: PathBuf,
    check: bool,
}

type CliResult<T> = Result<T, std::io::Error>;

fn main() -> CliResult<()> {
    let options = parse_args()?;
    fs::create_dir_all(&options.out_dir)?;

    let (stale, orphans) = reconcile_schema_artifacts(&options)?;
    if stale.is_empty() && orphans.is_empty() {
        return Ok(());
    }

    report_schema_drift(&stale, &orphans)?;
    Err(schema_drift_error(&stale, &orphans))
}

fn reconcile_schema_artifacts(options: &Options) -> CliResult<(Vec<&'static str>, Vec<String>)> {
    let artifacts = generated_schema_artifacts();
    let expected_file_names = artifacts
        .iter()
        .map(|artifact| artifact.file_name)
        .collect::<BTreeSet<_>>();
    let mut stale = Vec::new();
    for artifact in artifacts {
        let path = options.out_dir.join(artifact.file_name);
        let generated = format!(
            "{}\n",
            serde_json::to_string_pretty(&artifact.schema).map_err(std::io::Error::other)?
        );
        if options.check {
            match fs::read_to_string(&path) {
                Ok(current) if current == generated => {}
                _ => stale.push(artifact.file_name),
            }
        } else {
            fs::write(path, generated)?;
        }
    }

    let orphans = orphan_schema_files(&options.out_dir, &expected_file_names)?;
    if !options.check {
        for file_name in orphans {
            fs::remove_file(options.out_dir.join(file_name))?;
        }
        return Ok((stale, Vec::new()));
    }

    Ok((stale, orphans))
}

fn report_schema_drift(stale: &[&str], orphans: &[String]) -> CliResult<()> {
    let mut stderr = std::io::stderr().lock();
    if !stale.is_empty() {
        writeln!(stderr, "Generated contract schemas are stale:")?;
        for file_name in stale {
            writeln!(stderr, "- {file_name}")?;
        }
    }

    if !orphans.is_empty() {
        writeln!(stderr, "Orphan contract schemas are present:")?;
        for file_name in orphans {
            writeln!(stderr, "- {file_name}")?;
        }
    }
    Ok(())
}

fn schema_drift_error(stale: &[&str], orphans: &[String]) -> std::io::Error {
    if stale.is_empty() {
        std::io::Error::other("orphan contract schemas are present")
    } else if orphans.is_empty() {
        std::io::Error::other("generated contract schemas are stale")
    } else {
        std::io::Error::other("generated contract schemas are stale or orphaned")
    }
}

fn orphan_schema_files(
    out_dir: &Path,
    expected_file_names: &BTreeSet<&'static str>,
) -> CliResult<Vec<String>> {
    let mut orphans = Vec::new();
    for entry in fs::read_dir(out_dir)? {
        let entry = entry?;
        let file_name = entry.file_name();
        let file_name = file_name.to_string_lossy();
        if file_name.ends_with(".schema.json") && !expected_file_names.contains(file_name.as_ref())
        {
            orphans.push(file_name.into_owned());
        }
    }
    orphans.sort();
    Ok(orphans)
}

fn parse_args() -> CliResult<Options> {
    let mut out_dir: Option<PathBuf> = None;
    let mut check = false;
    let mut args = std::env::args().skip(1);

    while let Some(arg) = args.next() {
        match arg.as_str() {
            "--out" => {
                let value = args
                    .next()
                    .ok_or_else(|| std::io::Error::other("--out requires a directory"))?;
                out_dir = Some(PathBuf::from(value));
            }
            "--check" => check = true,
            other => {
                return Err(std::io::Error::other(format!(
                    "unsupported argument: {other}"
                )));
            }
        }
    }

    Ok(Options {
        out_dir: out_dir.ok_or_else(|| std::io::Error::other("--out is required"))?,
        check,
    })
}