noxid-cli 0.2.1

The Noxid compiler command line: check, build, test, adapt, and the agent surface
//! Embed the `noxid new --template <name>` project sources.
//!
//! The templates are ordinary compile-tested projects under
//! `examples/templates/<name>/`, so the compiler proves every template still
//! builds. This script walks those directories and generates an
//! `include_str!` table, which keeps the source of truth in the project files
//! instead of in string literals inside `main.rs`, and keeps `noxid` a single
//! self-contained binary with no external Rust dependency.
//!
//! It reads them through `crates/cli/embedded/`, not through
//! `../../examples/templates`. A published crate is a tarball of this
//! directory alone, so a build script that reaches above it works in this
//! checkout and breaks every `cargo install noxid-cli`. The repository files
//! stay the source of truth: `tools/sync-embedded.sh` refreshes the copies and
//! `tests/embedded_assets_in_sync.rs` fails the build on drift.

use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};

/// Build outputs and installed packages are never part of a template.
const SKIPPED_DIRECTORIES: [&str; 4] = ["target", "dist", "node_modules", ".git"];

fn main() {
    let manifest = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").expect("manifest dir"));
    let templates = manifest.join("embedded/templates");
    let out = PathBuf::from(std::env::var_os("OUT_DIR").expect("out dir")).join("templates.rs");
    println!("cargo:rerun-if-changed={}", templates.display());

    let mut generated = String::from(
        "// @generated by crates/cli/build.rs from examples/templates. Do not edit.\n\
         pub(crate) struct TemplateFile {\n    pub(crate) path: &'static str,\n    pub(crate) contents: &'static str,\n}\n\n\
         pub(crate) struct TemplateSource {\n    pub(crate) directory: &'static str,\n    pub(crate) files: &'static [TemplateFile],\n    /// Directories the scaffold creates but leaves empty. A template marks one\n    /// with a `.gitkeep`, which git needs and a scaffolded project must not\n    /// inherit: the file set a user sees has to be the file set the template\n    /// means.\n    pub(crate) directories: &'static [&'static str],\n}\n\n\
         pub(crate) const TEMPLATE_SOURCES: &[TemplateSource] = &[\n",
    );

    let mut directories = fs::read_dir(&templates)
        .unwrap_or_else(|error| panic!("cannot read {}: {error}", templates.display()))
        .map(|entry| entry.expect("template entry").path())
        .filter(|path| path.is_dir())
        .collect::<Vec<_>>();
    directories.sort();
    assert!(
        !directories.is_empty(),
        "examples/templates must contain at least one template project"
    );

    for directory in directories {
        let name = directory
            .file_name()
            .and_then(|name| name.to_str())
            .expect("template directory name")
            .to_string();
        let mut files = BTreeMap::new();
        let mut directories = BTreeSet::new();
        collect(&directory, &directory, &mut files, &mut directories);
        assert!(
            files.contains_key("Noxid.toml"),
            "template {name} has no Noxid.toml"
        );
        generated.push_str(&format!(
            "    TemplateSource {{ directory: {name:?}, files: &[\n"
        ));
        for (relative, absolute) in files {
            println!("cargo:rerun-if-changed={}", absolute.display());
            generated.push_str(&format!(
                "        TemplateFile {{ path: {relative:?}, contents: include_str!({:?}) }},\n",
                absolute.display().to_string()
            ));
        }
        generated.push_str("    ], directories: &[");
        for relative in directories {
            generated.push_str(&format!("{relative:?}, "));
        }
        generated.push_str("] },\n");
    }
    generated.push_str("];\n");

    generated.push_str(&vendored_plugins(&manifest));

    fs::write(&out, generated).unwrap_or_else(|error| panic!("cannot write templates: {error}"));
}

/// The vetted first-party files a scaffolded project needs in order to import
/// the compiler-owned Drizzle adapter outside this repository (WO-54). They are
/// embedded byte-identical: `noxid new` copies them, hashes them into
/// `.noxid-plugins.json`, and `noxid build` refuses a copy that drifted.
///
/// `adapter.js` is the reviewed surface; `VETTING.md` is the admission record
/// the build checks the lockfile against; `data-scopes.test.mjs` is the
/// behavioral proof of the scope boundary, runnable inside the scaffold with
/// `node --test`; the two `tools/*.mjs` modules are the adapter's own relative
/// imports, so omitting them would make the copy unloadable rather than
/// byte-identical.
///
/// Each path is the one a scaffold writes, and also the path of the copy under
/// `crates/cli/embedded/` that this script actually reads.
const VENDORED_PLUGIN_SOURCES: [&str; 6] = [
    "plugins/drizzle-orm/VETTING.md",
    "plugins/drizzle-orm/adapter.js",
    "plugins/drizzle-orm/data-scopes.test.mjs",
    "plugins/postgres/VETTING.md",
    "tools/database-url.mjs",
    "tools/node-sqlite.mjs",
];

