Tests for EvalOutput / PlainOutput / TracingOutput sink infrastructure, the eval_api public functions, the macro_api layer, and SKILL.md examples.

Output sinks (test_output.rs)

// <<test output>>=
// crates/weaveback-macro/src/evaluator/tests/test_output.rs

use crate::evaluator::output::{EvalOutput, PlainOutput, SourceSpan};
use crate::evaluator::{EvalConfig, Evaluator};
use crate::macro_api::process_string_defaults;
use std::path::PathBuf;

/// Helper: evaluate `source` through evaluate_to(PlainOutput) and return the
/// resulting String.
fn eval_to_plain(source: &str) -> String {
    let mut eval = Evaluator::new(EvalConfig::default());
    let path = PathBuf::from("<test>");
    let ast = eval.parse_string(source, &path).unwrap();
    let mut out = PlainOutput::new();
    eval.evaluate_to(&ast, &mut out).unwrap();
    out.finish()
}

// ---------- Parity tests: evaluate_to(PlainOutput) == evaluate() ----------

#[test]
fn plain_output_parity_literal_text() {
    let src = "hello world";
    let via_evaluate = String::from_utf8(process_string_defaults(src).unwrap()).unwrap();
    let via_output = eval_to_plain(src);
    assert_eq!(via_output, via_evaluate);
}

#[test]
fn plain_output_parity_variable() {
    let src = "%set(x, 42)%(x) is the answer";
    let via_evaluate = String::from_utf8(process_string_defaults(src).unwrap()).unwrap();
    let via_output = eval_to_plain(src);
    assert_eq!(via_output, via_evaluate);
}

#[test]
fn plain_output_parity_def_and_call() {
    let src = "%def(greet, name, %{Hello, %(name)!%})%greet(World)";
    let via_evaluate = String::from_utf8(process_string_defaults(src).unwrap()).unwrap();
    let via_output = eval_to_plain(src);
    assert_eq!(via_output, via_evaluate);
}

#[test]
fn plain_output_parity_nested_macros() {
    let src = r#"%def(inner, %{X%})%def(outer, %{[%inner()]%})%outer()"#;
    let via_evaluate = String::from_utf8(process_string_defaults(src).unwrap()).unwrap();
    let via_output = eval_to_plain(src);
    assert_eq!(via_output, via_evaluate);
}

#[test]
fn plain_output_parity_if() {
    let src = "%set(flag, yes)%if(%(flag), true, false)";
    let via_evaluate = String::from_utf8(process_string_defaults(src).unwrap()).unwrap();
    let via_output = eval_to_plain(src);
    assert_eq!(via_output, via_evaluate);
}

#[test]
fn plain_output_parity_multiline() {
    let src = "%def(tag, name, value, %{<%(name)>%(value)</%(name)>%})\n%tag(div, hello)\n";
    let via_evaluate = String::from_utf8(process_string_defaults(src).unwrap()).unwrap();
    let via_output = eval_to_plain(src);
    assert_eq!(via_output, via_evaluate);
}

#[test]
fn plain_output_parity_named_args() {
    let src = "%def(tag, name, value, %{<%(name)>%(value)</%(name)>%})%tag(name=span, value=hi)";
    let via_evaluate = String::from_utf8(process_string_defaults(src).unwrap()).unwrap();
    let via_output = eval_to_plain(src);
    assert_eq!(via_output, via_evaluate);
}

// ---------- Span correctness: SpyOutput to verify spans ----------

/// A test-only EvalOutput that records spans.
struct SpyOutput {
    buf: String,
    spans: Vec<(String, SourceSpan)>,
    untracked: Vec<String>,
}

impl SpyOutput {
    fn new() -> Self {
        Self {
            buf: String::new(),
            spans: Vec::new(),
            untracked: Vec::new(),
        }
    }
}

impl EvalOutput for SpyOutput {
    fn push_str(&mut self, text: &str, span: SourceSpan) {
        self.buf.push_str(text);
        self.spans.push((text.to_string(), span));
    }

    fn push_untracked(&mut self, text: &str) {
        self.buf.push_str(text);
        self.untracked.push(text.to_string());
    }

    fn finish(self) -> String {
        self.buf
    }
}

