bot-forge 1.0.2

Rust CLI for installing agent skills and developer tools from configurable forms.
Documentation
//! Generate or verify machine-readable configuration and registry schema artifacts.

use std::env;
use std::fs;
use std::path::{Path, PathBuf};

use bot_forge::config::ConfigDocument;
use bot_forge::constants::BUILTIN_CATALOG;
use bot_forge::state::RegistryDocument;
use sha2::{Digest, Sha256};

fn main() {
    let arguments = env::args().skip(1).collect::<Vec<_>>();
    let check = match parse_arguments(&arguments) {
        Ok(check) => check,
        Err(message) => {
            eprintln!("{message}");
            std::process::exit(2);
        }
    };
    let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    ConfigDocument::parse(&format!("catalog = \"rust-dev\"\n{BUILTIN_CATALOG}"))
        .expect("builtin catalog must be valid");
    let mut schema = serde_json::to_value(schemars::schema_for!(ConfigDocument)).unwrap();
    let schema_object = schema
        .as_object_mut()
        .expect("config schema must be an object");
    schema_object.insert("title".to_string(), "bot-forge configuration".into());
    schema_object.insert("required".to_string(), serde_json::json!(["catalog"]));
    schema_object["properties"]["catalog"] = serde_json::json!({
        "const": "rust-dev",
        "type": "string"
    });
    let schema = serde_json::to_string_pretty(&schema).unwrap() + "\n";
    let mut registry_schema =
        serde_json::to_value(schemars::schema_for!(RegistryDocument)).unwrap();
    let registry_object = registry_schema
        .as_object_mut()
        .expect("registry schema must be an object");
    registry_object.insert(
        "$id".to_string(),
        "https://bot-forge.local/schema/registry.json".into(),
    );
    registry_object.insert("title".to_string(), "bot-forge registry".into());
    let registry_schema = serde_json::to_string_pretty(&registry_schema).unwrap() + "\n";
    let catalog_digest = format!(
        "{}  catalogs/rust-dev.toml\n",
        Sha256::digest(BUILTIN_CATALOG.as_bytes())
            .iter()
            .map(|byte| format!("{byte:02x}"))
            .collect::<String>()
    );
    let artifacts = [
        (root.join("schema/config.json"), schema),
        (root.join("schema/registry.json"), registry_schema),
        (root.join("catalogs/SHA256SUMS"), catalog_digest),
    ];
    let mut drift = Vec::new();
    for (path, contents) in artifacts {
        if check {
            if fs::read_to_string(&path).ok().as_deref() != Some(&contents) {
                drift.push(path);
            }
        } else {
            write(&path, &contents);
        }
    }
    if !drift.is_empty() {
        eprintln!("generated config artifacts are stale:");
        for path in drift {
            eprintln!("  {}", path.display());
        }
        std::process::exit(1);
    }
}

fn parse_arguments(arguments: &[String]) -> Result<bool, &'static str> {
    match arguments {
        [] => Ok(false),
        [argument] if argument == "--check" => Ok(true),
        _ => Err("usage: generate-config-artifacts [--check]"),
    }
}

fn write(path: &Path, contents: &str) {
    fs::write(path, contents).unwrap_or_else(|error| panic!("write {}: {error}", path.display()));
}

#[cfg(test)]
mod tests {
    use crate::parse_arguments;

    #[test]
    fn arguments_are_strict() {
        assert_eq!(parse_arguments(&[]), Ok(false));
        assert_eq!(parse_arguments(&["--check".into()]), Ok(true));
        assert!(parse_arguments(&["unknown".into()]).is_err());
        assert!(parse_arguments(&["--check".into(), "extra".into()]).is_err());
    }
}