//! 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 list_ail_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")) .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"); } /// CLI-pipeline idempotency (post-form-a-default-authoring §C3): for /// every `.ail` fixture, the user-facing pipeline /// `ail parse | ail render | ail parse` is byte-identical /// to direct `ail parse `. Pins drift internal tests cannot see. #[test] fn cli_parse_then_render_then_parse_is_idempotent() { let fixtures = list_ail_fixtures(); assert!( !fixtures.is_empty(), "no .ail fixtures found under {}", examples_dir().display() ); let tmpdir = tempfile::TempDir::new().expect("create tempdir"); let mut checked = 0usize; let mut failures = Vec::::new(); for ail_path in &fixtures { // Direct parse → JSON tempfile. let json1 = tmpdir.path().join(format!("v1_{checked}.ail.json")); let parse1 = Command::new(ail_bin()) .args([ "parse", ail_path.to_str().unwrap(), "-o", json1.to_str().unwrap(), ]) .output() .expect("spawn ail parse #1"); if !parse1.status.success() { failures.push(format!( "{}: first ail parse exit={:?}\nstderr: {}", ail_path.display(), parse1.status.code(), String::from_utf8_lossy(&parse1.stderr), )); checked += 1; continue; } // Render that JSON → .ail tempfile. let ail_round = tmpdir.path().join(format!("round_{checked}.ail")); let rendered = Command::new(ail_bin()) .args(["render", json1.to_str().unwrap()]) .output() .expect("spawn ail render"); if !rendered.status.success() { failures.push(format!( "{}: ail render exit={:?}\nstderr: {}", ail_path.display(), rendered.status.code(), String::from_utf8_lossy(&rendered.stderr), )); checked += 1; continue; } std::fs::write(&ail_round, &rendered.stdout) .expect("write ail_round tempfile"); // Re-parse → second JSON tempfile. let json2 = tmpdir.path().join(format!("v2_{checked}.ail.json")); let parse2 = Command::new(ail_bin()) .args([ "parse", ail_round.to_str().unwrap(), "-o", json2.to_str().unwrap(), ]) .output() .expect("spawn ail parse #2"); if !parse2.status.success() { failures.push(format!( "{}: second ail parse exit={:?}\nstderr: {}", ail_path.display(), parse2.status.code(), String::from_utf8_lossy(&parse2.stderr), )); checked += 1; continue; } // Assert the two canonical JSONs are byte-equal. let b1 = std::fs::read(&json1).expect("read json1"); let b2 = std::fs::read(&json2).expect("read json2"); if b1 != b2 { let s1 = String::from_utf8_lossy(&b1).into_owned(); let s2 = String::from_utf8_lossy(&b2).into_owned(); failures.push(format!( "{}: CLI parse|render|parse non-idempotent.\n v1: {s1}\n v2: {s2}", ail_path.display(), )); } checked += 1; } if !failures.is_empty() { panic!( "CLI parse|render|parse non-idempotent on {} of {} fixtures:\n{}", failures.len(), checked, failures.join("\n\n") ); } assert!(checked > 0, "no .ail fixtures found"); eprintln!("CLI parse|render|parse idempotent for {checked} fixtures"); }