//! CLI-pipeline idempotency gate: every `examples/*.ail` survives //! `ail parse | ail render | ail parse` with byte identity on the //! canonical JSON output. //! //! 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 parse` and `ail render` 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. //! //! Retired iter form-a.1 T9: //! `cli_render_then_parse_preserves_canonical_bytes_on_every_fixture` //! (walked `.ail.json` corpus; replaced by the corpus-flipped //! `cli_parse_then_render_then_parse_is_idempotent` below, added in //! T1). The `list_json_fixtures` helper and `roundtrip_one` helper //! were the only consumers of `.ail.json` walks and are retired //! alongside. use ailang_test_support::{examples_dir, list_ail_fixtures}; use std::process::Command; fn ail_bin() -> &'static str { env!("CARGO_BIN_EXE_ail") } /// 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"); }