//! End-to-end test: load example module, compile to binary, run it. //! //! This test guards the most important property of the entire pipeline: //! AST → typecheck → LLVM IR → clang → binary → correct stdout. use std::path::Path; use std::process::Command; fn ail_bin() -> &'static str { env!("CARGO_BIN_EXE_ail") } fn build_and_run(example: &str) -> String { // Workspace root is two levels above the crate manifest. let manifest_dir = env!("CARGO_MANIFEST_DIR"); let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap(); let src = workspace.join("examples").join(example); let tmp = std::env::temp_dir().join(format!( "ailang_e2e_{}_{}", example.replace('.', "_"), std::process::id() )); std::fs::create_dir_all(&tmp).unwrap(); let out = tmp.join("bin"); let status = Command::new(ail_bin()) .args(["build", src.to_str().unwrap(), "-o"]) .arg(&out) .status() .expect("ail build failed to run"); assert!(status.success(), "ail build failed for {example}"); let output = Command::new(&out).output().expect("execute binary"); assert!( output.status.success(), "binary {} exited non-zero", out.display() ); String::from_utf8(output.stdout).expect("stdout utf8") } #[test] fn sum_1_to_10_is_55() { let stdout = build_and_run("sum.ail.json"); assert_eq!(stdout.trim(), "55"); } /// Guards block tracking in codegen: max3 has nested `if`s, and wrong /// phi block tracking would produce a wrong result here. #[test] fn max3_picks_largest() { let stdout = build_and_run("max3.ail.json"); assert_eq!(stdout.trim(), "17"); } #[test] fn hello_world_str_lit() { let stdout = build_and_run("hello.ail.json"); assert_eq!(stdout.trim(), "Hello, AILang."); } /// Guards ADT codegen + match: recursive list, sum_list via match on Cons/Nil. #[test] fn list_sum_via_match() { let stdout = build_and_run("list.ail.json"); assert_eq!(stdout.trim(), "42"); } /// Iter 7: first-class function references — `apply(inc, 41)` must /// produce 42, exercising fn-typed parameters and indirect call. #[test] fn higher_order_apply_inc() { let stdout = build_and_run("hof.ail.json"); assert_eq!(stdout.trim(), "42"); } /// Iter 8b: lambdas with capture. `let n = 3 in apply(\x. x + n, 39)` /// must print 42 — the lambda captures `n` from the enclosing `let`, /// the env struct is malloc'd, the closure pair is passed to `apply`, /// and apply's indirect call unpacks both halves. #[test] fn closure_captures_let_n() { let stdout = build_and_run("closure.ail.json"); assert_eq!(stdout.trim(), "42"); } /// Iter 9 dogfood: a non-trivial program that combines ADTs, recursion, /// closure-without-capture, fn-typed parameters, IO effects, and `let`- /// sequencing of effectful sub-expressions in a pattern-match arm. /// `map (\x. x * 2)` over `[1, 2, 3]` then print each element. #[test] fn list_map_doubles_then_prints() { let stdout = build_and_run("list_map.ail.json"); let lines: Vec<&str> = stdout.lines().collect(); assert_eq!(lines, vec!["2", "4", "6"]); } /// Iter 14a: end-to-end exercise of parameterised ADTs through a /// polymorphic higher-order fn. `data List a` plus /// `map : forall a b. ((a) -> b, List) -> List` recursive, /// instantiated at `(Int, Int)`. Guards: forall with two type vars + /// recursive call inside its own body + ctor construction with /// substituted field types + match-arm bindings flowing into an /// effectful tail call. #[test] fn list_map_poly_inc_then_prints() { let stdout = build_and_run("list_map_poly.ail.json"); let lines: Vec<&str> = stdout.lines().collect(); assert_eq!(lines, vec!["2", "3", "4"]); } /// Iter 14e: `tail: true` annotation on `print_list`'s recursive /// call must reach LLVM as a `musttail call`. Asserted by emitting IR /// for list_map_poly and grepping for the exact instruction. This is /// the only direct evidence that the type-system marker actually /// influences codegen — the e2e test above only checks observed /// stdout, which `musttail` does not change. #[test] fn iter14e_print_list_recursion_emits_musttail() { let manifest_dir = env!("CARGO_MANIFEST_DIR"); let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap(); let src = workspace.join("examples").join("list_map_poly.ail.json"); let tmp = std::env::temp_dir().join(format!( "ailang_iter14e_musttail_{}", std::process::id() )); std::fs::create_dir_all(&tmp).unwrap(); let out_ll = tmp.join("list_map_poly.ll"); let status = Command::new(ail_bin()) .args(["emit-ir", src.to_str().unwrap(), "-o"]) .arg(&out_ll) .status() .expect("ail emit-ir failed to run"); assert!(status.success(), "ail emit-ir failed"); let ir = std::fs::read_to_string(&out_ll).expect("read emitted IR"); // Find the musttail call to print_list inside the print_list body. // The exact line shape: // %vN = musttail call i8 @ail_list_map_poly_print_list(ptr %vM) let has_musttail = ir.lines().any(|l| { l.contains("musttail call") && l.contains("@ail_list_map_poly_print_list(") }); assert!( has_musttail, "expected `musttail call ... @ail_list_map_poly_print_list(...)` \ in emitted IR; not found.\nIR:\n{ir}" ); // Defence-in-depth: the musttail call must be immediately followed // by a `ret i8 %v...`. We assert the next non-empty line is a ret. let mut lines = ir.lines(); while let Some(line) = lines.next() { if line.contains("musttail call") && line.contains("@ail_list_map_poly_print_list(") { let next = lines.next().unwrap_or("").trim(); assert!( next.starts_with("ret i8 "), "musttail call must be immediately followed by `ret i8 ...`; \ got: `{next}`" ); return; } } panic!("musttail call line not found (sanity check)"); } /// Iter 11 dogfood: insertion sort over an 11-element IntList. /// Exercises `<=`, `if`, mutual-leaf recursion (`insert` and `sort`), /// nested ctor construction, and the Iter 10 seq operator inside /// print_list. Validates that the language handles deeper ADT /// recursion + branching without surprises. #[test] fn insertion_sort_orders_list() { let stdout = build_and_run("sort.ail.json"); let lines: Vec<&str> = stdout.lines().collect(); assert_eq!( lines, vec!["1", "1", "2", "3", "3", "4", "5", "5", "5", "6", "9"], ); } /// Iter 12b: polymorphism end-to-end. `id : forall a. (a) -> a` /// gets called with `42` (Int) and `true` (Bool); each instantiation /// triggers a separate specialised LLVM fn. Output: 42, then "true". #[test] fn polymorphic_id_at_int_and_bool() { let stdout = build_and_run("poly_id.ail.json"); let lines: Vec<&str> = stdout.lines().collect(); assert_eq!(lines, vec!["42", "true"]); } /// Iter 12b: polymorphism with a function-typed parameter. /// `apply : forall a b. ((a) -> b, a) -> b` invoked as /// `apply(succ, 41) == 42`. Exercises the closure-pair ABI inside a /// monomorphised body (a fn-typed param survives substitution). #[test] fn polymorphic_apply_with_fn_param() { let stdout = build_and_run("poly_apply.ail.json"); assert_eq!(stdout.trim(), "42"); } /// Iter 13b: round-trip a primitive through a parameterised ADT and a /// polymorphic-fn boundary. `type Box[a] = MkBox(a)` plus /// `unbox : forall a. (Box) -> a` plus `print_int(unbox(MkBox(42)))`. /// Guards: ctor lower with substituted LLVM field types, /// match-arm field bindings carrying the substituted AILang type /// down into a polymorphic fn body. #[test] fn parameterised_box_round_trip() { let stdout = build_and_run("box.ail.json"); assert_eq!(stdout.trim(), "42"); } /// Iter 13b: pattern-match on `Maybe`. `or_else(Some(7), 99) == 7` /// then `or_else(None, 99) == 99`. Guards: match-arm field /// substitution at a parameterised ctor (the `Some(x)` arm must bind /// `x : Int`, not `x : a`). #[test] fn parameterised_maybe_match() { let stdout = build_and_run("maybe_int.ail.json"); let lines: Vec<&str> = stdout.lines().collect(); assert_eq!(lines, vec!["7", "99"]); } /// Guards `ail diff`: a modified body changes the hash of `sum`, while /// `main` stays unchanged. Expects exit code 1, `changed` contains exactly /// `sum`, `unchanged` contains `main`, `added`/`removed` empty. #[test] fn diff_detects_changed_def() { let manifest_dir = env!("CARGO_MANIFEST_DIR"); let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap(); let src_a = workspace.join("examples").join("sum.ail.json"); // Variant: load sum.ail.json, mutate the true-arm body of the // top-level `match` in `sum` (literal 0 → literal 1). `main` stays // bit-identical. After Iter 14d the body is `Match` (not `If`); the // first arm is the `(lit-bool true)` arm, formerly the `then` // branch. let raw = std::fs::read(&src_a).expect("read sum.ail.json"); let mut module: serde_json::Value = serde_json::from_slice(&raw).expect("parse sum.ail.json"); { let defs = module .get_mut("defs") .and_then(|d| d.as_array_mut()) .expect("defs array"); for def in defs.iter_mut() { if def.get("name").and_then(|n| n.as_str()) == Some("sum") { let new_body = serde_json::json!({ "t": "lit", "lit": { "kind": "int", "value": 1 } }); def["body"]["arms"][0]["body"] = new_body; } } } let tmp = std::env::temp_dir().join(format!( "ailang_diff_changed_{}", std::process::id() )); std::fs::create_dir_all(&tmp).unwrap(); let path_b = tmp.join("sum_v2.ail.json"); std::fs::write(&path_b, serde_json::to_vec_pretty(&module).unwrap()) .expect("write sum_v2.ail.json"); let output = Command::new(ail_bin()) .args([ "diff", src_a.to_str().unwrap(), path_b.to_str().unwrap(), "--json", ]) .output() .expect("ail diff failed to run"); let code = output.status.code().expect("process terminated by signal"); assert_eq!( code, 1, "expected exit code 1 for differing modules; stderr: {}", String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).expect("stdout utf8"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON"); let added = v["added"].as_array().expect("added array"); let removed = v["removed"].as_array().expect("removed array"); let changed = v["changed"].as_array().expect("changed array"); let unchanged = v["unchanged"].as_array().expect("unchanged array"); assert!(added.is_empty(), "expected added empty: {added:?}"); assert!(removed.is_empty(), "expected removed empty: {removed:?}"); assert_eq!(changed.len(), 1, "expected one changed entry: {changed:?}"); assert_eq!( changed[0].get("name").and_then(|n| n.as_str()), Some("sum") ); assert_ne!( changed[0].get("hash_a").and_then(|n| n.as_str()), changed[0].get("hash_b").and_then(|n| n.as_str()), "hash_a and hash_b must differ for a changed def" ); assert!( unchanged.iter().any(|e| e.get("name").and_then(|n| n.as_str()) == Some("main")), "expected `main` in unchanged: {unchanged:?}" ); } /// Diff of a module with itself: exit 0, all lists except `unchanged` empty. #[test] fn diff_no_changes_exit_zero() { let manifest_dir = env!("CARGO_MANIFEST_DIR"); let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap(); let src = workspace.join("examples").join("sum.ail.json"); let output = Command::new(ail_bin()) .args([ "diff", src.to_str().unwrap(), src.to_str().unwrap(), "--json", ]) .output() .expect("ail diff failed to run"); let code = output.status.code().expect("process terminated by signal"); assert_eq!( code, 0, "expected exit code 0 for identical modules; stderr: {}", String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).expect("stdout utf8"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON"); assert!(v["added"].as_array().unwrap().is_empty()); assert!(v["removed"].as_array().unwrap().is_empty()); assert!(v["changed"].as_array().unwrap().is_empty()); assert!( !v["unchanged"].as_array().unwrap().is_empty(), "self-diff should report unchanged defs" ); } /// Guards the workspace loader (Iter 5a): the entry module `ws_main` /// imports `ws_lib`; both must be found by the loader, loaded, and listed /// in the JSON output. Cross-module typecheck/codegen is explicitly not /// part of this test — it only verifies the loader pipeline. #[test] fn workspace_lists_imported_modules() { let manifest_dir = env!("CARGO_MANIFEST_DIR"); let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap(); let entry = workspace.join("examples").join("ws_main.ail.json"); let output = Command::new(ail_bin()) .args(["workspace", entry.to_str().unwrap(), "--json"]) .output() .expect("ail workspace failed to run"); assert!( output.status.success(), "ail workspace exited non-zero; stderr: {}", String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).expect("stdout utf8"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON"); assert_eq!( v.get("entry").and_then(|n| n.as_str()), Some("ws_main"), "entry must be ws_main: {stdout}" ); let modules = v["modules"].as_array().expect("modules must be array"); assert_eq!(modules.len(), 2, "expected 2 modules: {stdout}"); let names: Vec<&str> = modules .iter() .filter_map(|m| m.get("name").and_then(|n| n.as_str())) .collect(); assert!(names.contains(&"ws_main"), "ws_main missing: {names:?}"); assert!(names.contains(&"ws_lib"), "ws_lib missing: {names:?}"); } /// Guards Iter 5b: `ail check examples/ws_main.ail.json --json` must /// resolve the cross-module call `ws_lib.add`. Expected: exit 0, /// stdout exactly `[]` (empty diagnostic array). #[test] fn check_workspace_resolves_import() { let manifest_dir = env!("CARGO_MANIFEST_DIR"); let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap(); let entry = workspace.join("examples").join("ws_main.ail.json"); let output = Command::new(ail_bin()) .args(["check", entry.to_str().unwrap(), "--json"]) .output() .expect("ail check --json failed to run"); let code = output.status.code().expect("process terminated by signal"); assert_eq!( code, 0, "expected exit 0; stderr: {}; stdout: {}", String::from_utf8_lossy(&output.stderr), String::from_utf8_lossy(&output.stdout), ); let stdout = String::from_utf8(output.stdout).expect("stdout utf8"); assert_eq!(stdout.trim(), "[]", "expected empty diagnostic array"); } /// Guards Iter 5c (cross-module codegen): `ail build` over /// `examples/ws_main.ail.json` must lower the workspace incl. `ws_lib`; /// `@ail_ws_main_main` must call `@ail_ws_lib_add(2,3)` and print `5`. #[test] fn workspace_build_runs_imported_fn() { let stdout = build_and_run("ws_main.ail.json"); assert_eq!(stdout.trim(), "5", "ws_lib.add(2,3) should print 5"); } /// Guards Iter 5d: `ail manifest --workspace --json` lists defs from all /// modules of the workspace — visible via a `module` field per entry. #[test] fn manifest_workspace_lists_all_defs() { let manifest_dir = env!("CARGO_MANIFEST_DIR"); let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap(); let entry = workspace.join("examples").join("ws_main.ail.json"); let output = Command::new(ail_bin()) .args([ "manifest", entry.to_str().unwrap(), "--workspace", "--json", ]) .output() .expect("ail manifest --workspace failed to run"); assert!( output.status.success(), "ail manifest --workspace exited non-zero; stderr: {}", String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).expect("stdout utf8"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON"); let symbols = v["symbols"].as_array().expect("symbols must be array"); let modules: Vec<&str> = symbols .iter() .filter_map(|s| s.get("module").and_then(|m| m.as_str())) .collect(); assert!( modules.contains(&"ws_main"), "expected at least one symbol with module=ws_main; got {modules:?}" ); assert!( modules.contains(&"ws_lib"), "expected at least one symbol with module=ws_lib; got {modules:?}" ); } /// Guards Iter 5d: `ail describe --workspace ws_lib.add` resolves the /// qualified dot notation to the actually imported module. #[test] fn describe_workspace_resolves_qualified_name() { let manifest_dir = env!("CARGO_MANIFEST_DIR"); let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap(); let entry = workspace.join("examples").join("ws_main.ail.json"); let output = Command::new(ail_bin()) .args([ "describe", entry.to_str().unwrap(), "ws_lib.add", "--workspace", "--json", ]) .output() .expect("ail describe --workspace failed to run"); assert!( output.status.success(), "ail describe --workspace exited non-zero; stderr: {}", String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).expect("stdout utf8"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON"); assert_eq!( v.get("module").and_then(|m| m.as_str()), Some("ws_lib"), "module field must be ws_lib; got {stdout}" ); assert_eq!( v.get("name").and_then(|n| n.as_str()), Some("add"), "def name must be add; got {stdout}" ); } /// Guards Iter 6: `ail deps` no longer surfaces built-ins, params, or /// let-bindings as edges. The single-module `sum` def in `sum.ail.json` /// uses `+`, `-`, `==`, the param `n`, and the recursive call `sum`. /// Only the recursive call must remain. #[test] fn deps_filters_builtins_params_locals() { let manifest_dir = env!("CARGO_MANIFEST_DIR"); let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap(); let src = workspace.join("examples").join("sum.ail.json"); let output = Command::new(ail_bin()) .args(["deps", src.to_str().unwrap(), "--of", "sum", "--json"]) .output() .expect("ail deps failed to run"); assert!( output.status.success(), "ail deps exited non-zero; stderr: {}", String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).expect("stdout utf8"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); let arr = v.as_array().expect("array of {name, refs}"); let entry = arr .iter() .find(|e| e["name"].as_str() == Some("sum")) .expect("sum entry present"); let refs: Vec<&str> = entry["refs"] .as_array() .expect("refs array") .iter() .filter_map(|x| x.as_str()) .collect(); assert_eq!( refs, vec!["sum"], "expected only the recursive call `sum`; got {refs:?}" ); } /// Guards Iter 6 in workspace mode: edges into built-ins (`ws_lib.+`) /// and into the params (`ws_lib.a`, `ws_lib.b`) of `ws_lib.add` must be /// gone after the deps refactor. #[test] fn deps_workspace_filters_builtins_and_params() { let manifest_dir = env!("CARGO_MANIFEST_DIR"); let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap(); let entry = workspace.join("examples").join("ws_main.ail.json"); let output = Command::new(ail_bin()) .args(["deps", entry.to_str().unwrap(), "--workspace", "--json"]) .output() .expect("ail deps --workspace failed to run"); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).expect("stdout utf8"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); let edges = v["edges"].as_array().expect("edges array"); for e in edges { let to_def = e.get("to_def").and_then(|x| x.as_str()).unwrap_or(""); assert_ne!(to_def, "+", "built-in `+` must not appear: {e}"); assert_ne!(to_def, "a", "param `a` must not appear: {e}"); assert_ne!(to_def, "b", "param `b` must not appear: {e}"); } } /// Guards Iter 5d: `ail deps --workspace` contains a cross-module edge /// `ws_main.main -> ws_lib.add`, because `ws_main` calls `ws_lib.add(2,3)`. #[test] fn deps_workspace_includes_cross_module() { let manifest_dir = env!("CARGO_MANIFEST_DIR"); let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap(); let entry = workspace.join("examples").join("ws_main.ail.json"); let output = Command::new(ail_bin()) .args(["deps", entry.to_str().unwrap(), "--workspace", "--json"]) .output() .expect("ail deps --workspace failed to run"); assert!( output.status.success(), "ail deps --workspace exited non-zero; stderr: {}", String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).expect("stdout utf8"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON"); let edges = v["edges"].as_array().expect("edges must be array"); let has_cross_edge = edges.iter().any(|e| { e.get("from_module").and_then(|s| s.as_str()) == Some("ws_main") && e.get("from_def").and_then(|s| s.as_str()) == Some("main") && e.get("to_module").and_then(|s| s.as_str()) == Some("ws_lib") && e.get("to_def").and_then(|s| s.as_str()) == Some("add") }); assert!( has_cross_edge, "expected cross-module edge ws_main.main -> ws_lib.add; got {stdout}" ); } /// Guards Iter 5d: `ail diff --workspace` detects an additional module /// in the B workspace as an `added_modules` entry and exits with code 1. #[test] fn diff_workspace_added_module() { use std::fs; fn write_module(dir: &Path, name: &str, body: serde_json::Value) { let p = dir.join(format!("{name}.ail.json")); fs::write(&p, serde_json::to_vec_pretty(&body).unwrap()).unwrap(); } let dir_a = std::env::temp_dir().join(format!( "ailang_diff_ws_a_{}", std::process::id() )); let dir_b = std::env::temp_dir().join(format!( "ailang_diff_ws_b_{}", std::process::id() )); let _ = fs::remove_dir_all(&dir_a); let _ = fs::remove_dir_all(&dir_b); fs::create_dir_all(&dir_a).unwrap(); fs::create_dir_all(&dir_b).unwrap(); // Both workspaces have an empty module `root`. B additionally // imports `extra`. Loader convention: `/.ail.json`, // module name must match the file name. let empty_root = serde_json::json!({ "schema": "ailang/v0", "name": "root", "imports": [], "defs": [], }); let root_with_import = serde_json::json!({ "schema": "ailang/v0", "name": "root", "imports": [{ "module": "extra" }], "defs": [], }); let extra = serde_json::json!({ "schema": "ailang/v0", "name": "extra", "imports": [], "defs": [], }); write_module(&dir_a, "root", empty_root); write_module(&dir_b, "root", root_with_import); write_module(&dir_b, "extra", extra); let entry_a = dir_a.join("root.ail.json"); let entry_b = dir_b.join("root.ail.json"); let output = Command::new(ail_bin()) .args([ "diff", entry_a.to_str().unwrap(), entry_b.to_str().unwrap(), "--workspace", "--json", ]) .output() .expect("ail diff --workspace failed to run"); let code = output.status.code().expect("process terminated by signal"); assert_eq!( code, 1, "expected exit 1 when workspaces differ; stderr: {}", String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).expect("stdout utf8"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON"); let added = v["added_modules"] .as_array() .expect("added_modules must be array"); assert!( added.iter().any(|e| e.get("name").and_then(|n| n.as_str()) == Some("extra")), "expected `extra` in added_modules; got {stdout}" ); let removed = v["removed_modules"] .as_array() .expect("removed_modules must be array"); assert!( removed.is_empty(), "expected removed_modules empty; got {stdout}" ); } /// Guards the `--json` diagnostic format for tooling consumers. /// `broken_unbound.ail.json` references a non-existent variable; /// exit code 1 is expected, plus at least one diagnostic with /// `severity == "error"` and `code == "unbound-var"`. #[test] fn check_json_unbound_var() { let manifest_dir = env!("CARGO_MANIFEST_DIR"); let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap(); let src = workspace.join("examples").join("broken_unbound.ail.json"); let output = Command::new(ail_bin()) .args(["check", src.to_str().unwrap(), "--json"]) .output() .expect("ail check --json failed to run"); let code = output.status.code().expect("process terminated by signal"); assert_eq!(code, 1, "expected exit code 1, stderr: {}", String::from_utf8_lossy(&output.stderr)); let stdout = String::from_utf8(output.stdout).expect("stdout utf8"); let diags: serde_json::Value = serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON"); let arr = diags.as_array().expect("diagnostics must be a JSON array"); assert!( arr.iter().any(|d| { d.get("severity").and_then(|v| v.as_str()) == Some("error") && d.get("code").and_then(|v| v.as_str()) == Some("unbound-var") }), "expected at least one error diagnostic with code unbound-var; got: {stdout}" ); }