noxid-cli 0.2.1

The Noxid compiler command line: check, build, test, adapt, and the agent surface
//! The embedded asset copies must stay byte-identical to their originals.
//!
//! A published crate is a tarball of its own directory. Nothing above
//! `crates/<name>/` travels with it, so an `include_str!` reaching for
//! `../../../tools/...` — or a build script reading `../../examples/templates`
//! — compiles inside this checkout and fails for everyone who runs
//! `cargo install noxid-cli`. That defect shipped in 0.2.0, because the publish
//! ran with `--no-verify`.
//!
//! The fix keeps each embedded file inside the crate that embeds it, under
//! `crates/<name>/embedded/`. The repository-level files stay the source of
//! truth, and this test is what stops the duplicate from rotting: edit
//! `tools/farm-dev.mjs` without running `tools/sync-embedded.sh` and CI fails
//! here rather than silently shipping a stale runner to users.

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

/// `(repository path, crate-relative copy)`. Keep in step with the copy table
/// in `tools/sync-embedded.sh`.
const EMBEDDED_FILES: &[(&str, &str)] = &[
    (
        "tools/database-url.mjs",
        "crates/cli/embedded/tools/database-url.mjs",
    ),
    (
        "tools/farm-build.mjs",
        "crates/cli/embedded/tools/farm-build.mjs",
    ),
    (
        "tools/farm-dev.mjs",
        "crates/cli/embedded/tools/farm-dev.mjs",
    ),
    (
        "tools/native-esm.mjs",
        "crates/cli/embedded/tools/native-esm.mjs",
    ),
    (
        "tools/node-sqlite.mjs",
        "crates/cli/embedded/tools/node-sqlite.mjs",
    ),
    (
        "tools/noxid-action-dev.mjs",
        "crates/cli/embedded/tools/noxid-action-dev.mjs",
    ),
    (
        "tools/noxid-db.mjs",
        "crates/cli/embedded/tools/noxid-db.mjs",
    ),
    (
        "tools/noxid-prerender.mjs",
        "crates/cli/embedded/tools/noxid-prerender.mjs",
    ),
    (
        "tools/server-lifecycle-exports.txt",
        "crates/cli/embedded/tools/server-lifecycle-exports.txt",
    ),
    (
        "plugins/drizzle-orm/VETTING.md",
        "crates/cli/embedded/plugins/drizzle-orm/VETTING.md",
    ),
    (
        "plugins/drizzle-orm/adapter.js",
        "crates/cli/embedded/plugins/drizzle-orm/adapter.js",
    ),
    (
        "plugins/drizzle-orm/data-scopes.test.mjs",
        "crates/cli/embedded/plugins/drizzle-orm/data-scopes.test.mjs",
    ),
    (
        "plugins/postgres/VETTING.md",
        "crates/cli/embedded/plugins/postgres/VETTING.md",
    ),
    (
        "tools/server-lifecycle-exports.txt",
        "crates/codegen-server-js/embedded/tools/server-lifecycle-exports.txt",
    ),
];

/// `(repository directory, crate-relative copy)` for whole trees.
const EMBEDDED_TREES: &[(&str, &str)] = &[("examples/templates", "crates/cli/embedded/templates")];

/// Directories a template never carries into a scaffold, and which
/// `tools/sync-embedded.sh` therefore does not copy.
const SKIPPED_DIRECTORIES: [&str; 4] = ["target", "dist", "node_modules", ".git"];

fn repository() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("../..")
        .canonicalize()
        .expect("repository root")
}

/// This test guards the checkout, where both sides of every pair exist. Inside
/// a published tarball only the embedded copy ships, and there is nothing to
/// compare it against.
fn inside_checkout(repository: &Path) -> bool {
    repository.join("tools/sync-embedded.sh").is_file()
}

#[test]
fn embedded_files_match_their_repository_originals() {
    let repository = repository();
    if !inside_checkout(&repository) {
        return;
    }
    for (original, copy) in EMBEDDED_FILES {
        let original_path = repository.join(original);
        let copy_path = repository.join(copy);
        let original_bytes = fs::read(&original_path)
            .unwrap_or_else(|error| panic!("read {}: {error}", original_path.display()));
        let copy_bytes = fs::read(&copy_path).unwrap_or_else(|error| {
            panic!(
                "read {}: {error}\nrun tools/sync-embedded.sh",
                copy_path.display()
            )
        });
        assert!(
            original_bytes == copy_bytes,
            "{copy} has drifted from {original}; run tools/sync-embedded.sh"
        );
    }
}

#[test]
fn embedded_trees_match_their_repository_originals() {
    let repository = repository();
    if !inside_checkout(&repository) {
        return;
    }
    for (original, copy) in EMBEDDED_TREES {
        let original_files = tree(&repository.join(original));
        let copy_files = tree(&repository.join(copy));
        assert!(
            original_files
                .iter()
                .map(|(path, _)| path)
                .eq(copy_files.iter().map(|(path, _)| path)),
            "{copy} holds a different file set than {original}; run tools/sync-embedded.sh"
        );
        for ((path, original_bytes), (_, copy_bytes)) in original_files.iter().zip(&copy_files) {
            assert!(
                original_bytes == copy_bytes,
                "{copy}/{path} has drifted from {original}/{path}; run tools/sync-embedded.sh"
            );
        }
    }
}

/// Every file under `root`, as `(relative path, contents)`, in a deterministic
/// order, skipping what a template never carries.
fn tree(root: &Path) -> Vec<(String, Vec<u8>)> {
    let mut files = Vec::new();
    collect(root, root, &mut files);
    files.sort_by(|(left, _), (right, _)| left.cmp(right));
    assert!(!files.is_empty(), "{} holds no files", root.display());
    files
}

fn collect(root: &Path, directory: &Path, files: &mut Vec<(String, Vec<u8>)>) {
    let entries = fs::read_dir(directory).unwrap_or_else(|error| {
        panic!(
            "read {}: {error}\nrun tools/sync-embedded.sh",
            directory.display()
        )
    });
    for entry in entries {
        let path = entry.expect("directory entry").path();
        let name = path
            .file_name()
            .and_then(|name| name.to_str())
            .expect("entry name")
            .to_string();
        if path.is_dir() {
            if !SKIPPED_DIRECTORIES.contains(&name.as_str()) {
                collect(root, &path, files);
            }
            continue;
        }
        if name == ".DS_Store" {
            continue;
        }
        let relative = path
            .strip_prefix(root)
            .expect("entry inside its root")
            .components()
            .map(|component| component.as_os_str().to_string_lossy().into_owned())
            .collect::<Vec<_>>()
            .join("/");
        files.push((relative, fs::read(&path).expect("read entry")));
    }
}