iter cli-diag-human: route WorkspaceLoadError through diagnostic in non-JSON path

Closes P2 todo "CLI human-mode diagnostic surface for
WorkspaceLoadError" surfaced by ct.1.8 tester (2026-05-11).
Pre-iter, non-JSON `ail check` (+8 sibling subcommands) propagated
loader errors via anyhow's Display, producing
`Error: <message>` without the bracketed `[code]` prefix the JSON
path emits.

Fix: new private helper `load_workspace_human(path)` in
crates/ail/src/main.rs that wraps ailang_surface::load_workspace,
threads any WorkspaceLoadError through workspace_error_to_diagnostic,
formats the resulting Diagnostic into a single stderr line
`error: [<code>] <def>: <message>`, and exits non-zero. Pure I/O
errors still propagate as anyhow errors.

Nine call sites swapped to the helper (check non-JSON arm, build,
run, emit-ir, prose, describe, deps, diff, manifest). The JSON arm
of `ail check` keeps its explicit `match` — different output
contract (structured stdout array).

The BareCrossModuleTypeRef Diagnostic message gained the existing
migration-command hint ("Run `ail migrate-canonical-types` to fix
legacy fixtures.") so the ct.1.8 actionable-hint test stays green.
The hint previously rode on WorkspaceLoadError's thiserror Display;
making workspace_error_to_diagnostic the single source for both
JSON and human paths required moving it into the Diagnostic body.

RED test crates/ail/tests/cli_diag_human_workspace_load_error.rs
pins the bracketed-code surface on a missing-import workspace
(tempdir-based fixture; no committed corpus addition).

cargo test --workspace: 565/0/3. clippy + doc: zero warnings.
Roadmap entry struck.
This commit is contained in:
2026-05-14 02:26:46 +02:00
parent 6755060175
commit f08ba2bb36
4 changed files with 200 additions and 17 deletions
+47 -10
View File
@@ -336,7 +336,7 @@ fn main() -> Result<()> {
if workspace {
// Workspace mode: load all modules and emit their defs
// together, alphabetically by (module, name).
let ws = ailang_surface::load_workspace(&path)?;
let ws = load_workspace_human(&path)?;
let mut entries: Vec<(String, &ailang_core::Def)> = Vec::new();
for (mod_name, m) in &ws.modules {
for d in &m.defs {
@@ -527,7 +527,7 @@ fn main() -> Result<()> {
}
Cmd::Describe { path, name, json, workspace } => {
if workspace {
let ws = ailang_surface::load_workspace(&path)?;
let ws = load_workspace_human(&path)?;
let (mod_name, def) = resolve_describe_name(&ws, &name)?;
if json {
// We pass the def itself through and add the
@@ -608,7 +608,7 @@ fn main() -> Result<()> {
std::process::exit(1);
}
} else {
let ws = ailang_surface::load_workspace(&path)?;
let ws = load_workspace_human(&path)?;
let diags = ailang_check::check_workspace(&ws);
if !diags.is_empty() {
for d in &diags {
@@ -648,7 +648,7 @@ fn main() -> Result<()> {
Cmd::EmitIr { path, out } => {
// Iter 5c: workspace lowering. For single-module programs the
// workspace is effectively a trivial workspace with one module.
let ws = ailang_surface::load_workspace(&path)?;
let ws = load_workspace_human(&path)?;
let diags = ailang_check::check_workspace(&ws);
if !diags.is_empty() {
for d in &diags {
@@ -743,8 +743,8 @@ fn main() -> Result<()> {
}
Cmd::Diff { a, b, json, workspace } => {
if workspace {
let ws_a = ailang_surface::load_workspace(&a)?;
let ws_b = ailang_surface::load_workspace(&b)?;
let ws_a = load_workspace_human(&a)?;
let ws_b = load_workspace_human(&b)?;
let report = build_workspace_diff(&ws_a, &ws_b);
if json {
let v = workspace_diff_report_to_json(&report);
@@ -771,7 +771,7 @@ fn main() -> Result<()> {
}
}
Cmd::Workspace { entry, json } => {
let ws = ailang_surface::load_workspace(&entry)?;
let ws = load_workspace_human(&entry)?;
// Iterate alphabetically over module names (BTreeMap order
// is already sorted; assert it explicitly).
let mut entries: Vec<(String, String, usize)> = ws
@@ -852,7 +852,7 @@ fn main() -> Result<()> {
}
Cmd::Deps { path, of, json, workspace } => {
if workspace {
let ws = ailang_surface::load_workspace(&path)?;
let ws = load_workspace_human(&path)?;
// `--of NAME`: optional, accepts dot notation
// (`<module>.<def>`) or a bare name (matches in all
@@ -1237,7 +1237,8 @@ fn workspace_error_to_diagnostic(
"bare-cross-module-type-ref",
format!(
"module `{module}` contains bare type name `{name}` that does not resolve to a local type; \
candidates from imports: {candidates:?}"
candidates from imports: {candidates:?}. \
Run `ail migrate-canonical-types` to fix legacy fixtures."
),
)
.with_ctx(serde_json::json!({
@@ -1326,6 +1327,42 @@ fn workspace_error_to_diagnostic(
}
}
/// Iter cli-diag-human (2026-05-14): loads a workspace and, on
/// `WorkspaceLoadError`, routes through `workspace_error_to_diagnostic`
/// to print a stderr line matching the JSON path's `[code]`-bracketed
/// format before exiting non-zero. Pure I/O errors have no diagnostic
/// equivalent (see `workspace_error_to_diagnostic` returning `None`
/// for `W::Io`) and still propagate as anyhow errors so the caller's
/// `?`-flow stays well-behaved.
///
/// Replaces the bare `ailang_surface::load_workspace(<path>)?` shape
/// in every non-JSON CLI subcommand. The JSON path of `ail check`
/// (which builds a structured diagnostics array on stdout) is the
/// one site that keeps the explicit `match` against the loader
/// result — it does not call this helper.
fn load_workspace_human(
path: &Path,
) -> Result<ailang_core::Workspace> {
match ailang_surface::load_workspace(path) {
Ok(ws) => Ok(ws),
Err(e) => match workspace_error_to_diagnostic(&e) {
Some(d) => {
eprintln!(
"error: [{}] {}{}",
d.code,
d.def
.as_ref()
.map(|n| format!("{n}: "))
.unwrap_or_default(),
d.message,
);
std::process::exit(1);
}
None => Err(anyhow::anyhow!(e)),
},
}
}
/// Collects the *external* references of a definition: everything its body
/// depends on that lives outside the def itself. Filtered out:
///
@@ -2197,7 +2234,7 @@ fn build_to(
opt: &str,
alloc: ailang_codegen::AllocStrategy,
) -> Result<PathBuf> {
let ws = ailang_surface::load_workspace(path)?;
let ws = load_workspace_human(path)?;
let diags = ailang_check::check_workspace(&ws);
if !diags.is_empty() {
for d in &diags {