knok-build 0.3.3

Build-script graph tracing and code generation for knok
use std::{
    env, fs,
    path::{Path, PathBuf},
    process::Command,
};

use tempfile::TempDir;

struct Fixture {
    name: &'static str,
    build_rs: &'static str,
    expected: &'static [&'static str],
}

#[test]
fn build_script_tracing_failures_are_reported() {
    let fixtures = [
        Fixture {
            name: "shape_mismatch",
            build_rs: r#"
use knok_build::prelude::*;

#[knok_build::graph(backend = Backend::LlvmCpu)]
fn forward(x: Tensor2<f32, 2, 3>, y: Tensor2<f32, 4, 3>) -> Tensor2<f32, 2, 3> {
    x + y
}

fn main() {
    knok_build::compile_graphs_with_options!(
        BuildOptions::stub_artifacts_for_check();
        forward
    );
}
"#,
            expected: &[
                "knok build failed",
                "elementwise operands are not broadcast-compatible",
            ],
        },
        Fixture {
            name: "return_type_mismatch",
            build_rs: r#"
use knok_build::prelude::*;

#[knok_build::graph(backend = Backend::LlvmCpu)]
fn forward(x: Tensor2<f32, 2, 3>) -> Tensor1<f32, 3> {
    sum_axis(x, 1)
}

fn main() {
    knok_build::compile_graphs_with_options!(
        BuildOptions::stub_artifacts_for_check();
        forward
    );
}
"#,
            expected: &["knok build failed", "return type mismatch"],
        },
        Fixture {
            name: "invalid_axis",
            build_rs: r#"
use knok_build::prelude::*;

#[knok_build::graph(backend = Backend::LlvmCpu)]
fn forward(x: Tensor2<f32, 2, 3>) -> Tensor1<f32, 2> {
    sum_axis(x, 3)
}

fn main() {
    knok_build::compile_graphs_with_options!(
        BuildOptions::stub_artifacts_for_check();
        forward
    );
}
"#,
            expected: &["knok build failed", "axis 3 is out of bounds"],
        },
    ];

    let temp = TempDir::new().expect("create temp fixture root");
    let target_dir = temp.path().join("target");
    let knok_root = workspace_root();
    for fixture in fixtures {
        let project = temp.path().join(fixture.name);
        write_fixture(&project, fixture.name, &knok_root, fixture.build_rs);

        let output = Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into()))
            .arg("check")
            .arg("--offline")
            .arg("--manifest-path")
            .arg(project.join("Cargo.toml"))
            .env("CARGO_TARGET_DIR", &target_dir)
            .output()
            .expect("run cargo check fixture");
        let stderr = String::from_utf8_lossy(&output.stderr);
        let stdout = String::from_utf8_lossy(&output.stdout);
        let combined = format!("{stdout}\n{stderr}");

        assert!(
            !output.status.success(),
            "fixture `{}` unexpectedly passed\n{}",
            fixture.name,
            combined
        );
        for expected in fixture.expected {
            assert!(
                combined.contains(expected),
                "fixture `{}` output did not contain `{}`\n{}",
                fixture.name,
                expected,
                combined
            );
        }
    }
}

fn write_fixture(project: &Path, name: &str, knok_root: &Path, build_rs: &str) {
    fs::create_dir_all(project.join("src")).expect("create fixture src");
    fs::write(
        project.join("Cargo.toml"),
        format!(
            r#"[package]
name = "knok-build-negative-{name}"
version = "0.0.0"
edition = "2021"
publish = false

[build-dependencies]
knok-build = {{ path = "{}" }}
"#,
            toml_path(&knok_root.join("crates/knok-build"))
        ),
    )
    .expect("write fixture manifest");
    fs::write(project.join("build.rs"), build_rs).expect("write fixture build script");
    fs::write(project.join("src/lib.rs"), "").expect("write fixture lib");
}

fn workspace_root() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .and_then(Path::parent)
        .expect("knok-build crate lives two levels below the workspace root")
        .to_path_buf()
}

fn toml_path(path: &Path) -> String {
    path.display().to_string().replace('\\', "\\\\")
}