netsuke-build 0.1.0-beta2

A YAML-powered Ninja/Jinja hybrid build system.
//! Unit tests for manifest template rendering.

use super::{ManifestValue, render_manifest};
use crate::ast::{DependencyOrder, NetsukeManifest, Recipe, Rule, StringOrList, Target, Vars};
use anyhow::{Context, Result};
use minijinja::Environment;
use semver::Version;

fn sample_manifest() -> Result<NetsukeManifest> {
    let mut target_vars = Vars::new();
    target_vars.insert("greet".into(), ManifestValue::String("hello".into()));
    target_vars.insert("subject".into(), ManifestValue::String("world".into()));
    target_vars.insert(
        "message".into(),
        ManifestValue::String("{{ greet }} {{ subject }}".into()),
    );

    let target = Target {
        name: StringOrList::String("{{ message }}!".into()),
        recipe: Recipe::Command {
            command: "{{ message }}".into(),
        },
        sources: StringOrList::List(vec!["{{ subject }}.txt".into()]),
        deps: StringOrList::Empty,
        dependency_order: DependencyOrder::Parallel,
        order_only_deps: StringOrList::List(vec!["{{ subject }}.meta".into()]),
        vars: target_vars,
        phony: false,
        always: false,
        description: Some("{{ message }}".into()),
    };

    let rule = Rule {
        name: "example".into(),
        recipe: Recipe::Command {
            command: "{{ 2 + 2 }}".into(),
        },
        description: Some("{{ 1 + 1 }}".into()),
    };

    let mut manifest_vars = Vars::new();
    manifest_vars.insert(
        "message".into(),
        ManifestValue::String("hello world".into()),
    );

    Ok(NetsukeManifest {
        netsuke_version: Version::parse("1.0.0")?,
        vars: manifest_vars,
        macros: Vec::new(),
        rules: vec![rule],
        actions: Vec::new(),
        targets: vec![target],
        defaults: Vec::new(),
    })
}

#[expect(clippy::panic, reason = "panic for clearer test failures")]
fn expect_var(vars: &Vars, key: impl AsRef<str>) -> &str {
    let key_ref = key.as_ref();
    let Some(value) = vars.get(key_ref).and_then(|value| value.as_str()) else {
        panic!("expected rendered var '{key_ref}'");
    };
    value
}

#[expect(clippy::panic, reason = "panic for clearer test failures")]
fn expect_string(value: &StringOrList, label: impl std::fmt::Display) -> &str {
    match value {
        StringOrList::String(item) => item,
        other => panic!("expected {label} as string, got {other:?}"),
    }
}

#[expect(clippy::panic, reason = "panic for clearer test failures")]
fn expect_list(value: &StringOrList, label: impl std::fmt::Display) -> &[String] {
    match value {
        StringOrList::List(items) => items,
        other => panic!("expected {label} as list, got {other:?}"),
    }
}

#[expect(clippy::panic, reason = "panic for clearer test failures")]
fn expect_command(recipe: &Recipe, label: impl std::fmt::Display) -> &str {
    match recipe {
        Recipe::Command { command } => match command {
            StringOrList::String(item) => item,
            other => panic!("expected {label} command as a scalar, got {other:?}"),
        },
        other => panic!("expected {label} command recipe, got {other:?}"),
    }
}

fn expect_script(recipe: &Recipe, label: impl std::fmt::Display) -> Result<&str> {
    match recipe {
        Recipe::Script { script } => Ok(script),
        other => anyhow::bail!("expected {label} script recipe, got {other:?}"),
    }
}

fn expect_rule_ref(recipe: &Recipe, label: impl std::fmt::Display) -> Result<&StringOrList> {
    match recipe {
        Recipe::Rule { rule } => Ok(rule),
        other => anyhow::bail!("expected {label} rule-reference recipe, got {other:?}"),
    }
}

fn assert_rendered_target(target: &Target) {
    assert_eq!(target.dependency_order, DependencyOrder::Parallel);
    assert_eq!(expect_var(&target.vars, "message"), "hello world");
    assert_eq!(
        target.description.as_deref(),
        Some("hello world"),
        "target description should be rendered through the target vars"
    );
    assert_eq!(expect_string(&target.name, "target name"), "hello world!");
    assert_eq!(
        expect_list(&target.sources, "target sources"),
        ["world.txt"]
    );
    assert_eq!(expect_command(&target.recipe, "target"), "hello world");
    assert_eq!(
        expect_list(&target.order_only_deps, "order-only deps"),
        ["world.meta"]
    );
}

fn assert_rendered_rule(rule: &Rule) {
    assert_eq!(rule.description.as_deref(), Some("2"));
    match &rule.recipe {
        Recipe::Command { command } => assert_eq!(command.as_single(), Some("4")),
        other => panic!("expected command recipe, got {other:?}"),
    }
}

