frame-cli 0.1.0

CLI for Frame — scaffold applications, manage components, run dev server
Documentation
//! End-to-end acceptance for deterministic, atomic Frame application scaffolds.

use std::collections::BTreeMap;
use std::error::Error;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU64, Ordering};

use frame_cli::{NewError, NewOptions, generate};

static SEQUENCE: AtomicU64 = AtomicU64::new(0);

type TestResult = Result<(), Box<dyn Error>>;

struct TestDirectory(PathBuf);

impl TestDirectory {
    fn new(label: &str) -> Result<Self, std::io::Error> {
        let sequence = SEQUENCE.fetch_add(1, Ordering::Relaxed);
        let path = std::env::temp_dir().join(format!(
            "frame-cli-{label}-{}-{sequence}",
            std::process::id()
        ));
        remove_if_present(&path)?;
        fs::create_dir_all(&path)?;
        Ok(Self(path))
    }

    fn path(&self) -> &Path {
        &self.0
    }
}

impl Drop for TestDirectory {
    fn drop(&mut self) {
        if let Err(error) = remove_if_present(&self.0) {
            eprintln!("failed to remove {}: {error}", self.0.display());
        }
    }
}

fn options(parent: &Path, name: &str) -> NewOptions {
    NewOptions {
        name: name.to_owned(),
        target_parent: parent.to_path_buf(),
    }
}

#[test]
fn generated_project_passes_all_four_gates_untouched() -> TestResult {
    require_gleam()?;
    let directory = TestDirectory::new("all-gates")?;
    let project = generate(&options(directory.path(), "gate_app"))?;
    let before = generated_files(&project)?;

    assert_success(
        run(&project, "cargo", &["fmt", "--all"])?,
        "cargo fmt --all",
    )?;
    assert_success(
        run(
            &project,
            "cargo",
            &[
                "clippy",
                "--workspace",
                "--all-targets",
                "--",
                "-D",
                "warnings",
            ],
        )?,
        "cargo clippy --workspace --all-targets -- -D warnings",
    )?;
    assert_success(
        run(&project, "cargo", &["test", "--workspace"])?,
        "cargo test --workspace",
    )?;
    let mut doc = Command::new("cargo");
    doc.args(["doc", "--workspace", "--no-deps"])
        .env("RUSTDOCFLAGS", "-D warnings")
        .current_dir(&project);
    assert_success(
        doc.output()?,
        "RUSTDOCFLAGS=-D warnings cargo doc --workspace --no-deps",
    )?;

    assert_eq!(
        before,
        generated_files(&project)?,
        "gates changed generated source"
    );

    let run_output = run(&project, "cargo", &["run", "--quiet", "-p", "app-host"])?;
    let stdout = String::from_utf8_lossy(&run_output.stdout).into_owned();
    assert_success(run_output, "cargo run --quiet -p app-host")?;
    assert!(stdout.starts_with("entity id round-tripped: "));
    assert_eq!(stdout.trim().len(), "entity id round-tripped: ".len() + 64);
    Ok(())
}

#[test]
fn generated_manifests_pin_the_actual_dependency_set_and_files_stay_bounded() -> TestResult {
    let directory = TestDirectory::new("manifest-pins")?;
    let project = generate(&options(directory.path(), "pinned_app"))?;
    let root_manifest = fs::read_to_string(project.join("Cargo.toml"))?;
    assert!(root_manifest.contains("beamr = { version = \"=0.15.4\""));
    assert!(root_manifest.contains("frame-core = { version = \"=0.1.0\" }"));
    assert!(root_manifest.contains("frame-state = { version = \"=0.1.0\" }"));
    assert!(
        !root_manifest.contains("path ="),
        "registry-pinned scaffold must carry no path dependencies"
    );
    assert!(!root_manifest.contains("haematite ="));
    for (path, bytes) in generated_files(&project)? {
        assert!(
            !bytes
                .windows(b"{{FRAME_ROOT}}".len())
                .any(|window| window == b"{{FRAME_ROOT}}"),
            "{} carried {{{{FRAME_ROOT}}}} residue",
            path.display()
        );
    }

    let host_source = fs::read_to_string(project.join("host/src/lib.rs"))?;
    assert!(host_source.contains("Process observation is event-driven"));
    assert!(!host_source.contains("observation_interval"));
    assert!(!host_source.contains("OBSERVATION_INTERVAL"));

    let host_manifest = fs::read_to_string(project.join("host/Cargo.toml"))?;
    let dependency_block = host_manifest
        .split_once("[dependencies]")
        .and_then(|(_, tail)| tail.split_once("[lints]"))
        .map(|(dependencies, _)| dependencies)
        .ok_or("generated host manifest lacked its dependency block")?;
    assert_eq!(
        dependency_block
            .lines()
            .filter(|line| !line.trim().is_empty())
            .collect::<Vec<_>>(),
        [
            "beamr = { workspace = true }",
            "frame-core = { workspace = true }",
            "frame-state = { workspace = true }",
        ]
    );

    for (path, bytes) in generated_files(&project)? {
        assert!(
            bytes.split(|byte| *byte == b'\n').count() < 500,
            "{} exceeded the 500-line cap",
            path.display()
        );
    }
    Ok(())
}

