ilo 26.5.0

ilo - the token-minimal programming language AI agents write
Documentation
// CLI verb-noun subcommands: `ilo run`, `ilo check`, `ilo build`.
//
// These verbs were added in 0.12.0 alongside the modular skills work to
// match the `cargo` / `zero` / `go` toolchain conventions. They sit
// alongside the existing positional forms (`ilo file.ilo`, `ilo compile
// ...`) which remain fully supported for backwards compatibility.
//
// Tests:
//   - `ilo run file.ilo` matches `ilo file.ilo` (file + inline + args).
//   - `ilo check file.ilo` runs the verifier without executing, exit 0 on
//     clean, exit 1 on type/parse errors, supports `--json` diagnostics.
//   - `ilo build file.ilo -o out` matches `ilo compile file.ilo -o out`.
//   - `ilo run` / `ilo check` / `ilo build` with no source arg print
//     friendly usage to stderr instead of the previous "treat `run` as
//     ilo source" parser blowup.
//   - Existing positional forms (regression) still work after the dispatch
//     refactor: `ilo file.ilo`, `ilo file.ilo arg`, `ilo file.ilo func`,
//     `ilo compile file.ilo -o out`.

use std::process::Command;

fn ilo() -> Command {
    Command::new(env!("CARGO_BIN_EXE_ilo"))
}

fn run_args(args: &[&str]) -> (bool, String, String) {
    let out = ilo()
        .args(args)
        .output()
        .unwrap_or_else(|e| panic!("failed to spawn ilo: {e}"));
    let stdout = String::from_utf8_lossy(&out.stdout).to_string();
    let stderr = String::from_utf8_lossy(&out.stderr).to_string();
    (out.status.success(), stdout, stderr)
}

fn write_temp_ilo(content: &str) -> (tempfile::TempDir, std::path::PathBuf) {
    let dir = tempfile::tempdir().expect("tempdir");
    let path = dir.path().join("test.ilo");
    std::fs::write(&path, content).expect("write temp ilo");
    (dir, path)
}

// ── ilo run ──────────────────────────────────────────────────────────────────

/// `ilo run <file>` behaves identically to `ilo <file>`.
#[test]
fn run_verb_runs_file() {
    let (_dir, path) = write_temp_ilo("main>n;42");
    let (ok, stdout, stderr) = run_args(&["run", path.to_str().unwrap()]);
    assert!(ok, "ilo run <file> should succeed; stderr: {stderr}");
    assert!(stdout.contains("42"), "stdout: {stdout}");
}

/// `ilo run <inline-code>` behaves identically to `ilo <inline-code>`.
#[test]
fn run_verb_runs_inline_code() {
    let (ok, stdout, stderr) = run_args(&["run", "main>n;42"]);
    assert!(ok, "ilo run <code> should succeed; stderr: {stderr}");
    assert!(stdout.contains("42"), "stdout: {stdout}");
}

/// `ilo run <file> arg1 arg2` forwards args to the program entry.
#[test]
fn run_verb_forwards_args() {
    let (_dir, path) = write_temp_ilo("main x:n y:n>n;+x y");
    let (ok, stdout, stderr) = run_args(&["run", path.to_str().unwrap(), "10", "32"]);
    assert!(ok, "ilo run <file> args should succeed; stderr: {stderr}");
    assert!(stdout.contains("42"), "stdout: {stdout}");
}

/// `ilo run` with no source arg prints clear usage to stderr (exit non-zero),
/// not a confusing "function header for `run` runs off end of file" parser
/// error from lexing the verb as inline ilo code.
#[test]
fn run_verb_no_args_prints_usage() {
    let (ok, _stdout, stderr) = run_args(&["run"]);
    assert!(!ok, "ilo run with no args should exit non-zero");
    assert!(
        stderr.contains("Usage: ilo run"),
        "stderr should contain usage line; got: {stderr}"
    );
    // Negative assertion: must NOT regress to the old confusing parser error.
    assert!(
        !stderr.contains("ILO-P020"),
        "should not surface the lex-as-source parser error; got: {stderr}"
    );
}