#[test]
fn render_manifest_renders_targets_and_rules() -> Result<()> {
    let env = Environment::new();
    let manifest = sample_manifest()?;
    let rendered = render_manifest(manifest, &env)?;
    let rendered_target = rendered
        .targets
        .first()
        .context("rendered target missing")?;
    assert_rendered_target(rendered_target);
    let rendered_rule = rendered.rules.first().context("rendered rule missing")?;
    assert_rendered_rule(rendered_rule);
    Ok(())
}

#[test]
fn command_list_renders_each_entry_with_ins_outs_placeholders() -> Result<()> {
    let env = Environment::new();
    let mut vars = Vars::new();
    vars.insert("ins".into(), ManifestValue::String("caller-ins".into()));
    vars.insert("outs".into(), ManifestValue::String("caller-outs".into()));
    let manifest = NetsukeManifest {
        netsuke_version: Version::parse("1.0.0")?,
        vars,
        macros: Vec::new(),
        rules: vec![Rule {
            name: "check".into(),
            recipe: Recipe::Command {
                command: StringOrList::List(vec![
                    "echo {{ 1 + 1 }}".into(),
                    "{{ ins }}".into(),
                    "{{ outs }}".into(),
                ]),
            },
            description: None,
        }],
        actions: Vec::new(),
        targets: Vec::new(),
        defaults: Vec::new(),
    };
    let rendered = render_manifest(manifest, &env)?;
    let rule = rendered.rules.first().context("rendered rule missing")?;
    let Recipe::Command { command } = &rule.recipe else {
        anyhow::bail!("expected command recipe, got {:?}", rule.recipe);
    };
    anyhow::ensure!(
        command.to_string_vec() == ["echo 2", crate::ir::INS_TOKEN, crate::ir::OUTS_TOKEN],
        "unexpected rendered command list: {command:?}"
    );
    Ok(())
}

#[test]
fn command_list_render_failure_names_the_failing_entry() -> Result<()> {
    let env = Environment::new();
    let manifest = NetsukeManifest {
        netsuke_version: Version::parse("1.0.0")?,
        vars: Vars::new(),
        macros: Vec::new(),
        rules: vec![Rule {
            name: "check".into(),
            recipe: Recipe::Command {
                command: StringOrList::List(vec!["echo ok".into(), "echo {{ 1 + }}".into()]),
            },
            description: None,
        }],
        actions: Vec::new(),
        targets: Vec::new(),
        defaults: Vec::new(),
    };
    let error = render_manifest(manifest, &env)
        .err()
        .context("expected the malformed entry to fail rendering")?;
    let report = format!("{error:#}");
    anyhow::ensure!(
        report.contains("render rule command entry 2"),
        "error should name the failing list position, got: {report}"
    );
    Ok(())
}

fn assert_rendered_script_and_rule_recipes(rendered: &NetsukeManifest) -> Result<()> {
    let rendered_target = rendered
        .targets
        .first()
        .context("rendered script target missing")?;
    anyhow::ensure!(
        expect_script(&rendered_target.recipe, "rendered script target")? == "echo world",
        "expected rendered script target recipe to equal 'echo world'"
    );
    let rendered_rule = rendered
        .rules
        .first()
        .context("rendered rule-reference rule missing")?;
    anyhow::ensure!(
        expect_list(
            expect_rule_ref(&rendered_rule.recipe, "rendered rule reference")?,
            "rule reference names",
        ) == ["base"],
        "expected rendered rule-reference names to equal ['base']"
    );
    Ok(())
}

#[test]
fn render_manifest_renders_script_and_rule_ref_recipes() -> Result<()> {
    let mut target_vars = Vars::new();
    target_vars.insert("subject".into(), ManifestValue::String("world".into()));
    let target = Target {
        name: StringOrList::String("scripted".into()),
        recipe: Recipe::Script {
            script: "echo {{ subject }}".into(),
        },
        sources: StringOrList::Empty,
        deps: StringOrList::Empty,
        dependency_order: DependencyOrder::Parallel,
        order_only_deps: StringOrList::Empty,
        vars: target_vars,
        phony: false,
        always: false,
        description: None,
    };
    let rule = Rule {
        name: "delegating".into(),
        recipe: Recipe::Rule {
            rule: StringOrList::List(vec!["{{ rule_name }}".into()]),
        },
        description: None,
    };
    let mut manifest_vars = Vars::new();
    manifest_vars.insert("rule_name".into(), ManifestValue::String("base".into()));

    let manifest = NetsukeManifest {
        netsuke_version: Version::parse("1.0.0")?,
        vars: manifest_vars,
        macros: Vec::new(),
        rules: vec![rule],
        actions: Vec::new(),
        targets: vec![target],
        defaults: Vec::new(),
    };

    let rendered = render_manifest(manifest, &minijinja::Environment::new())?;
    assert_rendered_script_and_rule_recipes(&rendered)?;
    Ok(())
}