blue-lang-cli 0.0.51

The blue command line: run, fmt, ast, erase, check.
//! **`blue check --format json` is a stable interface.** A tool — an editor,
//! CI, an agent repairing its own code — parses these field names, so a rename
//! must be a red test, not a silent break. This pins the exact bytes for a
//! fixture that exercises every field: an error with suggestions, a warning
//! with a machine-applicable fix, and a waived diagnostic. And every field
//! name the output uses must be documented in `docs/DIAGNOSTICS.md`.
//!
//! Red run (2026-09-29): `JsonDiagnostic::slug` renamed `rule` —
//! `the JSON output changed` with the first line differing at `"rule"`.

use std::path::PathBuf;
use std::process::Command;

fn blue() -> Command {
    let mut c = Command::new(env!("CARGO_BIN_EXE_blue"));
    c.env_remove("BLUE_PATH")
        .env_remove("BLUE_CONFIG")
        .env_remove("BLUE_TIER");
    c
}

fn fixture(name: &str, src: &str) -> PathBuf {
    let dir = std::env::temp_dir().join(format!("blue-check-json-{}", std::process::id()));
    std::fs::create_dir_all(&dir).expect("mkdir");
    let p = dir.join(format!("{name}.b"));
    std::fs::write(&p, src).expect("write");
    p
}

const SRC: &str = "\
def f(xs)
  lenght(xs)
end

def g(x, y)
  x
end

# waive B0002: the callback shape is fixed
def h(event, ctx)
  event
end
";

/// The exact output, with the fixture's path written `F`.
const EXPECTED: &str = r#"{"code":"B0001","slug":"unbound-name","severity":"error","message":"unbound name `lenght`","file":"F","line":2,"column":3,"end_line":2,"end_column":9,"byte_start":12,"byte_end":18,"help":"did you mean `length` (builtin)?","related":[],"fixes":[{"message":"replace with `length` (builtin)","applicability":"maybe-incorrect","edits":[{"file":"F","line":2,"column":3,"end_line":2,"end_column":9,"byte_start":12,"byte_end":18,"original":"lenght","replacement":"length"}]}],"waiver":null}
{"code":"B0002","slug":"unused-binding","severity":"warning","message":"parameter `y` is never read","file":"F","line":5,"column":10,"end_line":5,"end_column":11,"byte_start":37,"byte_end":38,"help":"rename it `_y` to say it is deliberately unused, or remove it","related":[],"fixes":[{"message":"rename to `_y`","applicability":"machine-applicable","edits":[{"file":"F","line":5,"column":10,"end_line":5,"end_column":11,"byte_start":37,"byte_end":38,"original":"y","replacement":"_y"}]}],"waiver":null}
{"code":"B0002","slug":"unused-binding","severity":"warning","message":"parameter `ctx` is never read","file":"F","line":10,"column":14,"end_line":10,"end_column":17,"byte_start":105,"byte_end":108,"help":"rename it `_ctx` to say it is deliberately unused, or remove it","related":[],"fixes":[{"message":"rename to `_ctx`","applicability":"machine-applicable","edits":[{"file":"F","line":10,"column":14,"end_line":10,"end_column":17,"byte_start":105,"byte_end":108,"original":"ctx","replacement":"_ctx"}]}],"waiver":{"reason":"the callback shape is fixed","line":9}}
"#;

#[test]
fn the_json_output_is_stable() {
    let p = fixture("stable", SRC);
    let out = blue()
        .args(["check", "--format", "json", p.to_str().expect("utf-8")])
        .output()
        .expect("spawn");
    assert_eq!(
        out.status.code(),
        Some(1),
        "an error is present, so the exit is 1"
    );
    let got = String::from_utf8(out.stdout)
        .expect("utf-8")
        .replace(p.to_str().expect("utf-8"), "F");
    assert_eq!(got, EXPECTED, "the JSON output changed");
    // Every line is one JSON object.
    for line in got.lines() {
        let v: serde_json::Value = serde_json::from_str(line).expect("each line parses");
        assert!(v.is_object());
    }
}

/// A syntax error comes out in the same shape.
#[test]
fn a_syntax_error_is_a_json_diagnostic_too() {
    let p = fixture("syntax", "def f(\n");
    let out = blue()
        .args(["check", "--format", "json", p.to_str().expect("utf-8")])
        .output()
        .expect("spawn");
    let got = String::from_utf8(out.stdout).expect("utf-8");
    let v: serde_json::Value = serde_json::from_str(got.trim()).expect("one object");
    assert_eq!(v["code"], "B0006");
    assert_eq!(v["line"], 2);
}

/// Every key the output uses is documented, and so is every code.
#[test]
fn every_field_and_code_is_documented() {
    let doc = std::fs::read_to_string(
        std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/DIAGNOSTICS.md"),
    )
    .expect("docs/DIAGNOSTICS.md");
    let mut keys = std::collections::BTreeSet::new();
    for line in EXPECTED.lines() {
        collect_keys(&serde_json::from_str(line).expect("json"), &mut keys);
    }
    let missing: Vec<&String> = keys
        .iter()
        .filter(|k| !doc.contains(&format!("`{k}`")))
        .collect();
    assert!(missing.is_empty(), "undocumented JSON fields: {missing:?}");
    for r in blue_lang_check::RULES {
        assert!(
            doc.contains(r.code.as_str()),
            "{} is not in docs/DIAGNOSTICS.md",
            r.code
        );
    }
}

fn collect_keys(v: &serde_json::Value, out: &mut std::collections::BTreeSet<String>) {
    match v {
        serde_json::Value::Object(m) => {
            for (k, v) in m {
                out.insert(k.clone());
                collect_keys(v, out);
            }
        }
        serde_json::Value::Array(xs) => xs.iter().for_each(|x| collect_keys(x, out)),
        _ => {}
    }
}

/// `--fix` applies the machine-applicable fixes, leaves the suggestions, and
/// re-checks: the unbound name remains, the unused parameters are renamed.
#[test]
fn fix_applies_only_machine_applicable_fixes() {
    let p = fixture("fix", SRC);
    let out = blue()
        .args(["check", "--fix", p.to_str().expect("utf-8")])
        .output()
        .expect("spawn");
    let after = std::fs::read_to_string(&p).expect("read");
    assert!(
        after.contains("lenght(xs)"),
        "a maybe-incorrect suggestion was applied:\n{after}"
    );
    assert!(after.contains("def g(x, _y)"), "{after}");
    // The waived one stays as written: a waived diagnostic is not reported, so
    // it offers nothing to fix.
    assert!(after.contains("def h(event, ctx)"), "{after}");
    let err = String::from_utf8(out.stderr).expect("utf-8");
    assert!(err.contains("applied 1 fix(es)"), "{err}");
    assert!(
        err.contains("B0001"),
        "the re-check still reports the unbound name: {err}"
    );
    assert_eq!(out.status.code(), Some(1));
}