rsfulmen 0.1.4

Rust helper library for the Fulmen ecosystem - foundry catalogs, config utilities, and cross-platform helpers
Documentation
use std::collections::BTreeMap;
use std::env;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};

fn main() {
    let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"));

    let docs_root = manifest_dir.join("docs/crucible-rs");
    let schemas_root = manifest_dir.join("schemas/crucible-rs");
    let config_root = manifest_dir.join("config/crucible-rs");

    let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR"));
    let out_file = out_dir.join("crucible_asset_index.rs");

    let mut docs = Vec::new();
    let mut schemas = Vec::new();
    let mut config = Vec::new();

    collect_files(&docs_root, &mut docs).expect("collect docs files");
    collect_files(&schemas_root, &mut schemas).expect("collect schemas files");
    collect_files(&config_root, &mut config).expect("collect config files");

    // Ensure rebuilds when assets change.
    println!("cargo:rerun-if-changed=docs/crucible-rs");
    println!("cargo:rerun-if-changed=schemas/crucible-rs");
    println!("cargo:rerun-if-changed=config/crucible-rs");
    println!("cargo:rerun-if-changed=.crucible/metadata/metadata.yaml");

    let generated = generate_index(
        &manifest_dir,
        &docs_root,
        &schemas_root,
        &config_root,
        &docs,
        &schemas,
        &config,
    );
    fs::write(&out_file, generated).expect("write crucible_asset_index.rs");
}

fn collect_files(root: &Path, out: &mut Vec<PathBuf>) -> io::Result<()> {
    if !root.exists() {
        return Ok(());
    }

    let mut stack = vec![root.to_path_buf()];
    while let Some(dir) = stack.pop() {
        for entry in fs::read_dir(&dir)? {
            let entry = entry?;
            let path = entry.path();

            if path.file_name().is_some_and(|n| n == ".DS_Store") {
                continue;
            }

            let metadata = entry.metadata()?;
            if metadata.is_dir() {
                stack.push(path);
            } else if metadata.is_file() {
                out.push(path);
            }
        }
    }

    out.sort();
    Ok(())
}

fn generate_index(
    manifest_dir: &Path,
    docs_root: &Path,
    schemas_root: &Path,
    config_root: &Path,
    docs: &[PathBuf],
    schemas: &[PathBuf],
    config: &[PathBuf],
) -> String {
    let docs_entries = files_to_entries(manifest_dir, docs_root, docs);
    let schemas_entries = files_to_entries(manifest_dir, schemas_root, schemas);
    let config_entries = files_to_entries(manifest_dir, config_root, config);

    let mut out = String::new();
    out.push_str("// @generated by build.rs; do not edit.\n");
    out.push_str("// This file contains the embedded Crucible asset index.\n\n");

    out.push_str("pub static DOCS: &[CrucibleAsset] = &[\n");
    for (rel, len) in docs_entries.iter() {
        out.push_str(&format!(
            "    CrucibleAsset {{ category: AssetCategory::Docs, path: \"{}\", bytes_len: {} }},\n",
            escape(rel),
            len
        ));
    }
    out.push_str("];\n\n");

    out.push_str("pub static SCHEMAS: &[CrucibleAsset] = &[\n");
    for (rel, len) in schemas_entries.iter() {
        out.push_str(&format!(
            "    CrucibleAsset {{ category: AssetCategory::Schemas, path: \"{}\", bytes_len: {} }},\n",
            escape(rel),
            len
        ));
    }
    out.push_str("];\n\n");

    out.push_str("pub static CONFIG: &[CrucibleAsset] = &[\n");
    for (rel, len) in config_entries.iter() {
        out.push_str(&format!(
            "    CrucibleAsset {{ category: AssetCategory::Config, path: \"{}\", bytes_len: {} }},\n",
            escape(rel),
            len
        ));
    }
    out.push_str("];\n\n");

    out.push_str("pub fn open_doc(path: &str) -> Option<&'static [u8]> {\n");
    out.push_str("    match path {\n");
    for (rel, _) in docs_entries.iter() {
        out.push_str(&format!(
            "        \"{}\" => Some(include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/docs/crucible-rs/{}\"))),\n",
            escape(rel),
            escape(rel)
        ));
    }
    out.push_str("        _ => None,\n");
    out.push_str("    }\n");
    out.push_str("}\n\n");

    out.push_str("pub fn open_schema(path: &str) -> Option<&'static [u8]> {\n");
    out.push_str("    match path {\n");
    for (rel, _) in schemas_entries.iter() {
        out.push_str(&format!(
            "        \"{}\" => Some(include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/schemas/crucible-rs/{}\"))),\n",
            escape(rel),
            escape(rel)
        ));
    }
    out.push_str("        _ => None,\n");
    out.push_str("    }\n");
    out.push_str("}\n\n");

    out.push_str("pub fn open_config(path: &str) -> Option<&'static [u8]> {\n");
    out.push_str("    match path {\n");
    for (rel, _) in config_entries.iter() {
        out.push_str(&format!(
            "        \"{}\" => Some(include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/config/crucible-rs/{}\"))),\n",
            escape(rel),
            escape(rel)
        ));
    }
    out.push_str("        _ => None,\n");
    out.push_str("    }\n");
    out.push_str("}\n");

    out
}

fn files_to_entries(
    manifest_dir: &Path,
    root: &Path,
    files: &[PathBuf],
) -> BTreeMap<String, usize> {
    let mut out = BTreeMap::new();

    for path in files {
        let rel = path
            .strip_prefix(root)
            .unwrap_or(path)
            .to_string_lossy()
            .replace('\\', "/");

        let len = fs::metadata(path).map(|m| m.len() as usize).unwrap_or(0);
        out.insert(rel, len);

        // Also rerun if file changes.
        if let Ok(rel_to_manifest) = path.strip_prefix(manifest_dir) {
            println!("cargo:rerun-if-changed={}", rel_to_manifest.display());
        }
    }

    out
}

fn escape(s: &str) -> String {
    s.replace('\\', "\\\\").replace('"', "\\\"")
}