md-tmpl 0.6.1

Lightweight template engine for .tmpl.md prompt files with typed frontmatter
Documentation
//! Build script that generates compile-time `template!` tests from shared TOML
//! fixtures.
//!
//! For each test case with an inline `template` field:
//! 1. A `template!` invocation is generated → exercises **compile-time** parsing,
//!    type checking, and code generation via the proc macro.
//! 2. A `#[test]` function is generated that renders the compiled template and
//!    compares the output to `expected_output` → exercises **runtime** rendering
//!    and verifies parity between compile-time and runtime paths.
//!
//! Tests that use file references (`parent_template`), `expected_error`, or
//! `env:` blocks are skipped since `template!` cannot handle those.

use std::{fmt::Write, path::Path};

/// Shared TOML files to process (relative to workspace root `tests/shared/`).
const SHARED_TOML_FILES: &[&str] = &[
    "inline_tmpl_tests.toml",
    "inline_control_tests.toml",
    "tmpl_param_tests.toml",
    "feature_e2e_tests.toml",
    // env_tests.toml is skipped — env: requires compile-time env values
    // include_tests.toml is skipped — file includes need actual files
];

fn main() {
    let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
    let shared_dir = Path::new(&manifest_dir).join("../../tests/shared");
    let out_dir = std::env::var("OUT_DIR").unwrap();
    let out_path = Path::new(&out_dir).join("shared_compile_time_gen.rs");

    let mut output = String::new();

    generate_preamble(&mut output);

    let mut test_count = 0u32;
    let mut skipped_count = 0u32;

    for toml_file in SHARED_TOML_FILES {
        let toml_path = shared_dir.join(toml_file);
        if !toml_path.exists() {
            eprintln!(
                "cargo::warning=shared test file not found: {}",
                toml_path.display()
            );
            continue;
        }

        // Tell Cargo to re-run if the TOML file changes.
        println!("cargo::rerun-if-changed={}", toml_path.display());

        let toml_content = std::fs::read_to_string(&toml_path)
            .unwrap_or_else(|e| panic!("failed to read {}: {e}", toml_path.display()));
        let root: toml::Table = toml::from_str(&toml_content)
            .unwrap_or_else(|e| panic!("failed to parse {}: {e}", toml_path.display()));
        let Some(tests) = root.get("tests").and_then(|v| v.as_array()) else {
            eprintln!("cargo::warning=no tests array in {toml_file}");
            continue;
        };

        for tc_val in tests {
            let tc = tc_val.as_table().expect("test case should be a table");
            if let Some(generated) = process_test_case(tc, &mut output) {
                if generated {
                    test_count += 1;
                } else {
                    skipped_count += 1;
                }
            } else {
                skipped_count += 1;
            }
        }
    }

    std::fs::write(&out_path, &output)
        .unwrap_or_else(|e| panic!("failed to write {}: {e}", out_path.display()));

    eprintln!(
        "cargo::warning=generated {test_count} compile-time template tests ({skipped_count} skipped: expected_error/file-includes/env)"
    );
}

/// Write the preamble (helper functions) to the generated test file.
fn generate_preamble(output: &mut String) {
    writeln!(
        output,
        r#"
// AUTO-GENERATED by build.rs — do not edit.
// Tests that every shared template compiles via template!() at compile time
// AND renders correctly at runtime.

use std::sync::Arc;
use md_tmpl::{{Context, Value}};

fn toml_to_value(val: &toml::Value) -> Value {{
    match val {{
        toml::Value::String(s) => {{
            if s == "None" {{
                Value::None
            }} else if let Some(inner) = s.strip_prefix("Some(").and_then(|r| r.strip_suffix(')')) {{
                Value::Str(inner.to_string())
            }} else {{
                Value::Str(s.clone())
            }}
        }}
        toml::Value::Integer(i) => Value::Int(*i),
        toml::Value::Float(f) => Value::Float(*f),
        toml::Value::Boolean(b) => Value::Bool(*b),
        toml::Value::Datetime(dt) => Value::Str(dt.to_string()),
        toml::Value::Array(arr) => {{
            Value::List(Arc::new(arr.iter().map(toml_to_value).collect()))
        }}
        toml::Value::Table(tbl) => {{
            let mut map = md_tmpl::__private::HashMap::new();
            for (k, v) in tbl {{
                map.insert(k.clone(), toml_to_value(v));
            }}
            Value::Struct(Arc::new(map))
        }}
    }}
}}

fn toml_to_context(params_toml: &str) -> Context {{
    let val: toml::Value = toml::from_str(params_toml).expect("parse params toml");
    let mut ctx = Context::new();
    if let toml::Value::Table(tbl) = val {{
        for (k, v) in tbl {{
            ctx.set(&k, toml_to_value(&v));
        }}
    }}
    ctx
}}
"#
    )
    .unwrap();
}