#[test]
fn spy_output_captures_literal_spans() {
    let src = "abc";
    let mut eval = Evaluator::new(EvalConfig::default());
    let path = PathBuf::from("<test>");
    let ast = eval.parse_string(src, &path).unwrap();
    let mut spy = SpyOutput::new();
    eval.evaluate_to(&ast, &mut spy).unwrap();

    assert_eq!(spy.buf, "abc");
    assert!(!spy.spans.is_empty(), "no spans were recorded");
    let (text, span) = &spy.spans[0];
    assert_eq!(text, "abc");
    assert_eq!(span.pos, 0);
    assert_eq!(span.length, 3);
}

#[test]
fn spy_output_set_variable_lookup_is_untracked() {
    // %set stores a variable with no span; %(x) therefore goes through
    // push_untracked (no origin token to attribute).  Note: %set itself
    // returns "" so it produces no output at all.
    let src = "%set(x, val)%(x)";
    let mut eval = Evaluator::new(EvalConfig::default());
    let path = PathBuf::from("<test>");
    let ast = eval.parse_string(src, &path).unwrap();
    let mut spy = SpyOutput::new();
    eval.evaluate_to(&ast, &mut spy).unwrap();

    assert_eq!(spy.buf, "val");
    let untracked_texts: Vec<&str> = spy.untracked.iter().map(|t| t.as_str()).collect();
    assert!(
        untracked_texts.contains(&"val"),
        "variable expansion without origin span should be untracked, got: {:?}",
        untracked_texts
    );
}

#[test]
fn spy_output_builtin_result_is_tracked_as_computed() {
    // Builtins that return non-empty strings (e.g. %capitalize) must emit
    // push_str with SpanKind::Computed, not push_untracked.
    // This ensures the tracer can attribute the output line to the call site.
    let src = "%capitalize(hello)";
    let mut eval = Evaluator::new(EvalConfig::default());
    let path = PathBuf::from("<test>");
    let ast = eval.parse_string(src, &path).unwrap();
    let mut spy = SpyOutput::new();
    eval.evaluate_to(&ast, &mut spy).unwrap();

    assert_eq!(spy.buf, "Hello");
    assert!(spy.untracked.is_empty(), "builtin result should not be untracked");
    let (text, span) = spy.spans.iter()
        .find(|(t, _)| t == "Hello")
        .expect("tracked span for 'Hello' not found");
    assert_eq!(text, "Hello");
    assert_eq!(span.kind, SpanKind::Computed,
        "builtin result should carry SpanKind::Computed");
}

#[test]
fn spy_output_user_macro_is_tracked() {
    let src = "%def(wrap, x, %{[%(x)]%})%wrap(hi)";
    let mut eval = Evaluator::new(EvalConfig::default());
    let path = PathBuf::from("<test>");
    let ast = eval.parse_string(src, &path).unwrap();
    let mut spy = SpyOutput::new();
    eval.evaluate_to(&ast, &mut spy).unwrap();

    assert_eq!(spy.buf, "[hi]");
    let tracked_texts: Vec<&str> = spy.spans.iter().map(|(t, _)| t.as_str()).collect();
    assert!(
        tracked_texts.contains(&"["),
        "literal '[' in macro body should be tracked"
    );
    assert!(
        tracked_texts.contains(&"]"),
        "literal ']' in macro body should be tracked"
    );
    assert!(
        tracked_texts.contains(&"hi"),
        "argument substitution should be tracked"
    );
}

// ---------- TracingOutput tests ----------

use crate::evaluator::output::SpanKind;
use crate::evaluator::output::TracingOutput;

#[test]
fn tracing_output_single_line_gets_an_entry() {
    let src = "%def(wrap, x, %{[%(x)]%})%wrap(hi)";
    let mut eval = Evaluator::new(EvalConfig::default());
    let path = PathBuf::from("test.md");
    let ast = eval.parse_string(src, &path).unwrap();
    let mut out = TracingOutput::new();
    eval.evaluate_to(&ast, &mut out).unwrap();

    let entries = out.into_macro_map_entries(eval.sources());
    assert_eq!(out.finish(), "[hi]");
    assert_eq!(entries.len(), 1, "expected one line entry, got: {entries:?}");
    let (out_line, entry) = &entries[0];
    assert_eq!(*out_line, 0);
    assert!(
        matches!(&entry.kind, SpanKind::MacroBody { macro_name } if macro_name == "wrap"),
        "expected MacroBody(wrap), got: {:?}", entry.kind
    );
}

