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),
}
}