/// Process a single TOML test case and generate test code.
///
/// Returns `Some(true)` if a test was generated, `Some(false)` if the test
/// was skipped, or `None` for structurally invalid cases (also skipped).
fn process_test_case(tc: &toml::Table, output: &mut String) -> Option<bool> {
    let name = tc
        .get("name")
        .and_then(|v| v.as_str())
        .expect("test must have a name");

    // Skip tests that expect errors — template! would correctly fail
    // at compile time, so we can't generate a module for them.
    if tc.contains_key("expected_error") {
        return Some(false);
    }

    // Skip tests that use parent_template (file includes).
    if tc.contains_key("parent_template") {
        return Some(false);
    }

    // Skip tests explicitly marked for runtime-only execution (e.g.,
    // templates with nested type aliases that generate typed Rust structs
    // which can't be populated by toml_to_context).
    if tc
        .get("skip_compile_time")
        .and_then(toml::Value::as_bool)
        // NOLINT: optional TOML field — absent means "don't skip", so false is the correct default
        .unwrap_or(false)
    {
        return Some(false);
    }

    // Must have inline template source and expected_output.
    let template_src = tc.get("template").and_then(|v| v.as_str())?;

    // Skip file references (e.g. "foo.tmpl.md").
    if template_src.ends_with(".tmpl.md") {
        return Some(false);
    }

    let expected_output = tc.get("expected_output").and_then(|v| v.as_str())?;

    // Skip templates that reference env: vars (need compile-time env values
    // that can't be provided via template!() without the env = {} syntax).
    if template_src.contains("\nenv:") || template_src.contains("\nenv :") {
        return Some(false);
    }

    generate_test_module(name, template_src, expected_output, tc, output);
    Some(true)
}

/// Generate a test module for a single test case.
fn generate_test_module(
    name: &str,
    template_src: &str,
    expected_output: &str,
    tc: &toml::Table,
    output: &mut String,
) {
    // Generate a unique module name from the test name.
    let mod_name = format!("ct_{name}");

    // Serialize params to a TOML string for runtime use.
    let params_toml = match tc.get("params") {
        Some(toml::Value::Table(tbl)) => toml::to_string(tbl)
            .unwrap_or_else(|e| panic!("failed to serialize params for {name}: {e}")),
        _ => String::new(),
    };

    // Escape the template source for use as a raw string literal.
    let (open_delim, close_delim) = raw_string_delimiters(template_src);
    let (exp_open, exp_close) = raw_string_delimiters(expected_output);
    let (params_open, params_close) = raw_string_delimiters(&params_toml);

    // Generate the template! invocation inside a unique module
    // to prevent borrow checker conflicts between LazyLock closures.
    // Each module also contains its test function.
    writeln!(
        output,
        r#"mod test_{name} {{
    use super::*;

    md_tmpl::template!(
        {open_delim}{template_src}{close_delim} => {mod_name}
    );

    #[test]
    fn compile_time() {{
        let expected = {exp_open}{expected_output}{exp_close};
        let params_toml = {params_open}{params_toml}{params_close};
        let ctx = toml_to_context(params_toml);
        let tmpl = {mod_name}::template();
        let output = tmpl
            .render_ctx(&ctx)
            .unwrap_or_else(|e| panic!("[{name}] render failed: {{e}}"));
        assert_eq!(output, expected, "[{name}] compile-time template output mismatch");

        // Also verify compile-time and runtime templates produce identical output.
        let runtime_tmpl = md_tmpl::Template::from_source(
            {open_delim}{template_src}{close_delim}
        ).unwrap_or_else(|e| panic!("[{name}] runtime parse failed: {{e}}"));
        let runtime_output = runtime_tmpl
            .render_ctx(&ctx)
            .unwrap_or_else(|e| panic!("[{name}] runtime render failed: {{e}}"));
        assert_eq!(
            output, runtime_output,
            "[{name}] compile-time vs runtime output divergence"
        );
    }}
}}"#,
    )
    .unwrap();
}

/// Find raw string delimiters (r#"..."#) that don't conflict with the content.
fn raw_string_delimiters(content: &str) -> (String, String) {
    let mut hashes = 0;
    while content.contains(&format!("\"{}", "#".repeat(hashes))) {
        hashes += 1;
    }
    let h = "#".repeat(hashes);
    (format!("r{h}\""), format!("\"{h}"))
}