#[test]
fn test_macro_map_entries() {
    let src = "line 1\nline 2 with %set(x, val)%(x)\nline 3";
    let mut eval = Evaluator::new(EvalConfig::default());
    let path = PathBuf::from("test.md");
    let ast = eval.parse_string(src, &path).unwrap();
    let mut out = TracingOutput::new();
    eval.evaluate_to(&ast, &mut out).unwrap();

    let entries = out.into_macro_map_entries(eval.sources());

    assert_eq!(entries.len(), 3);

    let (out_line_0, entry_0) = &entries[0];
    assert_eq!(*out_line_0, 0);
    assert!(entry_0.src_file.ends_with("test.md"));
    assert_eq!(entry_0.src_line, 0);
    assert_eq!(entry_0.src_col, 0);
    assert!(matches!(entry_0.kind, SpanKind::Literal));

    let (out_line_1, entry_1) = &entries[1];
    assert_eq!(*out_line_1, 1);
    assert_eq!(entry_1.src_line, 1);

    let (out_line_2, entry_2) = &entries[2];
    assert_eq!(*out_line_2, 2);
    assert_eq!(entry_2.src_line, 2);
}
// @

Eval API (test_eval_api.rs)

// <<test eval api>>=
// crates/weaveback-macro/src/evaluator/tests/test_eval_api.rs

use std::io::Write;
use std::path::{Path, PathBuf};
use tempfile::{NamedTempFile, TempDir};

use crate::evaluator::{
    errors::{EvalError, EvalResult},
    eval_api::{eval_file_with_config, eval_files_with_config, eval_string_with_defaults},
    state::EvalConfig,
};

fn create_temp_file(content: &str) -> NamedTempFile {
    let mut file = NamedTempFile::new().unwrap();
    write!(file, "{}", content).unwrap();
    file
}

#[test]
fn test_eval_string_basic() {
    let result = eval_string_with_defaults("%def(hello, World)\nHello %hello()!").unwrap();
    assert_eq!(result, "\nHello World!");
}

#[test]
fn test_eval_file() {
    let temp_dir = TempDir::new().unwrap();

    let input_content = "%def(greeting, Hello)\n%greeting(), World!";
    let input_file = create_temp_file(input_content);

    let output_file = temp_dir.path().join("output.txt");

    eval_file_with_config(input_file.path(), &output_file, EvalConfig::default()).unwrap();

    let result = std::fs::read_to_string(output_file).unwrap();
    assert_eq!(result, "\nHello, World!");
}

#[test]
fn test_eval_file_overwrite_protection() {
    let input_file = create_temp_file("content");
    let result = eval_file_with_config(
        input_file.path(),
        input_file.path(),
        EvalConfig::default(),
    );
    assert!(matches!(result, Err(EvalError::Runtime(_))));
}

#[test]
fn test_eval_files() {
    let temp_dir = TempDir::new().unwrap();
    let output_dir = temp_dir.path().join("output");

    let file1 = create_temp_file("%def(shared, Shared content)");
    let file2 = create_temp_file("%shared()");

    eval_files_with_config(
        &[file1.path().to_path_buf(), file2.path().to_path_buf()],
        &output_dir,
        EvalConfig::default(),
    )
    .unwrap();

    let files: Vec<_> = std::fs::read_dir(&output_dir)
        .unwrap()
        .filter_map(|e| e.ok())
        .collect();
    assert_eq!(files.len(), 2);
}
// @

Macro API (test_macro_api.rs)

// <<test macro api>>=
// crates/weaveback-macro/src/evaluator/tests/test_macro_api.rs

use crate::evaluator::{EvalConfig, Evaluator};
use crate::macro_api::{
    process_file, process_files_from_config, process_string, process_string_defaults,
};
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use tempfile::TempDir;

fn create_temp_file(dir: &TempDir, name: &str, content: &str) -> PathBuf {
    let path = dir.path().join(name);
    let mut file = fs::File::create(&path).unwrap();
    write!(file, "{}", content).unwrap();
    path
}

