vyre-build-scan 0.1.0

Build-time filesystem scanner for flat trait-impl registries
Documentation
//! Flat Rust module registry generation.
//!
//! # build.rs example
//!
//! ```ignore
//! fn main() {
//!     vyre_build_scan::scan(&vyre_build_scan::Registry {
//!         scan_dir: "src/enforce/gates",
//!         const_name: "ALL_GATES",
//!         element_type: "&'static dyn crate::enforce::EnforceGate",
//!         item_const_name: "REGISTERED",
//!         output_file: "gates_registry.rs",
//!         module_prefix: "crate::enforce::gates",
//!     });
//! }
//! ```

use crate::config::Registry;
use crate::fatal::{fatal, required_env_path};
use crate::paths::output_path_in_out_dir;
use std::collections::BTreeSet;
use std::fs;
use std::io::Write;
use std::path::Path;

/// Scan a flat directory of `.rs` files and emit a typed registry.
///
/// Build scripts call this for directories where every directly contained
/// source file owns one registered value with the same exported constant name.
/// The generated file is always written under `OUT_DIR`; invalid output paths
/// abort the build with an actionable message.
///
/// # build.rs example
///
/// ```ignore
/// fn main() {
///     vyre_build_scan::scan(&vyre_build_scan::Registry {
///         scan_dir: "src/enforce/gates",
///         const_name: "ALL_GATES",
///         element_type: "&'static dyn crate::enforce::EnforceGate",
///         item_const_name: "REGISTERED",
///         output_file: "gates_registry.rs",
///         module_prefix: "crate::enforce::gates",
///     });
/// }
/// ```
pub fn scan(registry: &Registry<'_>) {
    let manifest_dir = required_env_path("CARGO_MANIFEST_DIR");
    let out_dir = required_env_path("OUT_DIR");

    let scan_path = manifest_dir.join(registry.scan_dir);

    println!("cargo:rerun-if-changed={}", scan_path.display());

    let modules = collect_module_files(&scan_path, registry.item_const_name);

    let mut out = String::new();
    out.push_str("// @generated by vyre-build-scan - do not edit.\n");
    out.push_str(&format!("// Source: {}\n", registry.scan_dir));
    out.push_str("// To add a new entry: drop a .rs file in the source directory with\n");
    out.push_str(&format!(
        "// `pub const {}: YourType = YourType;` - next build picks it up.\n\n",
        registry.item_const_name
    ));
    out.push_str(&format!(
        "pub static {}: &[{}] = &[\n",
        registry.const_name, registry.element_type,
    ));
    for module in &modules {
        out.push_str(&format!(
            "    &{}::{}::{},\n",
            registry.module_prefix, module, registry.item_const_name
        ));
    }
    out.push_str("];\n");

    let output_path = output_path_in_out_dir(&out_dir, registry.output_file)
        .unwrap_or_else(|message| fatal(message));
    let mut file = fs::File::create(&output_path)
        .unwrap_or_else(|err| panic!("build_scan: cannot create {}: {err}", output_path.display()));
    file.write_all(out.as_bytes())
        .unwrap_or_else(|err| panic!("build_scan: cannot write {}: {err}", output_path.display()));

    for module in &modules {
        let file_path = scan_path.join(format!("{module}.rs"));
        println!("cargo:rerun-if-changed={}", file_path.display());
    }
}

/// Run [`scan`] for multiple registries in a single build script.
///
/// # build.rs example
///
/// ```ignore
/// fn main() {
///     let registries = [
///         vyre_build_scan::Registry {
///             scan_dir: "src/enforce/gates",
///             const_name: "ALL_GATES",
///             element_type: "&'static dyn crate::enforce::EnforceGate",
///             item_const_name: "REGISTERED",
///             output_file: "gates_registry.rs",
///             module_prefix: "crate::enforce::gates",
///         },
///         vyre_build_scan::Registry {
///             scan_dir: "src/security_detection",
///             const_name: "ALL_DETECTIONS",
///             element_type: "&'static dyn crate::security_detection::Detection",
///             item_const_name: "REGISTERED",
///             output_file: "security_detections_registry.rs",
///             module_prefix: "crate::security_detection",
///         },
///     ];
///     vyre_build_scan::scan_all(&registries);
/// }
/// ```
pub fn scan_all(registries: &[Registry<'_>]) {
    for registry in registries {
        scan(registry);
    }
}

fn collect_module_files(dir: &Path, item_const_name: &str) -> Vec<String> {
    let mut names: BTreeSet<String> = BTreeSet::new();
    let entries = fs::read_dir(dir)
        .unwrap_or_else(|err| panic!("build_scan: cannot read {}: {err}", dir.display()));
    for entry in entries {
        let entry = entry.unwrap_or_else(|err| {
            panic!(
                "build_scan: cannot read dir entry in {}: {err}",
                dir.display()
            )
        });
        let path = entry.path();
        if !path.is_file() {
            continue;
        }
        let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
            continue;
        };
        let Some(ext) = path.extension().and_then(|s| s.to_str()) else {
            continue;
        };
        if ext != "rs" || stem == "mod" || stem.starts_with('_') {
            continue;
        }
        let contents = fs::read_to_string(&path)
            .unwrap_or_else(|err| panic!("build_scan: cannot read {}: {err}", path.display()));
        if contents.contains(&format!("pub const {item_const_name}")) {
            names.insert(stem.to_string());
        }
    }
    names.into_iter().collect()
}

#[cfg(test)]
mod tests {
    use super::collect_module_files;
    use std::{env, fs, path::PathBuf};

    #[test]
    fn skips_mod_rs_and_underscore_files() {
        let tmp = tempdir_sibling("scan_skips");
        fs::create_dir_all(&tmp).unwrap();
        fs::write(tmp.join("mod.rs"), "").unwrap();
        fs::write(tmp.join("_registry.rs"), "").unwrap();
        fs::write(tmp.join("atomics.rs"), "pub const REGISTERED: () = ();").unwrap();
        fs::write(tmp.join("barrier.rs"), "pub const REGISTERED: () = ();").unwrap();

        let got = collect_module_files(&tmp, "REGISTERED");
        assert_eq!(got, vec!["atomics".to_string(), "barrier".to_string()]);

        let _ = fs::remove_dir_all(&tmp);
    }

    #[test]
    fn empty_dir_yields_empty_list() {
        let tmp = tempdir_sibling("scan_empty");
        fs::create_dir_all(&tmp).unwrap();
        let got = collect_module_files(&tmp, "REGISTERED");
        assert!(got.is_empty());
        let _ = fs::remove_dir_all(&tmp);
    }

    #[test]
    fn deterministic_order_is_alphabetical() {
        let tmp = tempdir_sibling("scan_order");
        fs::create_dir_all(&tmp).unwrap();
        for name in ["xor", "and", "or", "not"] {
            fs::write(
                tmp.join(format!("{name}.rs")),
                "pub const REGISTERED: () = ();",
            )
            .unwrap();
        }
        let got = collect_module_files(&tmp, "REGISTERED");
        assert_eq!(
            got,
            vec![
                "and".to_string(),
                "not".to_string(),
                "or".to_string(),
                "xor".to_string()
            ]
        );
        let _ = fs::remove_dir_all(&tmp);
    }

    fn tempdir_sibling(suffix: &str) -> PathBuf {
        let mut base = env::temp_dir();
        base.push(format!("vyre-build-scan-{}-{}", std::process::id(), suffix));
        base
    }
}