// ── ilo check ────────────────────────────────────────────────────────────────

/// `ilo check <clean-file>` exits 0 with no diagnostics.
#[test]
fn check_verb_clean_file_succeeds() {
    let (_dir, path) = write_temp_ilo("add a:n b:n>n;+a b main>n;add 1 2");
    let (ok, stdout, stderr) = run_args(&["check", path.to_str().unwrap()]);
    assert!(
        ok,
        "ilo check on a clean file should succeed; stdout: {stdout}; stderr: {stderr}"
    );
    // Check is silent on success — no diagnostics emitted.
    assert!(
        stderr.is_empty(),
        "check on clean file should produce no stderr; got: {stderr}"
    );
    assert!(
        stdout.is_empty(),
        "check on clean file should produce no stdout; got: {stdout}"
    );
}

/// `ilo check <inline-code>` works on inline source too.
#[test]
fn check_verb_inline_code() {
    let (ok, _stdout, stderr) = run_args(&["check", "main>n;42"]);
    assert!(
        ok,
        "ilo check on clean inline code should succeed; stderr: {stderr}"
    );
}

/// `ilo check <type-error-file>` exits non-zero and reports the error.
#[test]
fn check_verb_type_error_exits_nonzero() {
    // Return type mismatch: declared `>n` but body returns text.
    let (_dir, path) = write_temp_ilo("f>n;\"hello\"");
    let (ok, _stdout, stderr) = run_args(&["check", path.to_str().unwrap()]);
    assert!(
        !ok,
        "ilo check on a type-error file should exit non-zero; stderr: {stderr}"
    );
    assert!(
        !stderr.is_empty(),
        "check on broken file should emit diagnostics"
    );
}

/// `ilo check <syntactically-broken-file>` produces useful output without
/// crashing — important for editor / agent loops that may feed in
/// half-written code.
#[test]
fn check_verb_parse_error_does_not_crash() {
    // Header missing return type and body — unfinished function header.
    let (_dir, path) = write_temp_ilo("f a:n");
    let (ok, _stdout, stderr) = run_args(&["check", path.to_str().unwrap()]);
    assert!(
        !ok,
        "ilo check on a syntactically broken file should exit non-zero"
    );
    assert!(
        !stderr.is_empty(),
        "check should emit some diagnostic; stderr: {stderr}"
    );
}

/// `ilo check <file> --json` emits diagnostics as NDJSON (one JSON object
/// per line) on stderr.
#[test]
fn check_verb_json_flag_emits_json_diagnostics() {
    let (_dir, path) = write_temp_ilo("f>n;\"hello\"");
    let (ok, _stdout, stderr) = run_args(&["check", path.to_str().unwrap(), "--json"]);
    assert!(!ok);
    // First non-empty line should be valid JSON with at least a `code` or `message` field.
    let first = stderr
        .lines()
        .find(|l| !l.trim().is_empty())
        .expect("expected at least one diagnostic line");
    let parsed: serde_json::Value =
        serde_json::from_str(first).expect("check --json diagnostic should be valid JSON");
    assert!(
        parsed.get("code").is_some() || parsed.get("message").is_some(),
        "diagnostic JSON should have code/message field; got: {parsed}"
    );
}

/// `ilo check <file>` on a file that emits only warning-severity diagnostics
/// (ILO-T032: bare `fmt`) exits 0 - warnings are advisory in interactive use.
/// `ilo check --strict <file>` on the same file exits 1 because CI harnesses
/// need to fail-on-warning. The diagnostic stream is unchanged either way.
#[test]
fn check_verb_strict_elevates_t032_to_exit_failure() {
    // Bare `fmt` at non-tail discards the formatted string - ILO-T032.
    // The trailing `42` keeps the function well-typed so the only
    // diagnostic is the T032 warning.
    let (_dir, path) = write_temp_ilo("f>n;fmt \"x={}\" 1;42");

    let (ok_relaxed, _stdout, stderr_relaxed) = run_args(&["check", path.to_str().unwrap()]);
    assert!(
        ok_relaxed,
        "bare `ilo check` should exit 0 on a warning-only file; stderr: {stderr_relaxed}"
    );
    assert!(
        stderr_relaxed.contains("ILO-T032"),
        "warning should still be emitted on stderr; got: {stderr_relaxed}"
    );

    let (ok_strict, _stdout, stderr_strict) =
        run_args(&["check", path.to_str().unwrap(), "--strict"]);
    assert!(
        !ok_strict,
        "`ilo check --strict` should exit non-zero when only warnings fire; stderr: {stderr_strict}"
    );
    assert!(
        stderr_strict.contains("ILO-T032"),
        "warning should still be emitted under --strict; got: {stderr_strict}"
    );
}

