kotlin-codegen 0.2.0

A declaration model and renderer for generating Kotlin source code
Documentation
//! Runs each example and compares its output against a stored golden file.
//!
//! The examples are the crate's documentation, so they have to keep working —
//! and because their output *is* generated Kotlin, pinning it turns them into
//! readable regression tests for the renderer and the validator. A diff here
//! shows the change in the emitted source, not in a builder call.
//!
//! To accept an intended change, run with `UPDATE_GOLDEN=1`:
//!
//! ```text
//! UPDATE_GOLDEN=1 cargo test --test examples
//! ```
//!
//! then read the diff before committing it.

use std::{
    path::{Path, PathBuf},
    process::Command,
};

#[test]
fn showcase_example_matches_golden() {
    check_example("showcase");
}

#[test]
fn invalid_example_matches_golden() {
    check_example("invalid");
}

/// Every check the validator can report appears in the `invalid` example.
///
/// Pinned separately from the golden file because it is the property that
/// matters: adding a `Check` variant without demonstrating it should fail
/// here, with an explanation, rather than only showing up as a golden diff
/// that looks like noise.
#[test]
fn the_invalid_example_demonstrates_every_check() {
    let output = run_example("invalid");
    let missing: Vec<&str> = kotlin_codegen::Check::ALL
        .iter()
        .map(|c| c.name())
        .filter(|name| !output.contains(&format!("[{name}]")))
        .collect();
    assert!(
        missing.is_empty(),
        "examples/invalid.rs demonstrates no case for: {missing:?}\n\
         Add one so the example stays a complete catalogue of the checks."
    );
}

fn check_example(name: &str) {
    let actual = run_example(name);
    let golden = golden_dir().join(format!("{name}.txt"));

    if std::env::var_os("UPDATE_GOLDEN").is_some() {
        std::fs::create_dir_all(golden.parent().expect("golden dir has a parent"))
            .expect("create golden dir");
        std::fs::write(&golden, &actual).expect("write golden");
        return;
    }

    let expected = std::fs::read_to_string(&golden).unwrap_or_else(|e| {
        panic!(
            "cannot read {}: {e}\nRun `UPDATE_GOLDEN=1 cargo test --test examples` to create it.",
            golden.display()
        )
    });

    if actual != expected {
        panic!(
            "`cargo run --example {name}` no longer matches {}\n\n{}\n\n\
             If the change is intended, run `UPDATE_GOLDEN=1 cargo test --test examples` \
             and review the diff.",
            golden.display(),
            first_difference(&expected, &actual),
        );
    }
}

fn golden_dir() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("golden")
}

/// Build and run an example, returning its stdout.
///
/// Shelling out to cargo rather than locating the binary under `target/`:
/// cargo only builds example targets for some invocations (`cargo test
/// --test examples` does not), so a path-based lookup works or fails
/// depending on how the suite was started. Letting cargo do it means the test
/// behaves the same however it is run, at the cost of a no-op build once the
/// example is already compiled.
fn run_example(name: &str) -> String {
    let cargo = option_env!("CARGO").unwrap_or("cargo");
    let mut cmd = Command::new(cargo);
    cmd.current_dir(env!("CARGO_MANIFEST_DIR"))
        .args(["run", "--quiet", "--example", name]);
    // Match the profile the tests are running under, so an example already
    // built by `cargo test --release` is not rebuilt in debug.
    if !cfg!(debug_assertions) {
        cmd.arg("--release");
    }
    let output = cmd
        .output()
        .unwrap_or_else(|e| panic!("cannot run `{cargo} run --example {name}`: {e}"));
    assert!(
        output.status.success(),
        "example `{name}` exited with {}:\n{}",
        output.status,
        String::from_utf8_lossy(&output.stderr),
    );
    String::from_utf8(output.stdout).expect("example output is UTF-8")
}

/// The first differing line, with a little context — enough to see what moved
/// without printing two whole generated files.
fn first_difference(expected: &str, actual: &str) -> String {
    let exp: Vec<&str> = expected.lines().collect();
    let act: Vec<&str> = actual.lines().collect();
    for (i, (e, a)) in exp.iter().zip(act.iter()).enumerate() {
        if e != a {
            return format!(
                "first difference at line {}:\n  expected: {e}\n  actual:   {a}",
                i + 1
            );
        }
    }
    format!(
        "identical for {} lines, then the length differs: expected {} lines, actual {}",
        exp.len().min(act.len()),
        exp.len(),
        act.len()
    )
}