fn vendored_plugins(manifest: &Path) -> String {
    let embedded = manifest.join("embedded");
    let mut generated = String::from(
        "\npub(crate) struct VendoredFile {\n    pub(crate) path: &'static str,\n    pub(crate) contents: &'static str,\n}\n\n\
         pub(crate) const VENDORED_PLUGIN_FILES: &[VendoredFile] = &[\n",
    );
    for relative in VENDORED_PLUGIN_SOURCES {
        let absolute = embedded.join(relative);
        assert!(
            absolute.is_file(),
            "vendored plugin source {} is missing",
            absolute.display()
        );
        println!("cargo:rerun-if-changed={}", absolute.display());
        generated.push_str(&format!(
            "    VendoredFile {{ path: {relative:?}, contents: include_str!({:?}) }},\n",
            absolute.display().to_string()
        ));
    }
    generated.push_str("];\n\n");
    generated.push_str(&format!(
        "pub(crate) const VENDORED_PLUGIN_COMMIT: &str = {:?};\n",
        vendored_plugin_commit(&manifest.join("../.."))
    ));
    generated
}

/// The commit that last touched any vendored file, recorded in every scaffold's
/// ledger so a project can say which revision of the plugins it holds. Using
/// the commit that last *changed* those files keeps the ledger stable across
/// unrelated commits instead of churning on every rebuild.
///
/// Outside a checkout of this repository --- which is where a `cargo install`
/// build runs --- there is no commit to name, and the ledger records
/// `"unknown"`.
fn vendored_plugin_commit(repository: &Path) -> String {
    if !repository.join(".git").exists() {
        return "unknown".into();
    }
    let mut command = std::process::Command::new("git");
    command
        .arg("-C")
        .arg(repository)
        .args(["log", "-1", "--format=%H", "--"]);
    for relative in VENDORED_PLUGIN_SOURCES {
        command.arg(relative);
    }
    let Ok(output) = command.output() else {
        return "unknown".into();
    };
    if !output.status.success() {
        return "unknown".into();
    }
    let commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
    if commit.is_empty() {
        "unknown".into()
    } else {
        commit
    }
}

/// Collect every file under `directory`, keyed by its `/`-separated path
/// relative to the template root, in deterministic order.
fn collect(
    root: &Path,
    directory: &Path,
    files: &mut BTreeMap<String, PathBuf>,
    directories: &mut BTreeSet<String>,
) {
    let mut entries = fs::read_dir(directory)
        .unwrap_or_else(|error| panic!("cannot read {}: {error}", directory.display()))
        .map(|entry| entry.expect("template entry").path())
        .collect::<Vec<_>>();
    entries.sort();
    for entry in entries {
        let name = entry
            .file_name()
            .and_then(|name| name.to_str())
            .expect("template file name")
            .to_string();
        if entry.is_dir() {
            if SKIPPED_DIRECTORIES.contains(&name.as_str()) {
                continue;
            }
            collect(root, &entry, files, directories);
            continue;
        }
        if name == ".DS_Store" {
            continue;
        }
        // `.gitkeep` is how a template keeps an intentionally empty directory
        // under version control. It is a marker, not template content: the
        // scaffold creates the directory and writes no file.
        if name == ".gitkeep" {
            let parent = entry
                .parent()
                .expect("template marker has a parent")
                .strip_prefix(root)
                .expect("template directory inside its template")
                .components()
                .map(|component| component.as_os_str().to_string_lossy().into_owned())
                .collect::<Vec<_>>()
                .join("/");
            assert!(
                !parent.is_empty(),
                "a template root cannot be marked with .gitkeep"
            );
            println!("cargo:rerun-if-changed={}", entry.display());
            directories.insert(parent);
            continue;
        }
        let relative = entry
            .strip_prefix(root)
            .expect("template file inside its template")
            .components()
            .map(|component| component.as_os_str().to_string_lossy().into_owned())
            .collect::<Vec<_>>()
            .join("/");
        files.insert(relative, entry);
    }
}