/// `--strict` also elevates ILO-T033 (bare `mset` / `+=` / `mdel` discarded)
/// to a non-zero exit. Pinned per-warning-code so a future verifier change
/// that swaps the code for a different category doesn't silently degrade
/// CI coverage.
#[test]
fn check_verb_strict_elevates_t033_to_exit_failure() {
    // Bare `mset` at non-tail discards the new map - ILO-T033.
    let (_dir, path) = write_temp_ilo("f>n;m=mmap;mset m \"k\" 1;42");

    let (ok_relaxed, _stdout, stderr_relaxed) = run_args(&["check", path.to_str().unwrap()]);
    assert!(
        ok_relaxed,
        "bare `ilo check` should exit 0 on a warning-only file; stderr: {stderr_relaxed}"
    );
    assert!(
        stderr_relaxed.contains("ILO-T033"),
        "warning should still be emitted on stderr; got: {stderr_relaxed}"
    );

    let (ok_strict, _stdout, stderr_strict) =
        run_args(&["check", path.to_str().unwrap(), "--strict"]);
    assert!(
        !ok_strict,
        "`ilo check --strict` should exit non-zero on T033; stderr: {stderr_strict}"
    );
    assert!(
        stderr_strict.contains("ILO-T033"),
        "warning should still be emitted under --strict; got: {stderr_strict}"
    );
}

/// `--strict --json` keeps the diagnostic severity as `warning` in the
/// JSON stream - only the exit code is elevated. This matters for editor
/// integrations that render diagnostics by severity: bumping them to
/// `error` in the JSON would make every CI-time warning look catastrophic
/// in the IDE.
#[test]
fn check_verb_strict_json_keeps_warning_severity() {
    let (_dir, path) = write_temp_ilo("f>n;fmt \"x={}\" 1;42");
    let (ok, _stdout, stderr) = run_args(&["check", path.to_str().unwrap(), "--strict", "--json"]);
    assert!(
        !ok,
        "--strict --json should exit non-zero on warning-only file; stderr: {stderr}"
    );
    let first = stderr
        .lines()
        .find(|l| !l.trim().is_empty())
        .expect("expected at least one diagnostic line");
    let parsed: serde_json::Value =
        serde_json::from_str(first).expect("check --json diagnostic should be valid JSON");
    assert_eq!(
        parsed.get("severity").and_then(|v| v.as_str()),
        Some("warning"),
        "JSON severity must stay 'warning' under --strict; got: {parsed}"
    );
    assert_eq!(
        parsed.get("code").and_then(|v| v.as_str()),
        Some("ILO-T032"),
        "expected ILO-T032 diagnostic; got: {parsed}"
    );
}

/// `--strict` on a clean file still exits 0. Warnings-as-errors must not
/// degrade into errors-on-empty-input.
#[test]
fn check_verb_strict_clean_file_succeeds() {
    let (_dir, path) = write_temp_ilo("main>n;42");
    let (ok, _stdout, stderr) = run_args(&["check", path.to_str().unwrap(), "--strict"]);
    assert!(
        ok,
        "`ilo check --strict` on a clean file should exit 0; stderr: {stderr}"
    );
}

/// `--strict` on a file with real errors still exits 1. The flag must not
/// regress the error-path exit code.
#[test]
fn check_verb_strict_error_file_still_exits_nonzero() {
    // Type error: `fmt` is `t`, function declared `>n`.
    let (_dir, path) = write_temp_ilo("f>n;\"hello\"");
    let (ok, _stdout, stderr) = run_args(&["check", path.to_str().unwrap(), "--strict"]);
    assert!(
        !ok,
        "`ilo check --strict` should exit 1 on an error file; stderr: {stderr}"
    );
}

