use std::sync::Arc;
use crate::{CompileOptions, Context, Template, Value};
#[test]
fn include_renders_own_constants() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path();
std::fs::write(
base.join("helper.tmpl.md"),
r#"---
name: helper
consts:
- GREETING = str := "Hey"
params: [name = str]
---
{{ GREETING }} {{ name }}!"#,
)
.unwrap();
let main_src = r"---
params: [name = str]
---
> {% include [helper](./helper.tmpl.md) with name=name %}";
let (tmpl, _) = Template::compile(main_src, CompileOptions::default().base_dir(base)).unwrap();
let mut ctx = Context::new();
ctx.set("name", "Alice");
let output = tmpl.render_ctx(&ctx).unwrap();
assert_eq!(output, "Hey Alice!");
}
#[test]
fn include_constants_do_not_leak_to_parent() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path();
std::fs::write(
base.join("helper.tmpl.md"),
r#"---
name: helper
consts:
- LEAKED_CONST = str := "secret"
params: []
---
included"#,
)
.unwrap();
let main_src = r"---
params: []
---
> {% include [helper](./helper.tmpl.md) %}
{{ LEAKED_CONST }}";
let result = Template::compile(main_src, CompileOptions::default().base_dir(base));
assert!(
result.is_err(),
"LEAKED_CONST from include should not be visible in parent"
);
}
#[test]
fn parent_context_visible_in_include_via_allow_unused() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path();
std::fs::write(
base.join("helper.tmpl.md"),
r"---
name: helper
params: [ctx_val = str]
---
Got: {{ ctx_val }}",
)
.unwrap();
let main_src = r"---
params: [ctx_val = str]
---
> {% include [helper](./helper.tmpl.md) with ctx_val=ctx_val %}";
let (tmpl, _) = Template::compile(main_src, CompileOptions::default().base_dir(base)).unwrap();
let mut ctx = Context::new();
ctx.set("ctx_val", "from_parent");
let output = tmpl.render_ctx(&ctx).unwrap();
assert_eq!(output, "Got: from_parent");
}
#[test]
fn imported_constants_render_correctly() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path();
std::fs::write(
base.join("colors.tmpl.md"),
r#"---
name: colors
consts:
- PRIMARY = str := "blue"
- COUNT = int := 42
---
"#,
)
.unwrap();
let main_src = r"---
imports:
- [colors](./colors.tmpl.md)
params: []
---
Color: {{ colors.PRIMARY }}, Count: {{ colors.COUNT }}";
let (tmpl, _) = Template::compile(main_src, CompileOptions::default().base_dir(base)).unwrap();
let ctx = Context::new();
let output = tmpl.render_ctx(&ctx).unwrap();
assert_eq!(output, "Color: blue, Count: 42");
}
#[test]
fn imported_constants_are_namespaced() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path();
std::fs::write(
base.join("a.tmpl.md"),
r#"---
name: a
consts:
- VAL = str := "from_a"
---
"#,
)
.unwrap();
std::fs::write(
base.join("b.tmpl.md"),
r#"---
name: b
consts:
- VAL = str := "from_b"
---
"#,
)
.unwrap();
let main_src = r"---
imports:
- [a](./a.tmpl.md)
- [b](./b.tmpl.md)
params: []
---
{{ a.VAL }} {{ b.VAL }}";
let (tmpl, _) = Template::compile(main_src, CompileOptions::default().base_dir(base)).unwrap();
let ctx = Context::new();
let output = tmpl.render_ctx(&ctx).unwrap();
assert_eq!(output, "from_a from_b");
}
#[test]
fn bare_imported_constant_not_accessible() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path();
std::fs::write(
base.join("lib.tmpl.md"),
r#"---
name: lib
consts:
- SECRET = str := "hidden"
---
"#,
)
.unwrap();
let main_src = r"---
imports:
- [lib](./lib.tmpl.md)
params: []
---
{{ SECRET }}";
let result = Template::compile(main_src, CompileOptions::default().base_dir(base));
assert!(
result.is_err(),
"imported constant should not be accessible without module prefix"
);
}
#[test]
fn variables_do_not_leak_between_includes() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path();
std::fs::write(
base.join("greet.tmpl.md"),
r"---
name: greet
params: [who = str]
---
Hello {{ who }}",
)
.unwrap();
std::fs::write(
base.join("farewell.tmpl.md"),
r"---
name: farewell
params: [who = str]
---
Bye {{ who }}",
)
.unwrap();
let main_src = r#"---
params: []
---
> {% include [greet](./greet.tmpl.md) with who="Alice" %}
> {% include [farewell](./farewell.tmpl.md) with who="Bob" %}"#;
let (tmpl, _) = Template::compile(main_src, CompileOptions::default().base_dir(base)).unwrap();
let ctx = Context::new();
let output = tmpl.render_ctx(&ctx).unwrap();
assert!(
output.contains("Hello Alice"),
"first include should greet Alice, got: {output}"
);
assert!(
output.contains("Bye Bob"),
"second include should farewell Bob, got: {output}"
);
assert!(
!output.contains("Hello Bob"),
"Alice variable must not leak to Bob include"
);
assert!(
!output.contains("Bye Alice"),
"Bob variable must not leak to Alice include"
);
}
#[test]
fn with_vars_override_parent_context() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path();
std::fs::write(
base.join("echo.tmpl.md"),
r"---
name: echo
params: [val = str]
---
{{ val }}",
)
.unwrap();
let main_src = r#"---
params: [val = str]
---
Parent: {{ val }}
> {% include [echo](./echo.tmpl.md) with val="overridden" %}"#;
let (tmpl, _) = Template::compile(main_src, CompileOptions::default().base_dir(base)).unwrap();
let mut ctx = Context::new();
ctx.set("val", "original");
let output = tmpl.render_ctx(&ctx).unwrap();
assert!(
output.contains("Parent: original"),
"parent should see original, got: {output}"
);
assert!(
output.contains("overridden"),
"include should see overridden val, got: {output}"
);
}
#[test]
fn include_default_params_injected() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path();
std::fs::write(
base.join("widget.tmpl.md"),
r#"---
name: widget
params:
- label = str
- color = str := "gray"
---
{{ label }}({{ color }})"#,
)
.unwrap();
let main_src = r#"---
params: []
---
> {% include [widget](./widget.tmpl.md) with label="Button" %}"#;
let (tmpl, _) = Template::compile(main_src, CompileOptions::default().base_dir(base)).unwrap();
let ctx = Context::new();
let output = tmpl.render_ctx(&ctx).unwrap();
assert_eq!(output, "Button(gray)");
}
#[test]
fn include_caller_overrides_default() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path();
std::fs::write(
base.join("widget.tmpl.md"),
r#"---
name: widget
params:
- label = str
- color = str := "gray"
---
{{ label }}({{ color }})"#,
)
.unwrap();
let main_src = r#"---
params: []
---
> {% include [widget](./widget.tmpl.md) with label="Button", color="red" %}"#;
let (tmpl, _) = Template::compile(main_src, CompileOptions::default().base_dir(base)).unwrap();
let ctx = Context::new();
let output = tmpl.render_ctx(&ctx).unwrap();
assert_eq!(output, "Button(red)");
}
#[test]
fn for_each_include_renders_each_item() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path();
std::fs::write(
base.join("item.tmpl.md"),
r"---
name: item
params: [thing = str]
---
- {{ thing }}",
)
.unwrap();
let main_src = r"---
params: [items = list(str)]
---
> {% include [item](./item.tmpl.md) for thing in items %}";
let (tmpl, _) = Template::compile(main_src, CompileOptions::default().base_dir(base)).unwrap();
let mut ctx = Context::new();
ctx.set(
"items",
Value::List(Arc::new(vec![
Value::Str("apple".into()),
Value::Str("banana".into()),
Value::Str("cherry".into()),
])),
);
let output = tmpl.render_ctx(&ctx).unwrap();
assert!(output.contains("- apple"), "got: {output}");
assert!(output.contains("- banana"), "got: {output}");
assert!(output.contains("- cherry"), "got: {output}");
}
#[test]
fn for_each_include_empty_list() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path();
std::fs::write(
base.join("item.tmpl.md"),
r"---
name: item
params: [thing = str]
---
- {{ thing }}",
)
.unwrap();
let main_src = r"---
params: [items = list(str)]
---
Before
> {% include [item](./item.tmpl.md) for thing in items %}
After";
let (tmpl, _) = Template::compile(main_src, CompileOptions::default().base_dir(base)).unwrap();
let mut ctx = Context::new();
ctx.set("items", Value::List(Arc::new(vec![])));
let output = tmpl.render_ctx(&ctx).unwrap();
assert!(output.contains("Before"), "got: {output}");
assert!(output.contains("After"), "got: {output}");
assert!(
!output.contains("- "),
"empty list should produce no item output, got: {output}"
);
}
#[test]
fn nested_includes_each_use_own_constants() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path();
std::fs::write(
base.join("leaf.tmpl.md"),
r#"---
name: leaf
consts:
- LEAF_TAG = str := "LEAF"
params: []
---
[{{ LEAF_TAG }}]"#,
)
.unwrap();
std::fs::write(
base.join("mid.tmpl.md"),
r#"---
name: mid
consts:
- MID_TAG = str := "MID"
params: []
---
[{{ MID_TAG }}]> {% include [leaf](./leaf.tmpl.md) %}"#,
)
.unwrap();
let main_src = r#"---
consts:
- TOP_TAG = str := "TOP"
params: []
---
[{{ TOP_TAG }}]> {% include [mid](./mid.tmpl.md) %}"#;
let (tmpl, _) = Template::compile(main_src, CompileOptions::default().base_dir(base)).unwrap();
let ctx = Context::new();
let output = tmpl.render_ctx(&ctx).unwrap();
assert!(output.contains("[TOP]"), "got: {output}");
assert!(output.contains("[MID]"), "got: {output}");
assert!(output.contains("[LEAF]"), "got: {output}");
}
#[test]
fn tmpl_param_carries_own_constants() {
let helper = Template::from_source(
r#"---
params: [name = str]
consts:
- PREFIX = str := "Dr."
---
{{ PREFIX }} {{ name }}"#,
)
.unwrap();
let main = Template::from_source(
r#"---
params: [formatter = tmpl(name = str)]
---
> {% include formatter with name="Smith" %}"#,
)
.unwrap();
let mut ctx = Context::new();
ctx.set("formatter", Value::Tmpl(Arc::new(helper)));
let output = main.render_ctx(&ctx).unwrap();
assert_eq!(output, "Dr. Smith");
}
#[test]
fn tmpl_param_constants_do_not_leak() {
let main_src = r"---
params: [h = tmpl()]
---
> {% include h %}
{{ HELPER_SECRET }}";
let result = Template::from_source(main_src);
assert!(
result.is_err(),
"tmpl param's constants should not be visible in parent scope"
);
}
#[test]
fn render_with_allow_extra() {
let tmpl = Template::from_source(
r"---
params: [name = str]
---
Hi {{ name }}",
)
.unwrap();
let mut ctx = Context::new();
ctx.set("name", "Alice");
ctx.set("extra_key", "should not error");
let output = tmpl.render_ctx_allowing_extra(&ctx).unwrap();
assert_eq!(output, "Hi Alice");
}
#[test]
fn render_strict_rejects_extra_keys() {
let tmpl = Template::from_source(
r"---
params: [name = str]
---
Hi {{ name }}",
)
.unwrap();
let mut ctx = Context::new();
ctx.set("name", "Alice");
ctx.set("extra_key", "boom");
let result = tmpl.render_ctx(&ctx);
assert!(
result.is_err(),
"strict mode should reject extra context keys"
);
}
#[test]
fn filter_on_constant() {
let tmpl = Template::from_source(
r#"---
consts:
- MSG = str := "hello world"
params: []
---
{{ MSG | upper }}"#,
)
.unwrap();
let ctx = Context::new();
let output = tmpl.render_ctx(&ctx).unwrap();
assert_eq!(output, "HELLO WORLD");
}
#[test]
fn filter_chain_on_variable() {
let tmpl = Template::from_source(
r"---
params: [msg = str]
---
{{ msg | trim | upper }}",
)
.unwrap();
let mut ctx = Context::new();
ctx.set("msg", " spaced ");
let output = tmpl.render_ctx(&ctx).unwrap();
assert_eq!(output, "SPACED");
}
#[test]
fn conditional_on_constant() {
let tmpl = Template::from_source(
r"---
consts:
- ENABLED = bool := true
params: []
---
> {% if ENABLED %}
ON
> {% /if %}",
)
.unwrap();
let ctx = Context::new();
let output = tmpl.render_ctx(&ctx).unwrap();
assert!(output.contains("ON"), "got: {output}");
}
#[test]
fn conditional_on_constant_false() {
let tmpl = Template::from_source(
r"---
consts:
- ENABLED = bool := false
params: []
---
> {% if ENABLED %}
ON
> {% else %}
OFF
> {% /if %}",
)
.unwrap();
let ctx = Context::new();
let output = tmpl.render_ctx(&ctx).unwrap();
assert!(output.contains("OFF"), "got: {output}");
assert!(!output.contains("ON"), "got: {output}");
}
#[test]
fn conditional_variable_vs_literal() {
let tmpl = Template::from_source(
r"---
params: [level = int]
---
> {% if level >= 5 %}
high
> {% else %}
low
> {% /if %}",
)
.unwrap();
let mut ctx = Context::new();
ctx.set("level", 10);
let output = tmpl.render_ctx(&ctx).unwrap();
assert!(output.contains("high"), "got: {output}");
let mut ctx2 = Context::new();
ctx2.set("level", 2);
let output2 = tmpl.render_ctx(&ctx2).unwrap();
assert!(output2.contains("low"), "got: {output2}");
}
#[test]
fn match_enum_renders_correct_arm() {
let tmpl = Template::from_source(
r"---
types:
- Status = enum(Active, Inactive)
params: [status = Status]
---
> {% match status %}
> {% case Active %}
running
> {% case Inactive %}
stopped
> {% /match %}",
)
.unwrap();
let mut ctx = Context::new();
ctx.set("status", "Active");
let output = tmpl.render_ctx(&ctx).unwrap();
assert!(output.contains("running"), "got: {output}");
assert!(!output.contains("stopped"), "got: {output}");
}
#[test]
fn inline_template_renders_correctly() {
let src = concat!(
r#"---
params: [name = str]
---
"#,
r#"> {% tmpl greeting %}
"#,
r#"---
"#,
r#"params: [who = str]
"#,
r#"---
"#,
r#"Hi {{ who }}!
"#,
r#"> {% /tmpl %}
"#,
"> {% include greeting with who=name %}",
);
let tmpl = Template::from_source(src).unwrap();
let mut ctx = Context::new();
ctx.set("name", "World");
let output = tmpl.render_ctx(&ctx).unwrap();
assert!(output.contains("Hi World!"), "got: {output}");
}
#[test]
fn inline_template_reusable() {
let src = concat!(
r#"---
params: []
---
"#,
r#"> {% tmpl tag %}
"#,
r#"---
"#,
r#"params: [label = str]
"#,
r#"---
"#,
r#"[{{ label }}]
"#,
r#"> {% /tmpl %}
"#,
r#"> {% include tag with label="A" %}
"#,
"> {% include tag with label=\"B\" %}",
);
let tmpl = Template::from_source(src).unwrap();
let ctx = Context::new();
let output = tmpl.render_ctx(&ctx).unwrap();
assert!(output.contains("[A]"), "got: {output}");
assert!(output.contains("[B]"), "got: {output}");
}
#[test]
fn cached_render_matches_direct_render() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path();
let src = r#"---
params: [x = str]
consts:
- TAG = str := "v1"
---
{{ TAG }}: {{ x }}"#;
std::fs::write(base.join("test.tmpl.md"), src).unwrap();
let cache = crate::TemplateCache::new();
let tmpl = cache.load(&base.join("test.tmpl.md")).unwrap();
let mut ctx = Context::new();
ctx.set("x", "hello");
let direct = tmpl.render_ctx(&ctx).unwrap();
let cached = tmpl.render_ctx_cached(&ctx, &cache).unwrap();
assert_eq!(direct, cached, "cached and direct render must match");
assert_eq!(direct, "v1: hello");
}
#[test]
fn render_into_appends_correctly() {
let tmpl = Template::from_source(
r"---
params: [x = str]
---
{{ x }}",
)
.unwrap();
let mut ctx = Context::new();
ctx.set("x", "world");
let mut buf = String::from("hello ");
tmpl.render_ctx_into(&ctx, &mut buf).unwrap();
assert_eq!(buf, "hello world");
}
#[test]
fn consts_only_template_renders() {
let tmpl = Template::from_source(
r#"---
consts:
- APP = str := "MyApp"
- VER = int := 2
params: []
---
{{ APP }} v{{ VER }}"#,
)
.unwrap();
let ctx = Context::new();
let output = tmpl.render_ctx(&ctx).unwrap();
assert_eq!(output, "MyApp v2");
}
#[test]
fn empty_body_renders_empty() {
let tmpl = Template::from_source(
r"---
params: []
---
",
)
.unwrap();
let ctx = Context::new();
let output = tmpl.render_ctx(&ctx).unwrap();
assert_eq!(output, "");
}
#[test]
fn frontmatter_only_renders_empty() {
let tmpl = Template::from_source(
r"---
params: []
---
",
)
.unwrap();
let ctx = Context::new();
let output = tmpl.render_ctx(&ctx).unwrap();
assert!(output.is_empty(), "got: {output:?}");
}
#[test]
fn dict_constant_dot_access() {
let tmpl = Template::from_source(
r#"---
consts:
- CFG = struct(host = str, port = int) := {host = "localhost", port = 8080}
params: []
---
{{ CFG.host }}:{{ CFG.port }}"#,
)
.unwrap();
let ctx = Context::new();
let output = tmpl.render_ctx(&ctx).unwrap();
assert_eq!(output, "localhost:8080");
}
#[test]
fn list_constant_join_filter() {
let tmpl = Template::from_source(
r#"---
consts:
- LANGS = list(str) := ["Rust", "Go", "Python"]
params: []
---
{{ LANGS | join(", ") }}"#,
)
.unwrap();
let ctx = Context::new();
let output = tmpl.render_ctx(&ctx).unwrap();
assert_eq!(output, "Rust, Go, Python");
}
#[test]
fn bool_constant_in_conditional() {
let tmpl = Template::from_source(
r"---
consts:
- DEBUG = bool := false
params: []
---
> {% if DEBUG %}
debug_on
> {% else %}
release
> {% /if %}",
)
.unwrap();
let ctx = Context::new();
let output = tmpl.render_ctx(&ctx).unwrap();
assert!(output.contains("release"), "got: {output}");
assert!(!output.contains("debug_on"), "got: {output}");
}
#[test]
fn imported_type_alias_in_params() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path();
std::fs::write(
base.join("types.tmpl.md"),
r"---
name: types
types:
- Priority = enum(High, Medium, Low)
---
",
)
.unwrap();
let main_src = r"---
imports:
- [types](./types.tmpl.md)
params: [p = types.Priority]
---
Priority: {{ kind(p) }}";
let (tmpl, _) = Template::compile(main_src, CompileOptions::default().base_dir(base)).unwrap();
let mut ctx = Context::new();
ctx.set("p", "High");
let output = tmpl.render_ctx(&ctx).unwrap();
assert_eq!(output, "Priority: High");
}
#[test]
fn constant_accessible_inside_for_loop() {
let tmpl = Template::from_source(
r#"---
consts:
- BULLET = str := "*"
params: [items = list(str)]
---
> {% for item in items %}
{{ BULLET }} {{ item }}
> {% /for %}"#,
)
.unwrap();
let mut ctx = Context::new();
ctx.set(
"items",
Value::List(Arc::new(vec![
Value::Str("a".into()),
Value::Str("b".into()),
])),
);
let output = tmpl.render_ctx(&ctx).unwrap();
assert!(output.contains("* a"), "got: {output}");
assert!(output.contains("* b"), "got: {output}");
}
#[test]
fn loop_variable_does_not_persist_after_loop() {
let tmpl = Template::from_source(
r"---
params: [items = list(str)]
---
> {% for item in items %}
{{ item }}
> {% /for %}
Done",
)
.unwrap();
let mut ctx = Context::new();
ctx.set("items", Value::List(Arc::new(vec![Value::Str("x".into())])));
let output = tmpl.render_ctx(&ctx).unwrap();
assert!(output.contains('x'), "got: {output}");
assert!(output.contains("Done"), "got: {output}");
}
#[test]
fn overlapping_param_names_isolated() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path();
std::fs::write(
base.join("a.tmpl.md"),
r"---
name: a
params: [val = str]
---
A={{ val }}",
)
.unwrap();
std::fs::write(
base.join("b.tmpl.md"),
r"---
name: b
params: [val = str]
---
B={{ val }}",
)
.unwrap();
let main_src = r#"---
params: []
---
> {% include [a](./a.tmpl.md) with val="first" %}
> {% include [b](./b.tmpl.md) with val="second" %}"#;
let (tmpl, _) = Template::compile(main_src, CompileOptions::default().base_dir(base)).unwrap();
let ctx = Context::new();
let output = tmpl.render_ctx(&ctx).unwrap();
assert!(output.contains("A=first"), "got: {output}");
assert!(output.contains("B=second"), "got: {output}");
assert!(
!output.contains("A=second"),
"val leaked from b to a, got: {output}"
);
assert!(
!output.contains("B=first"),
"val leaked from a to b, got: {output}"
);
}
#[test]
fn constant_wins_over_context() {
let tmpl = Template::from_source(
r#"---
consts:
- FIXED = str := "immutable"
params: []
---
{{ FIXED }}"#,
)
.unwrap();
let mut ctx = Context::new();
ctx.set("FIXED", "hacked");
let output = tmpl.render_ctx_allowing_extra(&ctx).unwrap();
assert_eq!(output, "immutable");
}
#[test]
fn include_uses_own_consts_and_passed_params() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path();
std::fs::write(
base.join("card.tmpl.md"),
r#"---
name: card
consts:
- BORDER = str := "===="
params: [title = str]
---
{{ BORDER }}
{{ title }}
{{ BORDER }}"#,
)
.unwrap();
let main_src = r#"---
params: []
---
> {% include [card](./card.tmpl.md) with title="Hello" %}"#;
let (tmpl, _) = Template::compile(main_src, CompileOptions::default().base_dir(base)).unwrap();
let ctx = Context::new();
let output = tmpl.render_ctx(&ctx).unwrap();
assert_eq!(
output,
r"====
Hello
===="
);
}
#[test]
fn for_each_include_with_constants() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path();
std::fs::write(
base.join("row.tmpl.md"),
r#"---
name: row
consts:
- PREFIX = str := ">"
params: [item = str]
---
{{ PREFIX }} {{ item }}"#,
)
.unwrap();
let main_src = r"---
params: [items = list(str)]
---
> {% include [row](./row.tmpl.md) for item in items %}";
let (tmpl, _) = Template::compile(main_src, CompileOptions::default().base_dir(base)).unwrap();
let mut ctx = Context::new();
ctx.set(
"items",
Value::List(Arc::new(vec![
Value::Str("alpha".into()),
Value::Str("beta".into()),
])),
);
let output = tmpl.render_ctx(&ctx).unwrap();
assert!(output.contains("> alpha"), "got: {output}");
assert!(output.contains("> beta"), "got: {output}");
}
#[test]
fn struct_param_extra_fields_accepted() {
let tmpl = Template::from_source(
r"---
params: [user = struct(name = str)]
---
Hello {{ user.name }}",
)
.unwrap();
let mut ctx = Context::new();
ctx.set(
"user",
Value::new_struct([
("name", Value::Str("Alice".into())),
("age", Value::Int(30)),
("email", Value::Str("alice@test.com".into())),
]),
);
let output = tmpl.render_ctx(&ctx).unwrap();
assert_eq!(output, "Hello Alice");
}
#[test]
fn list_items_extra_fields_accepted() {
let tmpl = Template::from_source(
r"---
params: [items = list(label = str)]
---
> {% for item in items %}
{{ item.label }}
> {% /for %}",
)
.unwrap();
let mut ctx = Context::new();
ctx.set(
"items",
Value::List(Arc::new(vec![
Value::new_struct([("label", Value::Str("A".into())), ("id", Value::Int(1))]),
Value::new_struct([
("label", Value::Str("B".into())),
("id", Value::Int(2)),
("extra", Value::Bool(true)),
]),
])),
);
let output = tmpl.render_ctx(&ctx).unwrap();
assert!(output.contains('A'), "got: {output}");
assert!(output.contains('B'), "got: {output}");
}
#[test]
fn type_alias_duck_typing() {
let tmpl = Template::from_source(
r"---
types:
- User = struct(name = str)
params: [user = User]
---
{{ user.name }}",
)
.unwrap();
let mut ctx = Context::new();
ctx.set(
"user",
Value::new_struct([
("name", Value::Str("Bob".into())),
("role", Value::Str("admin".into())),
]),
);
let output = tmpl.render_ctx(&ctx).unwrap();
assert_eq!(output, "Bob");
}
#[test]
fn nested_struct_extra_fields_at_every_depth() {
let tmpl = Template::from_source(
r"---
params: [config = struct(db = struct(host = str))]
---
{{ config.db.host }}",
)
.unwrap();
let mut ctx = Context::new();
ctx.set(
"config",
Value::new_struct([(
"db",
Value::new_struct([
("host", Value::Str("localhost".into())),
("port", Value::Int(5432)),
("pool_size", Value::Int(10)),
]),
)]),
);
let output = tmpl.render_ctx_allowing_extra(&ctx).unwrap();
assert_eq!(output, "localhost");
}
#[test]
fn include_with_partial_struct() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path();
std::fs::write(
base.join("greet_user.tmpl.md"),
r"---
name: greet_user
params: [user = struct(name = str)]
---
Hello {{ user.name }}!",
)
.unwrap();
let main_src = r"---
params: [user = struct(name = str, age = int, email = str)]
---
> {% include [greet_user](./greet_user.tmpl.md) with user=user %}
Age: {{ user.age }}";
let (tmpl, _) = Template::compile(main_src, CompileOptions::default().base_dir(base)).unwrap();
let mut ctx = Context::new();
ctx.set(
"user",
Value::new_struct([
("name", Value::Str("Alice".into())),
("age", Value::Int(30)),
("email", Value::Str("alice@test.com".into())),
]),
);
let output = tmpl.render_ctx(&ctx).unwrap();
assert!(
output.contains("Hello Alice!"),
"include should render with partial struct, got: {output}"
);
assert!(
output.contains("Age: 30"),
"parent should still access all fields, got: {output}"
);
}
#[test]
fn for_each_include_partial_struct_items() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path();
std::fs::write(
base.join("show_title.tmpl.md"),
r"---
name: show_title
params: [item = struct(title = str)]
---
- {{ item.title }}",
)
.unwrap();
let main_src = r"---
params: [items = list(title = str, description = str, priority = int)]
---
> {% include [show_title](./show_title.tmpl.md) for item in items %}";
let (tmpl, _) = Template::compile(main_src, CompileOptions::default().base_dir(base)).unwrap();
let mut ctx = Context::new();
ctx.set(
"items",
Value::List(Arc::new(vec![
Value::new_struct([
("title", Value::Str("Fix bug".into())),
("description", Value::Str("Crash on startup".into())),
("priority", Value::Int(1)),
]),
Value::new_struct([
("title", Value::Str("Add tests".into())),
("description", Value::Str("Coverage is low".into())),
("priority", Value::Int(3)),
]),
])),
);
let output = tmpl.render_ctx(&ctx).unwrap();
assert!(
output.contains("- Fix bug"),
"child should render title from partial struct, got: {output}"
);
assert!(
output.contains("- Add tests"),
"second item should also render, got: {output}"
);
}
#[test]
fn nested_include_chain_partial_structs() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path();
std::fs::write(
base.join("name_tag.tmpl.md"),
r"---
name: name_tag
params: [person = struct(name = str)]
---
[{{ person.name }}]",
)
.unwrap();
std::fs::write(
base.join("badge.tmpl.md"),
r"---
name: badge
params: [person = struct(name = str, role = str)]
---
> {% include [name_tag](./name_tag.tmpl.md) with person=person %}
({{ person.role }})",
)
.unwrap();
let main_src = r"---
params: [person = struct(name = str, role = str, id = int)]
---
> {% include [badge](./badge.tmpl.md) with person=person %}
ID: {{ person.id }}";
let (tmpl, _) = Template::compile(main_src, CompileOptions::default().base_dir(base)).unwrap();
let mut ctx = Context::new();
ctx.set(
"person",
Value::new_struct([
("name", Value::Str("Alice".into())),
("role", Value::Str("Engineer".into())),
("id", Value::Int(42)),
]),
);
let output = tmpl.render_ctx(&ctx).unwrap();
assert!(
output.contains("[Alice]"),
"grandchild should render name, got: {output}"
);
assert!(
output.contains("(Engineer)"),
"child should render role, got: {output}"
);
assert!(
output.contains("ID: 42"),
"parent should access id, got: {output}"
);
}