use std::fs;
use std::path::PathBuf;
use std::process::Command;
use cairn_lang_core::CAIRN_VERSION;
use cairn_lang_core::lock::{HashHex, LockEdition, Lockfile};
use tempfile::TempDir;
fn cargo_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_cairn"))
}
fn examples_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("examples")
}
fn run_compile(args: &[&str]) -> std::process::Output {
Command::new(cargo_bin())
.arg("compile")
.args(args)
.output()
.expect("failed to invoke cairn binary")
}
fn cottage_in_tempdir() -> (TempDir, PathBuf) {
let tmp = TempDir::new().expect("tempdir");
let src = examples_dir().join("cottage.crn");
let dst = tmp.path().join("cottage.crn");
fs::copy(&src, &dst).expect("copy cottage");
(tmp, dst)
}
#[test]
fn c1_compile_cottage_with_explicit_target_exits_zero() {
let (_tmp_src, src) = cottage_in_tempdir();
let out_dir = TempDir::new().expect("out tempdir");
let result = run_compile(&[
src.to_str().unwrap(),
"--edition",
"java",
"--target",
"1.21.4",
"--out",
out_dir.path().to_str().unwrap(),
]);
assert!(
result.status.success(),
"stderr={}",
String::from_utf8_lossy(&result.stderr),
);
}
#[test]
fn c2_compile_writes_named_nbt_into_out_dir() {
let (_tmp_src, src) = cottage_in_tempdir();
let out_dir = TempDir::new().expect("out tempdir");
let result = run_compile(&[
src.to_str().unwrap(),
"--edition",
"java",
"--out",
out_dir.path().to_str().unwrap(),
]);
assert!(result.status.success());
let written = out_dir.path().join("cottage.nbt");
assert!(written.exists(), "expected {} to exist", written.display());
}
#[test]
fn c3_compiled_nbt_begins_with_gzip_magic() {
let (_tmp_src, src) = cottage_in_tempdir();
let out_dir = TempDir::new().expect("out tempdir");
let result = run_compile(&[
src.to_str().unwrap(),
"--edition",
"java",
"--out",
out_dir.path().to_str().unwrap(),
]);
assert!(result.status.success());
let bytes = fs::read(out_dir.path().join("cottage.nbt")).expect("read nbt");
assert!(bytes.len() >= 2);
assert_eq!(&bytes[..2], &[0x1f, 0x8b], "gzip magic prefix");
}
#[test]
fn c4_default_lock_is_written_beside_source() {
let (_tmp_src, src) = cottage_in_tempdir();
let out_dir = TempDir::new().expect("out tempdir");
let result = run_compile(&[
src.to_str().unwrap(),
"--edition",
"java",
"--out",
out_dir.path().to_str().unwrap(),
]);
assert!(result.status.success());
let mut expected_lock = src.as_os_str().to_owned();
expected_lock.push(".lock");
assert!(
PathBuf::from(&expected_lock).exists(),
"expected lockfile next to source: {}",
PathBuf::from(expected_lock).display(),
);
}
#[test]
fn c5_explicit_lock_path_is_honored() {
let (_tmp_src, src) = cottage_in_tempdir();
let out_dir = TempDir::new().expect("out tempdir");
let lock_path = out_dir.path().join("explicit.lock");
let result = run_compile(&[
src.to_str().unwrap(),
"--edition",
"java",
"--out",
out_dir.path().to_str().unwrap(),
"--lock",
lock_path.to_str().unwrap(),
]);
assert!(result.status.success());
assert!(lock_path.exists(), "lockfile at explicit path");
}
#[test]
fn c6_lockfile_records_cairn_version() {
let (_tmp_src, src) = cottage_in_tempdir();
let out_dir = TempDir::new().expect("out tempdir");
let lock_path = out_dir.path().join("c6.lock");
let result = run_compile(&[
src.to_str().unwrap(),
"--edition",
"java",
"--out",
out_dir.path().to_str().unwrap(),
"--lock",
lock_path.to_str().unwrap(),
]);
assert!(result.status.success());
let lf = Lockfile::read_from_path(&lock_path).expect("read lock");
assert_eq!(lf.cairn_version, CAIRN_VERSION);
}
#[test]
fn c7_lockfile_records_target_triple() {
let (_tmp_src, src) = cottage_in_tempdir();
let out_dir = TempDir::new().expect("out tempdir");
let lock_path = out_dir.path().join("c7.lock");
let result = run_compile(&[
src.to_str().unwrap(),
"--edition",
"java",
"--target",
"1.21.4",
"--out",
out_dir.path().to_str().unwrap(),
"--lock",
lock_path.to_str().unwrap(),
]);
assert!(result.status.success());
let lf = Lockfile::read_from_path(&lock_path).expect("read lock");
assert_eq!(lf.target.edition, LockEdition::Java);
assert_eq!(lf.target.mc_version, "1.21.4");
assert_eq!(lf.target.data_version, 4189);
}
#[test]
fn c8_bedrock_compiles_stateless_example_to_mcstructure() {
let tmp = TempDir::new().expect("tempdir");
let dst = tmp.path().join("roof-flat.crn");
fs::copy(examples_dir().join("roof-flat.crn"), &dst).expect("copy roof-flat");
let out_dir = TempDir::new().expect("out tempdir");
let result = run_compile(&[
dst.to_str().unwrap(),
"--edition",
"bedrock",
"--out",
out_dir.path().to_str().unwrap(),
]);
let stderr = String::from_utf8(result.stderr).expect("utf-8");
assert!(
result.status.success(),
"roof-flat should compile for bedrock; stderr={stderr}",
);
assert_eq!(
stderr.matches("W_DEFERRED_MEMBER").count(),
0,
"roof-flat should lower clean, stderr={stderr}",
);
let written = out_dir.path().join("roof_flat.mcstructure");
assert!(written.exists(), "expected {} to exist", written.display());
}
#[test]
fn c8b_bedrock_mcstructure_is_uncompressed_little_endian() {
let tmp = TempDir::new().expect("tempdir");
let dst = tmp.path().join("roof-flat.crn");
fs::copy(examples_dir().join("roof-flat.crn"), &dst).expect("copy roof-flat");
let out_dir = TempDir::new().expect("out tempdir");
let result = run_compile(&[
dst.to_str().unwrap(),
"--edition",
"bedrock",
"--out",
out_dir.path().to_str().unwrap(),
]);
assert!(result.status.success());
let bytes = fs::read(out_dir.path().join("roof_flat.mcstructure")).expect("read mcstructure");
assert!(bytes.len() >= 3);
assert_ne!(&bytes[..2], &[0x1f, 0x8b], "must not be gzip");
assert_eq!(&bytes[..3], &[0x0a, 0x00, 0x00], "unnamed root compound");
}
#[test]
fn c8c_bedrock_lockfile_records_bedrock_target_and_pack_hash() {
let tmp = TempDir::new().expect("tempdir");
let dst = tmp.path().join("roof-flat.crn");
fs::copy(examples_dir().join("roof-flat.crn"), &dst).expect("copy roof-flat");
let out_dir = TempDir::new().expect("out tempdir");
let lock_path = out_dir.path().join("c8c.lock");
let result = run_compile(&[
dst.to_str().unwrap(),
"--edition",
"bedrock",
"--target",
"1.21.60",
"--out",
out_dir.path().to_str().unwrap(),
"--lock",
lock_path.to_str().unwrap(),
]);
assert!(
result.status.success(),
"stderr={}",
String::from_utf8_lossy(&result.stderr),
);
let lf = Lockfile::read_from_path(&lock_path).expect("read lock");
assert_eq!(lf.target.edition, LockEdition::Bedrock);
assert_eq!(lf.target.mc_version, "1.21.60");
assert_eq!(lf.target.data_version, 18_168_865);
assert_ne!(
lf.inputs.registry_pack_hash.as_str(),
HashHex::ZERO_STR,
"bedrock pack hash must be filled in",
);
}
#[test]
fn c8d_bedrock_cottage_maps_stair_states_without_degradation() {
let tmp = TempDir::new().expect("tempdir");
let dst = tmp.path().join("cottage.crn");
fs::copy(examples_dir().join("cottage.crn"), &dst).expect("copy cottage");
let out_dir = TempDir::new().expect("out tempdir");
let result = run_compile(&[
dst.to_str().unwrap(),
"--edition",
"bedrock",
"--out",
out_dir.path().to_str().unwrap(),
]);
let stderr = String::from_utf8(result.stderr).expect("utf-8");
assert!(
result.status.success(),
"cottage on bedrock, stderr={stderr}"
);
assert!(
!stderr.contains("W_INTENT_DEGRADED"),
"cottage's straight gable stairs must not degrade, got: {stderr}",
);
assert!(
out_dir.path().join("cottage.mcstructure").exists(),
"the mcstructure artifact should be written",
);
}
#[test]
fn c8f_bedrock_themed_tower_degrades_stair_shape_but_compiles() {
let tmp = TempDir::new().expect("tempdir");
let dst = tmp.path().join("themed-tower.crn");
fs::copy(examples_dir().join("themed-tower.crn"), &dst).expect("copy themed-tower");
let out_dir = TempDir::new().expect("out tempdir");
let result = run_compile(&[
dst.to_str().unwrap(),
"--edition",
"bedrock",
"--out",
out_dir.path().to_str().unwrap(),
]);
let stderr = String::from_utf8(result.stderr).expect("utf-8");
assert!(
result.status.success(),
"themed-tower should still compile on bedrock, stderr={stderr}",
);
assert!(
stderr.contains("W_INTENT_DEGRADED") && stderr.contains("shape"),
"expected a shape-drop degradation warning, got: {stderr}",
);
assert!(
out_dir.path().join("keep.mcstructure").exists(),
"the mcstructure artifact should still be written on a degradation",
);
}
#[test]
fn c8e_bedrock_unknown_target_names_bedrock_versions() {
let tmp = TempDir::new().expect("tempdir");
let dst = tmp.path().join("roof-flat.crn");
fs::copy(examples_dir().join("roof-flat.crn"), &dst).expect("copy roof-flat");
let out_dir = TempDir::new().expect("out tempdir");
let result = run_compile(&[
dst.to_str().unwrap(),
"--edition",
"bedrock",
"--target",
"1.21.61",
"--out",
out_dir.path().to_str().unwrap(),
]);
assert_eq!(result.status.code(), Some(1));
let stderr = String::from_utf8(result.stderr).expect("utf-8");
assert!(
stderr.contains("unsupported bedrock target"),
"error must name the bedrock vocabulary, got: {stderr}",
);
assert!(stderr.contains("1.21.60"), "supported list, got: {stderr}");
}
#[test]
fn c9_missing_edition_flag_exits_two_with_usage() {
let (_tmp_src, src) = cottage_in_tempdir();
let result = run_compile(&[src.to_str().unwrap()]);
assert_eq!(result.status.code(), Some(2));
let stderr = String::from_utf8(result.stderr).expect("utf-8");
assert!(stderr.contains("--edition"), "expected --edition mention");
}
#[test]
fn c10_unknown_target_exits_one_with_supported_list() {
let (_tmp_src, src) = cottage_in_tempdir();
let out_dir = TempDir::new().expect("out tempdir");
let result = run_compile(&[
src.to_str().unwrap(),
"--edition",
"java",
"--target",
"0.0.0",
"--out",
out_dir.path().to_str().unwrap(),
]);
assert_eq!(result.status.code(), Some(1));
let stderr = String::from_utf8(result.stderr).expect("utf-8");
assert!(stderr.contains("1.20.4"));
assert!(stderr.contains("latest"));
}
#[test]
fn c11_missing_source_exits_two() {
let result = run_compile(&["definitely-not-a-file.crn", "--edition", "java"]);
assert_eq!(result.status.code(), Some(2));
}
#[test]
fn c12_parse_error_surfaces_file_line_col() {
let tmp = TempDir::new().expect("tempdir");
let bad = tmp.path().join("bad.crn");
fs::write(&bad, "struct cottage size=2x2\n;;;\n").expect("write");
let out_dir = TempDir::new().expect("out tempdir");
let result = run_compile(&[
bad.to_str().unwrap(),
"--edition",
"java",
"--out",
out_dir.path().to_str().unwrap(),
]);
assert_eq!(result.status.code(), Some(1));
let stderr = String::from_utf8(result.stderr).expect("utf-8");
let bad_display = bad.display().to_string();
let mut found_position = false;
for line in stderr.lines() {
if !line.contains(&bad_display) {
continue;
}
let rest = &line[line.find(&bad_display).unwrap() + bad_display.len()..];
let mut parts = rest.splitn(4, ':');
let (_empty, line_no, col_no) = (parts.next(), parts.next(), parts.next());
if line_no.is_some_and(|s| s.trim().parse::<u32>().is_ok())
&& col_no.is_some_and(|s| s.trim().parse::<u32>().is_ok())
{
found_position = true;
break;
}
}
assert!(
found_position,
"expected gcc-style `{bad_display}:line:col:` prefix, got stderr={stderr}",
);
}
#[test]
fn c13_default_out_dir_is_source_parent() {
let (tmp_src, src) = cottage_in_tempdir();
let result = run_compile(&[src.to_str().unwrap(), "--edition", "java"]);
assert!(
result.status.success(),
"stderr={}",
String::from_utf8_lossy(&result.stderr),
);
let expected_nbt = tmp_src.path().join("cottage.nbt");
assert!(expected_nbt.exists(), "{} missing", expected_nbt.display());
}
#[test]
fn compile_is_byte_reproducible() {
let (_tmp_src, src) = cottage_in_tempdir();
let out_a = TempDir::new().expect("out a");
let out_b = TempDir::new().expect("out b");
let lock_a = out_a.path().join("a.lock");
let lock_b = out_b.path().join("b.lock");
for (out_dir, lock_path) in [(out_a.path(), &lock_a), (out_b.path(), &lock_b)] {
let result = run_compile(&[
src.to_str().unwrap(),
"--edition",
"java",
"--target",
"1.21.4",
"--out",
out_dir.to_str().unwrap(),
"--lock",
lock_path.to_str().unwrap(),
]);
assert!(result.status.success());
}
let bytes_a = fs::read(out_a.path().join("cottage.nbt")).expect("a");
let bytes_b = fs::read(out_b.path().join("cottage.nbt")).expect("b");
assert_eq!(bytes_a, bytes_b, "compile output is not byte-reproducible");
let lf_a = Lockfile::read_from_path(&lock_a).expect("read a");
let lf_b = Lockfile::read_from_path(&lock_b).expect("read b");
assert_eq!(lf_a.source_hash, lf_b.source_hash);
assert_eq!(lf_a.resolved_ir_hash, lf_b.resolved_ir_hash);
}
#[test]
fn compile_rolls_back_on_lockfile_failure() {
let (_tmp_src, src) = cottage_in_tempdir();
let out_dir = TempDir::new().expect("out tempdir");
let lock_as_dir = out_dir.path().join("lock-is-a-dir");
fs::create_dir(&lock_as_dir).expect("mkdir");
let result = run_compile(&[
src.to_str().unwrap(),
"--edition",
"java",
"--out",
out_dir.path().to_str().unwrap(),
"--lock",
lock_as_dir.to_str().unwrap(),
]);
assert_eq!(result.status.code(), Some(1));
assert!(
!out_dir.path().join("cottage.nbt").exists(),
"rollback should have removed cottage.nbt",
);
}
#[test]
fn compile_does_not_print_wrote_before_lockfile_success() {
let (_tmp_src, src) = cottage_in_tempdir();
let out_dir = TempDir::new().expect("out tempdir");
let lock_as_dir = out_dir.path().join("lock-dir");
fs::create_dir(&lock_as_dir).expect("mkdir");
let result = run_compile(&[
src.to_str().unwrap(),
"--edition",
"java",
"--out",
out_dir.path().to_str().unwrap(),
"--lock",
lock_as_dir.to_str().unwrap(),
]);
let stdout = String::from_utf8(result.stdout).expect("utf-8");
assert!(
!stdout.contains("wrote"),
"no `wrote` line should appear before the lockfile succeeds, got: {stdout}",
);
}
#[test]
fn compile_all_examples_exit_zero() {
for name in [
"cottage.crn",
"themed-tower.crn",
"village.crn",
"redstone-door.crn",
"roof-shed.crn",
"roof-hip.crn",
"roof-flat.crn",
] {
let path = examples_dir().join(name);
let out_dir = TempDir::new().expect("out tempdir");
let lock_path = out_dir.path().join(format!("{name}.lock"));
let result = run_compile(&[
path.to_str().unwrap(),
"--edition",
"java",
"--out",
out_dir.path().to_str().unwrap(),
"--lock",
lock_path.to_str().unwrap(),
]);
assert!(
result.status.success(),
"{name} should compile, stderr={}",
String::from_utf8_lossy(&result.stderr),
);
}
}
#[test]
fn c14c_roof_kind_examples_lower_without_deferred_warnings() {
for name in ["roof-shed.crn", "roof-hip.crn", "roof-flat.crn"] {
let path = examples_dir().join(name);
let out_dir = TempDir::new().expect("out tempdir");
let lock_path = out_dir.path().join(format!("{name}.lock"));
let result = run_compile(&[
path.to_str().unwrap(),
"--edition",
"java",
"--out",
out_dir.path().to_str().unwrap(),
"--lock",
lock_path.to_str().unwrap(),
]);
assert!(
result.status.success(),
"{name} should compile, stderr={}",
String::from_utf8_lossy(&result.stderr),
);
let stderr = String::from_utf8(result.stderr).expect("utf-8");
assert_eq!(
stderr.matches("W_DEFERRED_MEMBER").count(),
0,
"{name} should lower clean, stderr={stderr}",
);
let nbt = out_dir
.path()
.join(name.replace('-', "_").replace(".crn", ".nbt"));
assert!(nbt.exists(), "expected {} to exist", nbt.display());
}
}
#[test]
fn c14f_redstone_door_compiles_without_deferred_warnings() {
let tmp = TempDir::new().expect("tempdir");
let dst = tmp.path().join("redstone-door.crn");
fs::copy(examples_dir().join("redstone-door.crn"), &dst).expect("copy redstone-door");
let out_dir = TempDir::new().expect("out tempdir");
let result = run_compile(&[
dst.to_str().unwrap(),
"--edition",
"java",
"--out",
out_dir.path().to_str().unwrap(),
]);
let stderr = String::from_utf8(result.stderr).expect("utf-8");
assert!(
result.status.success(),
"redstone-door should compile, stderr={stderr}",
);
assert_eq!(
stderr.matches("W_DEFERRED_MEMBER").count(),
0,
"redstone-door should lower clean, stderr={stderr}",
);
assert!(
out_dir.path().join("gatehouse.nbt").exists(),
"redstone-door should still write gatehouse.nbt, stderr={stderr}",
);
}
#[test]
fn c15_lockfile_registry_pack_hash_is_populated() {
let (_tmp_src, src) = cottage_in_tempdir();
let out_dir = TempDir::new().expect("out tempdir");
let lock_path = out_dir.path().join("c15.lock");
let result = run_compile(&[
src.to_str().unwrap(),
"--edition",
"java",
"--out",
out_dir.path().to_str().unwrap(),
"--lock",
lock_path.to_str().unwrap(),
]);
assert!(result.status.success());
let lf = Lockfile::read_from_path(&lock_path).expect("read lock");
assert_ne!(
lf.inputs.registry_pack_hash.as_str(),
HashHex::ZERO_STR,
"registry_pack_hash must be filled in by the registry pack ingest",
);
assert_eq!(
lf.inputs.constraint_catalog_hash.as_str(),
HashHex::ZERO_STR,
"constraint_catalog_hash stays zero until its own ingest lands",
);
}
#[test]
fn c14_cottage_compiles_without_deferred_warnings() {
let (_tmp_src, src) = cottage_in_tempdir();
let out_dir = TempDir::new().expect("out tempdir");
let result = run_compile(&[
src.to_str().unwrap(),
"--edition",
"java",
"--out",
out_dir.path().to_str().unwrap(),
]);
assert!(result.status.success());
let stderr = String::from_utf8(result.stderr).expect("utf-8");
assert_eq!(
stderr.matches("W_DEFERRED_MEMBER").count(),
0,
"cottage should lower clean, stderr={stderr}",
);
}
#[test]
fn c14e_themed_tower_compiles_without_deferred_warnings() {
let tmp = TempDir::new().expect("tempdir");
let dst = tmp.path().join("themed-tower.crn");
fs::copy(examples_dir().join("themed-tower.crn"), &dst).expect("copy themed-tower");
let out_dir = TempDir::new().expect("out tempdir");
let result = run_compile(&[
dst.to_str().unwrap(),
"--edition",
"java",
"--out",
out_dir.path().to_str().unwrap(),
]);
let stderr = String::from_utf8(result.stderr).expect("utf-8");
assert!(
result.status.success(),
"themed-tower should compile clean; stderr={stderr}",
);
assert_eq!(
stderr.matches("W_DEFERRED_MEMBER").count(),
0,
"themed-tower should lower without deferred-member warnings, stderr={stderr}",
);
assert!(
out_dir.path().join("keep.nbt").exists(),
"themed-tower should still write keep.nbt, stderr={stderr}",
);
}
#[test]
fn c16_themed_tower_compiles_with_lifted_abstract_tokens() {
let tmp = TempDir::new().expect("tempdir");
let dst = tmp.path().join("themed-tower.crn");
fs::copy(examples_dir().join("themed-tower.crn"), &dst).expect("copy themed-tower");
let out_dir = TempDir::new().expect("out tempdir");
let result = run_compile(&[
dst.to_str().unwrap(),
"--edition",
"java",
"--out",
out_dir.path().to_str().unwrap(),
]);
let stderr = String::from_utf8(result.stderr).expect("utf-8");
assert!(
result.status.success(),
"themed-tower should compile clean; stderr={stderr}",
);
assert_eq!(
stderr.matches("W_ABSTRACT_TOKEN_DEFERRED").count(),
0,
"abstract tokens must lift via the catalog; stderr={stderr}",
);
assert_eq!(
stderr.matches("E_UNKNOWN_ABSTRACT_TOKEN").count(),
0,
"every token themed-tower binds must be in the catalog; stderr={stderr}",
);
let written = out_dir.path().join("keep.nbt");
assert!(written.exists(), "expected {} to exist", written.display());
}
#[test]
fn c17_unknown_abstract_token_compile_exits_nonzero() {
let tmp = TempDir::new().expect("tempdir");
let src = tmp.path().join("typo.crn");
fs::write(
&src,
concat!(
"@cairn 2026.06\n",
"\n",
"theme t:\n",
" slot floor -> @floor.wood.broadlef\n",
"\n",
"struct s size=3x3\n",
" floor mat_slot=floor\n",
),
)
.expect("write tmp .crn");
let out_dir = TempDir::new().expect("out tempdir");
let result = run_compile(&[
src.to_str().unwrap(),
"--edition",
"java",
"--out",
out_dir.path().to_str().unwrap(),
]);
let stderr = String::from_utf8(result.stderr).expect("utf-8");
assert!(
!result.status.success(),
"exit should be non-zero; stderr={stderr}",
);
assert!(
stderr.contains("E_UNKNOWN_ABSTRACT_TOKEN"),
"stderr should carry the new diagnostic code; got: {stderr}",
);
assert!(
stderr.contains("floor.wood.broadleaf"),
"stderr should surface the nearest declared token; got: {stderr}",
);
}
fn example_in_tempdir(name: &str) -> (TempDir, PathBuf) {
let tmp = TempDir::new().expect("tempdir");
let src = examples_dir().join(name);
let dst = tmp.path().join(name);
fs::copy(&src, &dst).expect("copy example");
(tmp, dst)
}
#[test]
fn c18_compile_village_emits_three_nbt() {
let (_tmp_src, src) = example_in_tempdir("village.crn");
let out_dir = TempDir::new().expect("out tempdir");
let result = run_compile(&[
src.to_str().unwrap(),
"--edition",
"java",
"--out",
out_dir.path().to_str().unwrap(),
]);
let stderr = String::from_utf8(result.stderr.clone()).expect("utf-8");
assert!(
result.status.success(),
"village should compile, stderr={stderr}",
);
for name in ["home1.nbt", "home2.nbt", "home3.nbt"] {
let written = out_dir.path().join(name);
assert!(written.exists(), "expected {} to exist", written.display());
let bytes = fs::read(&written).expect("read nbt");
assert!(
bytes.len() >= 2 && bytes[..2] == [0x1f, 0x8b],
"{name} must be gzip"
);
}
}
#[test]
fn c19_village_lockfile_records_placements() {
let (_tmp_src, src) = example_in_tempdir("village.crn");
let out_dir = TempDir::new().expect("out tempdir");
let lock_path = out_dir.path().join("village.lock");
let result = run_compile(&[
src.to_str().unwrap(),
"--edition",
"java",
"--out",
out_dir.path().to_str().unwrap(),
"--lock",
lock_path.to_str().unwrap(),
]);
assert!(result.status.success());
let lf = Lockfile::read_from_path(&lock_path).expect("read lock");
assert_eq!(lf.placements.len(), 3, "expected one entry per place");
let by_id = |id: &str| {
lf.placements
.iter()
.find(|p| p.id == id)
.unwrap_or_else(|| panic!("placement `{id}` missing from lockfile"))
};
let home1 = by_id("home1");
assert_eq!(home1.origin, [0, 0, 0]);
assert_eq!(home1.def, "cottage");
assert_eq!(home1.theme, "medieval");
assert_eq!(home1.site, "hamlet");
let home2 = by_id("home2");
assert_eq!(home2.origin, [15, 0, 0]);
let home3 = by_id("home3");
assert_eq!(home3.origin, [0, 0, -14]);
}
#[test]
fn c20_village_lockfile_round_trips_through_yaml() {
let (_tmp_src, src) = example_in_tempdir("village.crn");
let out_dir = TempDir::new().expect("out tempdir");
let lock_path = out_dir.path().join("rt.lock");
let _ = run_compile(&[
src.to_str().unwrap(),
"--edition",
"java",
"--out",
out_dir.path().to_str().unwrap(),
"--lock",
lock_path.to_str().unwrap(),
]);
let lf = Lockfile::read_from_path(&lock_path).expect("read lock");
let rewrite = out_dir.path().join("rt.rewrite.lock");
lf.write_to_path(&rewrite).expect("write rewritten lock");
let parsed = Lockfile::read_from_path(&rewrite).expect("read rewritten lock");
assert_eq!(lf, parsed);
}
#[test]
fn c21_village_lowers_connect_rows_into_walkway_artifacts() {
let (_tmp_src, src) = example_in_tempdir("village.crn");
let out_dir = TempDir::new().expect("out tempdir");
let result = run_compile(&[
src.to_str().unwrap(),
"--edition",
"java",
"--out",
out_dir.path().to_str().unwrap(),
]);
assert!(result.status.success());
let stderr = String::from_utf8(result.stderr).expect("utf-8");
assert_eq!(
stderr.matches("W_DEFERRED_MEMBER").count(),
0,
"connect rows must no longer emit W_DEFERRED_MEMBER; stderr={stderr}",
);
assert_eq!(
stderr.matches("W_WALKWAY_BLOCKED").count(),
0,
"the home1↔home3 walkway must route around home1 instead of warning; stderr={stderr}",
);
let written: Vec<_> = std::fs::read_dir(out_dir.path())
.expect("read out_dir")
.filter_map(Result::ok)
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
let walkway_count = written
.iter()
.filter(|name| {
name.starts_with("hamlet_walkway_")
&& std::path::Path::new(name.as_str())
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("nbt"))
})
.count();
assert_eq!(
walkway_count, 2,
"expected two walkway artifacts (one per `connect` row), got {written:?}",
);
}
fn run_check(args: &[&str]) -> std::process::Output {
Command::new(cargo_bin())
.arg("check")
.args(args)
.output()
.expect("failed to invoke cairn binary")
}
#[test]
fn c22_unknown_def_in_place_errors_with_suggestion() {
let tmp = TempDir::new().expect("tempdir");
let src = tmp.path().join("typo_use.crn");
fs::write(
&src,
concat!(
"@cairn 2026.06\n",
"\n",
"def cottage size=4x4:\n",
" walls mat_slot=wall height=3\n",
"\n",
"theme t:\n",
" slot wall -> @cobblestone\n",
"\n",
"site s:\n",
" place id=home1 use=cottag theme=t at=origin\n",
),
)
.expect("write tmp .crn");
let result = run_check(&[src.to_str().unwrap()]);
let stdout = String::from_utf8(result.stdout).expect("utf-8");
assert!(
!result.status.success(),
"exit should be non-zero; stdout={stdout}",
);
assert!(
stdout.contains("E_UNRESOLVED_PLACE_REF"),
"stdout should carry E_UNRESOLVED_PLACE_REF; got: {stdout}",
);
assert!(
stdout.contains("did you mean `cottage`?"),
"stdout should suggest the closest def name; got: {stdout}",
);
}
#[test]
fn c23_unknown_theme_in_place_errors_with_suggestion() {
let tmp = TempDir::new().expect("tempdir");
let src = tmp.path().join("typo_theme.crn");
fs::write(
&src,
concat!(
"@cairn 2026.06\n",
"\n",
"def cottage size=4x4:\n",
" walls mat_slot=wall height=3\n",
"\n",
"theme medieval:\n",
" slot wall -> @cobblestone\n",
"\n",
"site s:\n",
" place id=home1 use=cottage theme=medeival at=origin\n",
),
)
.expect("write tmp .crn");
let result = run_check(&[src.to_str().unwrap()]);
let stdout = String::from_utf8(result.stdout).expect("utf-8");
assert!(!result.status.success(), "stdout={stdout}");
assert!(
stdout.contains("E_UNRESOLVED_THEME_REF"),
"stdout should carry E_UNRESOLVED_THEME_REF; got: {stdout}",
);
assert!(
stdout.contains("did you mean `medieval`?"),
"stdout should suggest the closest theme name; got: {stdout}",
);
}
#[test]
fn c24_duplicate_place_id_errors() {
let tmp = TempDir::new().expect("tempdir");
let src = tmp.path().join("dup.crn");
fs::write(
&src,
concat!(
"@cairn 2026.06\n",
"\n",
"def cottage size=4x4:\n",
" walls mat_slot=wall height=3\n",
"\n",
"theme t:\n",
" slot wall -> @cobblestone\n",
"\n",
"site s:\n",
" place id=home1 use=cottage theme=t at=origin\n",
" place id=home1 use=cottage theme=t east_of=home1 gap=2\n",
),
)
.expect("write tmp .crn");
let result = run_check(&[src.to_str().unwrap()]);
let stdout = String::from_utf8(result.stdout).expect("utf-8");
assert!(!result.status.success(), "stdout={stdout}");
assert!(
stdout.contains("E_DUPLICATE_PLACE_ID"),
"stdout should carry E_DUPLICATE_PLACE_ID; got: {stdout}",
);
}
#[test]
fn c25_east_of_unknown_ref_errors_with_suggestion() {
let tmp = TempDir::new().expect("tempdir");
let src = tmp.path().join("east_typo.crn");
fs::write(
&src,
concat!(
"@cairn 2026.06\n",
"\n",
"def cottage size=4x4:\n",
" walls mat_slot=wall height=3\n",
"\n",
"theme t:\n",
" slot wall -> @cobblestone\n",
"\n",
"site s:\n",
" place id=home1 use=cottage theme=t at=origin\n",
" place id=home2 use=cottage theme=t east_of=home9 gap=2\n",
),
)
.expect("write tmp .crn");
let result = run_check(&[src.to_str().unwrap()]);
let stdout = String::from_utf8(result.stdout).expect("utf-8");
assert!(!result.status.success(), "stdout={stdout}");
assert!(
stdout.contains("E_UNRESOLVED_PLACE_REF"),
"stdout should carry E_UNRESOLVED_PLACE_REF; got: {stdout}",
);
assert!(
stdout.contains("did you mean `home1`?"),
"stdout should suggest the closest prior place id; got: {stdout}",
);
}
#[test]
fn c26_bare_def_without_place_emits_w_unused_def_and_no_nbt() {
let tmp = TempDir::new().expect("tempdir");
let src = tmp.path().join("orphan_def.crn");
fs::write(
&src,
concat!(
"@cairn 2026.06\n",
"\n",
"def cottage size=4x4:\n",
" walls mat_slot=wall height=3\n",
"\n",
"theme t:\n",
" slot wall -> @cobblestone\n",
),
)
.expect("write tmp .crn");
let out_dir = TempDir::new().expect("out tempdir");
let result = run_compile(&[
src.to_str().unwrap(),
"--edition",
"java",
"--out",
out_dir.path().to_str().unwrap(),
]);
let stderr = String::from_utf8(result.stderr.clone()).expect("utf-8");
assert!(
result.status.success(),
"W_UNUSED_DEF is a warning, not an error; stderr={stderr}",
);
assert!(
stderr.contains("W_UNUSED_DEF"),
"`cairn compile` should surface W_UNUSED_DEF on stderr; got: {stderr}",
);
let entries: Vec<_> = fs::read_dir(out_dir.path())
.expect("read out dir")
.filter_map(Result::ok)
.filter(|e| {
e.path()
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("nbt"))
})
.collect();
assert!(
entries.is_empty(),
"bare def must not produce a .nbt; got {entries:?}",
);
}
#[test]
fn c26b_unknown_def_in_place_compile_exits_nonzero() {
let tmp = TempDir::new().expect("tempdir");
let src = tmp.path().join("typo_use_compile.crn");
fs::write(
&src,
concat!(
"@cairn 2026.06\n",
"\n",
"def cottage size=4x4:\n",
" walls mat_slot=wall height=3\n",
"\n",
"theme t:\n",
" slot wall -> @cobblestone\n",
"\n",
"site s:\n",
" place id=home1 use=cottag theme=t at=origin\n",
),
)
.expect("write tmp .crn");
let out_dir = TempDir::new().expect("out tempdir");
let result = run_compile(&[
src.to_str().unwrap(),
"--edition",
"java",
"--out",
out_dir.path().to_str().unwrap(),
]);
let stderr = String::from_utf8(result.stderr).expect("utf-8");
assert!(
!result.status.success(),
"compile should fail on E_UNRESOLVED_PLACE_REF; stderr={stderr}",
);
assert!(
stderr.contains("E_UNRESOLVED_PLACE_REF"),
"stderr should carry E_UNRESOLVED_PLACE_REF; got: {stderr}",
);
assert!(
stderr.contains("did you mean `cottage`?"),
"stderr should propagate the nearest-match note; got: {stderr}",
);
}
#[test]
fn c27_home2_nbt_uses_resolved_theme_palette() {
let (_tmp_src, src) = example_in_tempdir("village.crn");
let out_dir = TempDir::new().expect("out tempdir");
let lock_path = out_dir.path().join("village.lock");
let _ = run_compile(&[
src.to_str().unwrap(),
"--edition",
"java",
"--out",
out_dir.path().to_str().unwrap(),
"--lock",
lock_path.to_str().unwrap(),
]);
let home2 = out_dir.path().join("home2.nbt");
let bytes = fs::read(&home2).expect("read home2.nbt");
assert!(
bytes.len() > 64,
"home2.nbt should carry real palette + voxel data, got {} bytes",
bytes.len(),
);
let lf = Lockfile::read_from_path(&lock_path).expect("read lock");
assert_ne!(
lf.resolved_ir_hash.as_str(),
HashHex::ZERO_STR,
"resolved_ir_hash must reflect lowered voxels, not an empty IR",
);
}
#[test]
fn compile_overwrites_existing_output() {
let (_tmp_src, src) = cottage_in_tempdir();
let out_dir = TempDir::new().expect("out tempdir");
let nbt_path = out_dir.path().join("cottage.nbt");
fs::write(&nbt_path, b"stale bytes").expect("seed nbt");
let lock_path = out_dir.path().join("c.lock");
fs::write(&lock_path, "stale: true\n").expect("seed lock");
let result = run_compile(&[
src.to_str().unwrap(),
"--edition",
"java",
"--out",
out_dir.path().to_str().unwrap(),
"--lock",
lock_path.to_str().unwrap(),
]);
assert!(result.status.success());
let bytes = fs::read(&nbt_path).expect("read nbt");
assert_ne!(bytes, b"stale bytes", "nbt should have been overwritten");
assert_eq!(&bytes[..2], &[0x1f, 0x8b], "overwritten file is real gzip");
let lf = Lockfile::read_from_path(&lock_path).expect("read lock");
assert!(lf.verified);
}