#[test]
fn test_process_string_basic() {
    let result = process_string_defaults("Hello %def(test, World) %test()").unwrap();
    assert_eq!(String::from_utf8(result).unwrap(), "Hello  World");
}

#[test]
fn test_include_basic() -> Result<(), Box<dyn std::error::Error>> {
    let temp_dir = TempDir::new()?;

    let _include_file = create_temp_file(&temp_dir, "include.txt", "test");

    let main_file = create_temp_file(&temp_dir, "main.txt", "%include(include.txt)");

    let mut config = EvalConfig::default();
    config.include_paths = vec![temp_dir.path().to_path_buf()];
    let mut evaluator = Evaluator::new(config);

    let output_file = temp_dir.path().join("output.txt");

    process_file(&main_file, &output_file, &mut evaluator)?;

    let result = fs::read_to_string(output_file)?;
    assert_eq!(result.trim(), "test");

    Ok(())
}

#[test]
fn test_process_string_with_error() {
    let result = process_string_defaults("%undefined_macro()");
    assert!(result.is_err());
}

#[test]
fn test_process_string_with_nested_macros() {
    let source = r#"
        %def(inner, value, Inside: %(value))
        %def(outer, arg, Outside: %inner(%(arg)))
        %outer(test)
    "#;

    let result = process_string_defaults(source).unwrap();
    let output = String::from_utf8(result).unwrap();
    assert!(output.contains("Outside: Inside: test"));
}

#[test]
fn test_process_string_with_special_chars() {
    let mut config = EvalConfig::default();
    config.special_char = '@';
    let mut evaluator = Evaluator::new(config);

    let result = process_string(
        "@def(test, value, Result: @(value))@test(works)",
        None,
        &mut evaluator,
    )
    .unwrap();

    assert_eq!(String::from_utf8(result).unwrap().trim(), "Result: works");
}

#[test]
fn test_process_files_with_shared_macros() {
    let temp_dir = TempDir::new().unwrap();
    let file1 = create_temp_file(&temp_dir, "file1.txt", "%def(shared, Shared content)");
    let file2 = create_temp_file(&temp_dir, "file2.txt", "%shared()");

    let output_file = temp_dir.path().join("output.txt");

    let config = EvalConfig::default();
    process_files_from_config(&[file1, file2], &output_file, config).unwrap();

    let output = fs::read_to_string(&output_file).unwrap();
    assert_eq!(output.trim(), "Shared content");
}
// @

SKILL.md examples (test_skill_examples.rs)

// <<test skill examples>>=
// crates/weaveback-macro/src/evaluator/tests/test_skill_examples.rs
//
// Tests that verify the exact examples shown in SKILL.md.

use crate::evaluator::EvalError;
use crate::macro_api::process_string_defaults;

/// SKILL.md — positional params, multi-line call, leading space stripped, %# comments stripped.
#[test]
fn test_tag_positional_space_stripped() {
    let input = "%def(tag, name, value, %{<%(name)>%(value)</%(name)>%})\n\
                 %tag( div,         %# element name — leading space stripped\n\
                       Hello world) %# value        — leading space stripped";
    let result = process_string_defaults(input).unwrap();
    let s = std::str::from_utf8(&result).unwrap();
    assert!(
        s.contains("<div>Hello world</div>"),
        "expected '<div>Hello world</div>' in output, got: {s:?}"
    );
}

/// SKILL.md — %{ %} wrapping preserves the leading space inside the block.
#[test]
fn test_tag_block_preserves_leading_space() {
    let input = "%def(tag, name, value, %{<%(name)>%(value)</%(name)>%})\n\
                 %tag(%{ div%}, %{ Hello world%})";
    let result = process_string_defaults(input).unwrap();
    let s = std::str::from_utf8(&result).unwrap();
    assert!(
        s.contains("< div> Hello world</ div>"),
        "expected '< div> Hello world</ div>' in output, got: {s:?}"
    );
}