#[test]
fn generation_is_byte_deterministic_and_frame_core_name_cannot_collide() -> TestResult {
    let left = TestDirectory::new("deterministic-left")?;
    let right = TestDirectory::new("deterministic-right")?;
    let first = generate(&options(left.path(), "same_app"))?;
    let second = generate(&options(right.path(), "same_app"))?;
    assert_eq!(tree_hash(&first)?, tree_hash(&second)?);

    let collision = generate(&options(left.path(), "frame-core"));
    assert!(matches!(collision, Err(NewError::InvalidBothNames { .. })));
    Ok(())
}

#[test]
fn existing_target_is_typed_and_untouched() -> TestResult {
    let directory = TestDirectory::new("existing")?;
    let target = directory.path().join("kept_app");
    fs::create_dir(&target)?;
    fs::write(target.join("marker"), b"untouched")?;
    let result = generate(&options(directory.path(), "kept_app"));
    assert!(matches!(result, Err(NewError::TargetExists { .. })));
    assert_eq!(fs::read(target.join("marker"))?, b"untouched");
    Ok(())
}

#[test]
fn missing_gleam_is_actionable_and_never_skipped() -> TestResult {
    let directory = TestDirectory::new("missing-gleam")?;
    let project = generate(&options(directory.path(), "tool_app"))?;
    let builder = directory.path().join("generated-build-script");
    assert_success(
        Command::new("rustc")
            .args(["--edition", "2024"])
            .arg(project.join("host/build.rs"))
            .arg("-o")
            .arg(&builder)
            .output()?,
        "compile generated build script",
    )?;
    let output = Command::new(builder)
        .current_dir(project.join("host"))
        .env("PATH", directory.path())
        .env("OUT_DIR", directory.path())
        .output()?;
    assert!(!output.status.success());
    let message = String::from_utf8_lossy(&output.stderr);
    assert!(message.contains("Gleam toolchain is required"));
    assert!(message.contains("https://gleam.run/getting-started/installing/"));
    Ok(())
}

fn require_gleam() -> Result<(), Box<dyn Error>> {
    let output = Command::new("gleam").arg("--version").output().map_err(|error| {
        std::io::Error::new(
            error.kind(),
            format!("Gleam is mandatory for scaffold acceptance; install it from https://gleam.run/getting-started/installing/: {error}"),
        )
    })?;
    assert_success(output, "mandatory gleam --version")
}

fn run(directory: &Path, program: &str, args: &[&str]) -> Result<Output, std::io::Error> {
    Command::new(program)
        .args(args)
        .current_dir(directory)
        .output()
}

fn assert_success(output: Output, command: &str) -> Result<(), Box<dyn Error>> {
    let Output {
        status,
        stdout,
        stderr,
    } = output;
    if status.success() {
        return Ok(());
    }
    Err(format!(
        "{command} failed with {status}\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&stdout),
        String::from_utf8_lossy(&stderr)
    )
    .into())
}

fn generated_files(root: &Path) -> Result<BTreeMap<PathBuf, Vec<u8>>, std::io::Error> {
    let mut files = BTreeMap::new();
    collect_files(root, root, &mut files)?;
    Ok(files)
}

fn collect_files(
    root: &Path,
    directory: &Path,
    files: &mut BTreeMap<PathBuf, Vec<u8>>,
) -> Result<(), std::io::Error> {
    let mut entries = fs::read_dir(directory)?.collect::<Result<Vec<_>, _>>()?;
    entries.sort_by_key(std::fs::DirEntry::file_name);
    for entry in entries {
        let path = entry.path();
        if path
            .file_name()
            .is_some_and(|name| name == "target" || name == "build")
            || path
                .file_name()
                .is_some_and(|name| name == "Cargo.lock" || name == "manifest.toml")
        {
            continue;
        }
        if path.is_dir() {
            collect_files(root, &path, files)?;
        } else {
            let relative = path
                .strip_prefix(root)
                .map_err(std::io::Error::other)?
                .to_path_buf();
            files.insert(relative, fs::read(path)?);
        }
    }
    Ok(())
}

fn tree_hash(root: &Path) -> Result<blake3::Hash, std::io::Error> {
    let mut hasher = blake3::Hasher::new();
    for (path, bytes) in generated_files(root)? {
        let path = path.to_string_lossy();
        hasher.update(&(path.len() as u64).to_le_bytes());
        hasher.update(path.as_bytes());
        hasher.update(&(bytes.len() as u64).to_le_bytes());
        hasher.update(&bytes);
    }
    Ok(hasher.finalize())
}

fn remove_if_present(path: &Path) -> Result<(), std::io::Error> {
    match fs::remove_dir_all(path) {
        Ok(()) => Ok(()),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
        Err(error) => Err(error),
    }
}