Files
AILang/crates/ail/tests/e2e.rs
T
Brummel 41d406bcbb Iter 14g: Term::If restored (revert of 14d)
Reconsidered 14d's removal of Term::If. The decision was wrong.
"No redundancies" requires judgment; reducibility (if -> match)
is not redundancy in the strong sense. Term::If is a primitive
control-flow shape; bool branching is the second most common
shape after sequencing, and removing it cost 3x tokens on every
branch site (`(if c a b)` 4 tokens vs the match-on-Bool form
12 tokens).

Meta-pattern fixed: I had been treating user observations as
directives. User said "if is a subset of match"; I jumped to
remove it citing CLAUDE.md, with no independent conviction.
The leak appeared in 14f's JOURNAL prose ("three lines for what
if used to do in one"), which read as regret. Two feedback
memories saved (memory/feedback_user_suggestions_not_directives,
memory/feedback_no_nostalgia_for_removed_features) to head this
off.

Implementation: mechanical reverse-application of 14d's diff at
every site (AST, check including the 14e tail-position arm,
codegen 4 sites, surface parser/printer, pretty, CLI walker,
e2e test mutation). Removed lower_bool_match helper — it existed
only because 14d's migration shape needed codegen for non-ptr
match scrutinees; with Term::If back, match-on-Bool returns to
its pre-14d unsupported state. Three fixtures (sum, sort, max3)
restored to pre-14d shape. gc_stress (added in 14f) also
migrated back to (if ...) since it was authored under the wrong
constraint.

14e (musttail) and 14f (GC_malloc) verified intact in IR.

Hashes restored to pre-14d values:
- sum.sum: db33f57cb329935e
- sort.insert: 697fcb9f30f8633a
- max3.max: 65c45d6a45dd0a72
- max3.max3: 624b14429bf302f5

All other defs across all 18 fixtures keep their post-14f
hashes. Tests 80/80 green; cargo doc 0 warnings. LOC delta
+265/-295 net -30.

DESIGN.md Decision 7 preserved with a "Status: REVERTED" header
for audit trail. Form-(A) `if-term` production restored.

Plan: back to 15a (std_maybe stdlib module).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:45:33 +02:00

753 lines
27 KiB
Rust

//! 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<a>) -> List<b>` 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 14f: stress the Boehm conservative GC integration end-to-end.
/// `build 50` performs 50 `Cons` allocations via `GC_malloc`; `sum_list`
/// walks the resulting `List Int` and prints the sum (1275 = 50*51/2).
/// If GC is wired wrongly — missing `-lgc`, missing
/// `declare ptr @GC_malloc(i64)`, libgc misbehaving on this build host —
/// the test fails at link or run time. The match-on-Bool against
/// `(== n 0)` shape is used because `(pat-lit 0)` against an `Int`
/// scrutinee with a wildcard fallback is not currently in the grammar.
#[test]
fn gc_handles_recursive_list_construction() {
let stdout = build_and_run("gc_stress.ail.json");
assert_eq!(
stdout.trim(),
"1275",
"expected sum [50,49,..,1] = 1275; \
GC_malloc / -lgc integration may be broken"
);
}
/// 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>) -> 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<Int>`. `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 `then` branch (1 instead of 0)
// in the `sum` definition. `main` stays bit-identical.
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") {
// Replace the then branch literal 0 → literal 1.
let new_then = serde_json::json!({
"t": "lit",
"lit": { "kind": "int", "value": 1 }
});
def["body"]["then"] = new_then;
}
}
}
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: `<root_dir>/<name>.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}"
);
}