/// `ilo check` with no source arg prints friendly usage.
#[test]
fn check_verb_no_args_prints_usage() {
    let (ok, _stdout, stderr) = run_args(&["check"]);
    assert!(!ok);
    assert!(
        stderr.contains("Usage: ilo check"),
        "stderr should contain usage line; got: {stderr}"
    );
}

// ── ilo build ────────────────────────────────────────────────────────────────

/// `ilo build <file> -o <out>` produces a binary, equivalent to
/// `ilo compile <file> -o <out>`.
///
/// Only meaningful when the cranelift feature is enabled (it's the default
/// for the release build). Skipped otherwise.
#[test]
fn build_verb_produces_binary() {
    let (dir, path) = write_temp_ilo("main>n;42");
    let out_path = dir.path().join("test-bin");
    let (ok, _stdout, stderr) = run_args(&[
        "build",
        path.to_str().unwrap(),
        "-o",
        out_path.to_str().unwrap(),
    ]);
    if !ok && stderr.contains("requires the 'cranelift' feature") {
        // Build without the cranelift feature — the dispatch is wired but
        // compilation itself is unavailable. Still validates the verb is
        // recognised.
        return;
    }
    assert!(
        ok,
        "ilo build <file> -o <out> should succeed; stderr: {stderr}"
    );
    assert!(out_path.exists(), "binary should exist at {out_path:?}");
}

/// `ilo build` with no source arg prints friendly usage.
#[test]
fn build_verb_no_args_prints_usage() {
    let (ok, _stdout, stderr) = run_args(&["build"]);
    assert!(!ok);
    assert!(
        stderr.contains("Usage: ilo build"),
        "stderr should contain usage line; got: {stderr}"
    );
}

// ── Backwards-compat regression: positional forms still work ─────────────────

/// `ilo <file.ilo>` (no verb) still runs.
#[test]
fn positional_file_still_runs() {
    let (_dir, path) = write_temp_ilo("main>n;7");
    let (ok, stdout, stderr) = run_args(&[path.to_str().unwrap()]);
    assert!(ok, "bare positional run should succeed; stderr: {stderr}");
    assert!(stdout.contains("7"));
}

/// `ilo <file.ilo> arg1 arg2` (no verb) still forwards args.
#[test]
fn positional_file_with_args_still_runs() {
    let (_dir, path) = write_temp_ilo("main x:n y:n>n;+x y");
    let (ok, stdout, stderr) = run_args(&[path.to_str().unwrap(), "3", "4"]);
    assert!(
        ok,
        "positional file + args should succeed; stderr: {stderr}"
    );
    assert!(stdout.contains("7"));
}

/// `ilo <file.ilo> func` (no verb) still selects a function.
#[test]
fn positional_file_with_func_still_runs() {
    let (_dir, path) = write_temp_ilo("dbl x:n>n;+*x 2 0 main>n;dbl 21");
    let (ok, stdout, stderr) = run_args(&[path.to_str().unwrap(), "dbl", "21"]);
    assert!(
        ok,
        "positional file + func should succeed; stderr: {stderr}"
    );
    assert!(stdout.contains("42"));
}

/// `ilo compile <file> -o <out>` (no verb) still compiles. Sister test to
/// `build_verb_produces_binary` — proves the alias didn't displace the
/// original verb.
#[test]
fn positional_compile_still_works() {
    let (dir, path) = write_temp_ilo("main>n;42");
    let out_path = dir.path().join("test-bin-compile");
    let (ok, _stdout, stderr) = run_args(&[
        "compile",
        path.to_str().unwrap(),
        "-o",
        out_path.to_str().unwrap(),
    ]);
    if !ok && stderr.contains("requires the 'cranelift' feature") {
        return;
    }
    assert!(ok, "compile should still succeed; stderr: {stderr}");
    assert!(out_path.exists());
}