/// SKILL.md — named parameters on multiple lines with interspersed whitespace.
#[test]
fn test_http_endpoint_named_params() {
    let input = "%def(http_endpoint, method, path, handler, %{\n\
                 %(method) %(path) \u{2192} %(handler)\n\
                 %})\n\
                 \n\
                 %http_endpoint(\n\
                     method  = GET,\n\
                     path    = /api/users,\n\
                     handler = list_users)";
    let result = process_string_defaults(input).unwrap();
    let s = std::str::from_utf8(&result).unwrap();
    assert!(
        s.contains("GET /api/users \u{2192} list_users"),
        "expected 'GET /api/users \u{2192} list_users' in output, got: {s:?}"
    );
}

/// Too few arguments: missing params silently become empty strings.
#[test]
fn test_too_few_args_become_empty() {
    let result = process_string_defaults(
        "%def(greet, name, msg, Hello %(name)%(msg)!)\n\
         %greet(Alice)",
    )
    .unwrap();
    let s = std::str::from_utf8(&result).unwrap();
    assert!(
        s.contains("Hello Alice!"),
        "expected 'Hello Alice!' (msg empty), got: {s:?}"
    );
}

/// Too many arguments: extra args beyond the parameter list are silently ignored.
#[test]
fn test_too_many_args_ignored() {
    let result = process_string_defaults(
        "%def(greet, name, Hello %(name)!)\n\
         %greet(Alice, Bob, Charlie)",
    )
    .unwrap();
    let s = std::str::from_utf8(&result).unwrap();
    assert!(
        s.contains("Hello Alice!"),
        "expected 'Hello Alice!' (extras ignored), got: {s:?}"
    );
    assert!(
        !s.contains("Bob") && !s.contains("Charlie"),
        "extra args should be ignored, got: {s:?}"
    );
}

/// %def uses *dynamic* scoping for outer variables: the value at *call* time is used.
#[test]
fn test_outer_variable_is_dynamic() {
    let result = process_string_defaults(
        "%set(greeting, Hi)\n\
         %def(greet, name, %(greeting) %(name)!)\n\
         %set(greeting, Bye)\n\
         %greet(Alice)",
    )
    .unwrap();
    let s = std::str::from_utf8(&result).unwrap();
    assert!(
        s.contains("Bye Alice!"),
        "expected dynamic 'Bye', got: {s:?}"
    );
}

/// Named params are matched by name; any order among named args is valid.
#[test]
fn test_named_params_any_order() {
    let result = process_string_defaults(
        "%def(http_endpoint, method, path, handler, \
              %(method) %(path) %(handler))\n\
         %http_endpoint(\n\
             handler = list_users,\n\
             method  = GET,\n\
             path    = /api/users)",
    )
    .unwrap();
    let s = std::str::from_utf8(&result).unwrap();
    assert!(
        s.contains("GET /api/users list_users"),
        "named params in reverse order should still bind by name, got: {s:?}"
    );
}

/// Positional before named: the first param is positional, the rest named.
#[test]
fn test_positional_before_named() {
    let result = process_string_defaults(
        "%def(f, a, b, c, %(a)-%(b)-%(c))\n\
         %f(X, c = Z, b = Y)",
    )
    .unwrap();
    let s = std::str::from_utf8(&result).unwrap();
    assert!(s.contains("X-Y-Z"), "expected 'X-Y-Z', got: {s:?}");
}

/// Positional after named → error.
#[test]
fn test_positional_after_named_is_error() {
    let result = process_string_defaults(
        "%def(f, a, b, %(a)-%(b))\n\
         %f(a = X, Y)",
    );
    assert!(
        matches!(result, Err(EvalError::InvalidUsage(_))),
        "expected InvalidUsage for positional-after-named, got: {result:?}"
    );
}

/// Binding the same param positionally and by name → error.
#[test]
fn test_double_bind_is_error() {
    let result = process_string_defaults(
        "%def(f, a, b, %(a)-%(b))\n\
         %f(X, a = Y)",
    );
    assert!(
        matches!(result, Err(EvalError::InvalidUsage(_))),
        "expected InvalidUsage for double-bind, got: {result:?}"
    );
}

/// Unknown named param is an error (helps catch typos).
#[test]
fn test_unknown_named_param_is_error() {
    let result = process_string_defaults(
        "%def(greet, name, Hello %(name)!)\n\
         %greet(name = Alice, typo = oops)",
    );
    assert!(
        matches!(result, Err(EvalError::InvalidUsage(_))),
        "expected InvalidUsage for unknown named arg, got: {result:?}"
    );
}
// @