//! CLI roundtrip: every `examples/*.ail.json` survives //! `ail render` → tempfile → `ail parse` with BLAKE3 hash //! identity on canonical bytes. //! //! This is the user-facing-surface defence line. Crate-internal //! roundtrip tests in `ailang-surface` cover the same property //! at the library level; this test additionally protects the //! `ail render` and `ail parse` CLI wrappers from drift (output //! formatting, exit codes, stdout framing). //! //! Pure reader: the test writes only to a `tempfile::TempDir` //! that lives outside the repo and is cleaned up by Drop. No //! committed content is mutated. use std::path::{Path, PathBuf}; use std::process::Command; fn ail_bin() -> &'static str { env!("CARGO_BIN_EXE_ail") } fn workspace_root() -> PathBuf { let manifest_dir = env!("CARGO_MANIFEST_DIR"); Path::new(manifest_dir).parent().unwrap().parent().unwrap().to_path_buf() } fn examples_dir() -> PathBuf { workspace_root().join("examples") } fn list_json_fixtures() -> Vec { let dir = examples_dir(); let mut paths: Vec = std::fs::read_dir(&dir) .unwrap_or_else(|e| panic!("read_dir({}): {e}", dir.display())) .filter_map(|entry| entry.ok()) .map(|e| e.path()) .filter(|p| { p.file_name() .and_then(|n| n.to_str()) .map(|n| n.ends_with(".ail.json")) .unwrap_or(false) }) .collect(); paths.sort(); paths } fn strip_trailing_newlines(mut v: Vec) -> Vec { while v.last() == Some(&b'\n') { v.pop(); } v } /// Run the full render → parse pipeline for one fixture. Returns /// Ok(()) on hash-identity, Err(reason) otherwise. fn roundtrip_one(fixture: &Path, tmpdir: &Path) -> Result<(), String> { // Step A: load the original fixture and compute canonical bytes // (independent of how the JSON file is formatted on disk). let original_module = ailang_core::load_module(fixture) .map_err(|e| format!("load_module: {e}"))?; let bytes_orig = ailang_core::canonical::to_bytes(&original_module); let h_orig = blake3::hash(&bytes_orig); // Step B: `ail render ` → captured stdout = ail text. let render_out = Command::new(ail_bin()) .args(["render", fixture.to_str().unwrap()]) .output() .map_err(|e| format!("spawn ail render: {e}"))?; if !render_out.status.success() { return Err(format!( "ail render exit={:?}\nstderr: {}", render_out.status.code(), String::from_utf8_lossy(&render_out.stderr), )); } // Step C: write the rendered .ail into the tempdir. let stem = fixture .file_name() .and_then(|n| n.to_str()) .and_then(|n| n.strip_suffix(".ail.json")) .unwrap_or("fixture"); let tmp_ail = tmpdir.join(format!("{stem}.round.ail")); std::fs::write(&tmp_ail, &render_out.stdout) .map_err(|e| format!("write tempfile {}: {e}", tmp_ail.display()))?; // Step D: `ail parse ` → captured stdout = canonical // bytes + trailing newline. let parse_out = Command::new(ail_bin()) .args(["parse", tmp_ail.to_str().unwrap()]) .output() .map_err(|e| format!("spawn ail parse: {e}"))?; if !parse_out.status.success() { return Err(format!( "ail parse exit={:?}\nstderr: {}", parse_out.status.code(), String::from_utf8_lossy(&parse_out.stderr), )); } let bytes_round = strip_trailing_newlines(parse_out.stdout); let h_round = blake3::hash(&bytes_round); if h_orig != h_round { let s_orig = String::from_utf8_lossy(&bytes_orig).into_owned(); let s_round = String::from_utf8_lossy(&bytes_round).into_owned(); return Err(format!( "BLAKE3 mismatch:\n orig: {}\n round: {}\noriginal bytes: {s_orig}\nround bytes: {s_round}", h_orig.to_hex(), h_round.to_hex(), )); } Ok(()) } #[test] fn cli_render_then_parse_preserves_canonical_bytes_on_every_fixture() { let fixtures = list_json_fixtures(); assert!( !fixtures.is_empty(), "no .ail.json fixtures found under {}", examples_dir().display() ); let tmpdir = tempfile::TempDir::new().expect("create tempdir"); let mut failures = Vec::::new(); let mut passed = 0usize; for fixture in &fixtures { match roundtrip_one(fixture, tmpdir.path()) { Ok(()) => passed += 1, Err(msg) => failures.push(format!("{}: {msg}", fixture.display())), } } if !failures.is_empty() { panic!( "CLI roundtrip failed for {} of {} fixtures (passed: {}):\n{}", failures.len(), fixtures.len(), passed, failures.join("\n\n") ); } eprintln!("CLI roundtrip ok for {passed} fixtures"); }