use std::collections::BTreeMap;
use std::rc::Rc;
use super::*;
fn dict(pairs: &[(&str, VmValue)]) -> BTreeMap<String, VmValue> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.clone()))
.collect()
}
fn s(v: &str) -> VmValue {
VmValue::String(Rc::from(v))
}
fn render(tpl: &str, b: &BTreeMap<String, VmValue>) -> String {
render_template_result(tpl, Some(b), None, None).unwrap()
}
fn render_with_spans(tpl: &str, b: &BTreeMap<String, VmValue>) -> (String, Vec<PromptSourceSpan>) {
render_template_with_provenance(tpl, Some(b), None, None, true).unwrap()
}
#[test]
fn bare_interp() {
let b = dict(&[("name", s("Alice"))]);
assert_eq!(render("hi {{name}}!", &b), "hi Alice!");
}
#[test]
fn provenance_expr_span_matches_output_range() {
let mut user = BTreeMap::new();
user.insert("name".to_string(), s("alice"));
let b = dict(&[
("user", VmValue::Dict(Rc::new(user))),
("count", VmValue::Int(42)),
]);
let (out, spans) = render_with_spans("hello {{ user.name }} ({{ count | default: 0 }})", &b);
assert_eq!(out, "hello alice (42)");
let expr_spans: Vec<_> = spans
.iter()
.filter(|s| s.kind == PromptSpanKind::Expr)
.collect();
assert_eq!(expr_spans.len(), 2);
let user_span = expr_spans
.iter()
.find(|s| &out[s.output_start..s.output_end] == "alice")
.expect("user expr span");
assert!(user_span.template_line >= 1);
assert_eq!(user_span.bound_value.as_deref(), Some("alice"));
let count_span = expr_spans
.iter()
.find(|s| &out[s.output_start..s.output_end] == "42")
.expect("count expr span");
assert_eq!(count_span.bound_value.as_deref(), Some("42"));
}
#[test]
fn provenance_legacy_bare_interp_span_tracked() {
let b = dict(&[("name", s("Alice"))]);
let (out, spans) = render_with_spans("hi {{name}}!", &b);
assert_eq!(out, "hi Alice!");
let bare = spans
.iter()
.find(|s| s.kind == PromptSpanKind::LegacyBareInterp)
.expect("legacy bare span");
assert_eq!(&out[bare.output_start..bare.output_end], "Alice");
assert_eq!(bare.bound_value.as_deref(), Some("Alice"));
}
#[test]
fn provenance_includes_loop_iterations() {
let b = dict(&[(
"items",
VmValue::List(Rc::new(vec![s("a"), s("b"), s("c")])),
)]);
let tpl = "{{for x in items}}[{{x}}]{{end}}";
let (out, spans) = render_with_spans(tpl, &b);
assert_eq!(out, "[a][b][c]");
let iter_spans: Vec<_> = spans
.iter()
.filter(|s| s.kind == PromptSpanKind::ForIteration)
.collect();
assert_eq!(iter_spans.len(), 3);
let slices: Vec<&str> = iter_spans
.iter()
.map(|s| &out[s.output_start..s.output_end])
.collect();
assert_eq!(slices, ["[a]", "[b]", "[c]"]);
}
#[test]
fn provenance_preview_is_truncated() {
let mut wrap = BTreeMap::new();
wrap.insert("val".to_string(), s(&"x".repeat(500)));
let b = dict(&[("blob", VmValue::Dict(Rc::new(wrap)))]);
let (_, spans) = render_with_spans("{{blob.val}}", &b);
let expr = spans
.iter()
.find(|s| s.kind == PromptSpanKind::Expr)
.expect("expr span");
let preview = expr.bound_value.as_deref().unwrap();
assert!(preview.chars().count() <= 80, "preview too long: {preview}");
assert!(preview.ends_with('…'));
}
#[test]
fn provenance_off_returns_empty_spans() {
let b = dict(&[("x", s("y"))]);
let (_, spans) = render_template_with_provenance("{{x}}", Some(&b), None, None, false).unwrap();
assert!(spans.is_empty());
}
#[test]
fn bare_interp_missing_passthrough() {
let b = dict(&[]);
assert_eq!(render("hi {{name}}!", &b), "hi {{name}}!");
}
#[test]
fn legacy_if_truthy() {
let b = dict(&[("x", VmValue::Bool(true))]);
assert_eq!(render("{{if x}}yes{{end}}", &b), "yes");
}
#[test]
fn legacy_if_falsey() {
let b = dict(&[("x", VmValue::Bool(false))]);
assert_eq!(render("{{if x}}yes{{end}}", &b), "");
}
#[test]
fn if_else() {
let b = dict(&[("x", VmValue::Bool(false))]);
assert_eq!(render("{{if x}}A{{else}}B{{end}}", &b), "B");
}
#[test]
fn if_elif_else() {
let b = dict(&[("n", VmValue::Int(2))]);
let tpl = "{{if n == 1}}one{{elif n == 2}}two{{elif n == 3}}three{{else}}many{{end}}";
assert_eq!(render(tpl, &b), "two");
}
#[test]
fn for_loop_basic() {
let items = VmValue::List(Rc::new(vec![s("a"), s("b"), s("c")]));
let b = dict(&[("xs", items)]);
assert_eq!(render("{{for x in xs}}{{x}},{{end}}", &b), "a,b,c,");
}
#[test]
fn for_loop_vars() {
let items = VmValue::List(Rc::new(vec![s("a"), s("b")]));
let b = dict(&[("xs", items)]);
let tpl = "{{for x in xs}}{{loop.index}}:{{x}}{{if !loop.last}},{{end}}{{end}}";
assert_eq!(render(tpl, &b), "1:a,2:b");
}
#[test]
fn for_empty_else() {
let b = dict(&[("xs", VmValue::List(Rc::new(vec![])))]);
assert_eq!(render("{{for x in xs}}A{{else}}empty{{end}}", &b), "empty");
}
#[test]
fn for_dict_kv() {
let mut d: BTreeMap<String, VmValue> = BTreeMap::new();
d.insert("a".into(), VmValue::Int(1));
d.insert("b".into(), VmValue::Int(2));
let b = dict(&[("m", VmValue::Dict(Rc::new(d)))]);
assert_eq!(
render("{{for k, v in m}}{{k}}={{v}};{{end}}", &b),
"a=1;b=2;"
);
}
#[test]
fn nested_path() {
let mut inner: BTreeMap<String, VmValue> = BTreeMap::new();
inner.insert("name".into(), s("Alice"));
let b = dict(&[("user", VmValue::Dict(Rc::new(inner)))]);
assert_eq!(render("{{user.name}}", &b), "Alice");
}
#[test]
fn list_index() {
let b = dict(&[("xs", VmValue::List(Rc::new(vec![s("a"), s("b"), s("c")])))]);
assert_eq!(render("{{xs[1]}}", &b), "b");
}
#[test]
fn filter_upper() {
let b = dict(&[("n", s("alice"))]);
assert_eq!(render("{{n | upper}}", &b), "ALICE");
}
#[test]
fn filter_default() {
let b = dict(&[("n", s(""))]);
assert_eq!(render("{{n | default: \"anon\"}}", &b), "anon");
}
#[test]
fn filter_join() {
let b = dict(&[("xs", VmValue::List(Rc::new(vec![s("a"), s("b")])))]);
assert_eq!(render("{{xs | join: \", \"}}", &b), "a, b");
}
#[test]
fn comparison_ops() {
let b = dict(&[("n", VmValue::Int(5))]);
assert_eq!(render("{{if n > 3}}big{{end}}", &b), "big");
assert_eq!(render("{{if n >= 5 and n < 10}}ok{{end}}", &b), "ok");
}
#[test]
fn bool_not() {
let b = dict(&[("x", VmValue::Bool(false))]);
assert_eq!(render("{{if not x}}yes{{end}}", &b), "yes");
assert_eq!(render("{{if !x}}yes{{end}}", &b), "yes");
}
#[test]
fn raw_block() {
let b = dict(&[]);
assert_eq!(
render("A {{ raw }}{{not-a-directive}}{{ endraw }} B", &b),
"A {{not-a-directive}} B"
);
}
#[test]
fn comment_stripped() {
let b = dict(&[("x", s("hi"))]);
assert_eq!(render("A{{# hidden #}}B{{x}}", &b), "ABhi");
}
#[test]
fn whitespace_trim() {
let b = dict(&[("x", s("v"))]);
let tpl = "line1\n {{- x -}} \nline2";
assert_eq!(render(tpl, &b), "line1vline2");
}
#[test]
fn filter_json() {
let b = dict(&[(
"x",
VmValue::Dict(Rc::new({
let mut m = BTreeMap::new();
m.insert("a".into(), VmValue::Int(1));
m
})),
)]);
assert_eq!(render("{{x | json}}", &b), r#"{"a":1}"#);
}
#[test]
fn error_unterminated_if() {
let b = dict(&[("x", VmValue::Bool(true))]);
let r = render_template_result("{{if x}}open", Some(&b), None, None);
assert!(r.is_err());
}
#[test]
fn error_unknown_filter() {
let b = dict(&[("x", s("a"))]);
let r = render_template_result("{{x | bogus}}", Some(&b), None, None);
assert!(r.is_err());
}
#[test]
fn include_with() {
use std::fs;
let dir = tempdir();
let partial = dir.path().join("p.prompt");
fs::write(&partial, "[{{name}}]").unwrap();
let parent = dir.path().join("main.prompt");
fs::write(
&parent,
r#"hello {{ include "p.prompt" with { name: who } }}!"#,
)
.unwrap();
let b = dict(&[("who", s("world"))]);
let src = fs::read_to_string(&parent).unwrap();
let out = render_template_result(&src, Some(&b), Some(dir.path()), Some(&parent)).unwrap();
assert_eq!(out, "hello [world]!");
}
#[test]
fn stdlib_prompt_asset_renders_and_includes_embedded_partial() {
let asset =
TemplateAsset::render_target("std/agent/prompts/tool_contract_text.harn.prompt").unwrap();
let out = render_asset_result(&asset, Some(&dict(&[]))).unwrap();
assert!(out.contains("## Tool Calling Contract"));
assert!(out.contains("## Available tools"));
}
#[test]
fn stdlib_prompt_provenance_uses_stable_template_uris() {
let asset =
TemplateAsset::render_target("std/agent/prompts/tool_contract_text.harn.prompt").unwrap();
let bindings = dict(&[
("mode", s("text")),
("text_response_protocol", s("rendered response protocol")),
("expanded_schemas", s("rendered schemas")),
]);
let (out, spans) = render_asset_with_provenance_result(&asset, Some(&bindings), true).unwrap();
assert!(out.contains("## Tool Calling Contract"));
assert!(out.contains("rendered response protocol"));
assert!(spans
.iter()
.any(|span| span.template_uri == "std://agent/prompts/tool_contract_text.harn.prompt"));
assert!(spans
.iter()
.any(|span| span.bound_value.as_deref() == Some("rendered response protocol")));
}
#[test]
fn stdlib_template_cache_reuses_parsed_asset() {
super::assets::reset_template_cache();
let asset = TemplateAsset::render_target("std/workflow/prompts/stage.harn.prompt").unwrap();
let bindings = dict(&[("name", s("triage")), ("goal", s("Sort the queue."))]);
let first = render_asset_result(&asset, Some(&bindings)).unwrap();
let count_after_first = super::assets::template_cache_len();
let second = render_asset_result(&asset, Some(&bindings)).unwrap();
assert_eq!(first, second);
assert_eq!(count_after_first, super::assets::template_cache_len());
}
#[test]
fn filesystem_template_cache_invalidates_when_contents_change() {
use std::fs;
super::assets::reset_template_cache();
let dir = tempdir();
let path = dir.path().join("main.prompt");
fs::write(&path, "one {{x}}").unwrap();
let first = TemplateAsset::render_target(path.to_str().unwrap()).unwrap();
assert_eq!(
render_asset_result(&first, Some(&dict(&[("x", s("render"))]))).unwrap(),
"one render"
);
fs::write(&path, "two {{x}}").unwrap();
let second = TemplateAsset::render_target(path.to_str().unwrap()).unwrap();
assert_eq!(
render_asset_result(&second, Some(&dict(&[("x", s("render"))]))).unwrap(),
"two render"
);
assert_eq!(super::assets::template_cache_len(), 2);
}
#[test]
fn package_root_include_still_resolves() {
use std::fs;
let dir = tempdir();
fs::write(dir.path().join("harn.toml"), "[package]\nname = \"x\"\n").unwrap();
fs::create_dir_all(dir.path().join("prompts")).unwrap();
fs::write(
dir.path().join("prompts/partial.harn.prompt"),
"ROOT:{{name}}",
)
.unwrap();
let parent = dir.path().join("main.prompt");
fs::write(&parent, r#"{{ include "@/prompts/partial.harn.prompt" }}"#).unwrap();
let src = fs::read_to_string(&parent).unwrap();
let out = render_template_result(
&src,
Some(&dict(&[("name", s("ok"))])),
Some(dir.path()),
Some(&parent),
)
.unwrap();
assert_eq!(out, "ROOT:ok");
}
#[test]
fn prompt_render_indices_accumulate_in_order() {
reset_prompt_registry();
record_prompt_render_index("p-1", 5);
record_prompt_render_index("p-1", 9);
record_prompt_render_index("p-2", 7);
let p1 = prompt_render_indices("p-1");
assert_eq!(p1, vec![5, 9]);
let p2 = prompt_render_indices("p-2");
assert_eq!(p2, vec![7]);
assert!(prompt_render_indices("unknown").is_empty());
reset_prompt_registry();
assert!(
prompt_render_indices("p-1").is_empty(),
"reset clears the map"
);
}
#[test]
fn include_propagates_parent_span_chain() {
use std::fs;
let dir = tempdir();
let leaf = dir.path().join("leaf.prompt");
fs::write(&leaf, "LEAF:{{v}}").unwrap();
let mid = dir.path().join("mid.prompt");
fs::write(&mid, r#"MID:{{ include "leaf.prompt" }}"#).unwrap();
let top = dir.path().join("top.prompt");
fs::write(&top, r#"TOP:{{ include "mid.prompt" }}"#).unwrap();
let b = dict(&[("v", s("ok"))]);
let src = fs::read_to_string(&top).unwrap();
let (rendered, spans) =
render_template_with_provenance(&src, Some(&b), Some(dir.path()), Some(&top), true)
.unwrap();
assert_eq!(rendered, "TOP:MID:LEAF:ok");
let leaf_expr = spans
.iter()
.find(|s| {
matches!(
s.kind,
PromptSpanKind::Expr | PromptSpanKind::LegacyBareInterp
) && s.parent_span.is_some()
})
.expect("interpolation span emitted");
let mid_parent = leaf_expr
.parent_span
.as_deref()
.expect("leaf span must have mid's include as parent");
assert_eq!(mid_parent.kind, PromptSpanKind::Include);
let top_parent = mid_parent
.parent_span
.as_deref()
.expect("mid's include must chain up to top's include");
assert_eq!(top_parent.kind, PromptSpanKind::Include);
assert!(top_parent.parent_span.is_none(), "chain bottoms out at top");
assert!(leaf_expr.template_uri.ends_with("leaf.prompt"));
assert!(mid_parent.template_uri.ends_with("mid.prompt"));
assert!(top_parent.template_uri.ends_with("top.prompt"));
}
#[test]
fn include_cycle_detected() {
use std::fs;
let dir = tempdir();
let a = dir.path().join("a.prompt");
let b = dir.path().join("b.prompt");
fs::write(&a, r#"A{{ include "b.prompt" }}"#).unwrap();
fs::write(&b, r#"B{{ include "a.prompt" }}"#).unwrap();
let src = fs::read_to_string(&a).unwrap();
let r = render_template_result(&src, None, Some(dir.path()), Some(&a));
assert!(r.is_err());
assert!(r.unwrap_err().kind.contains("circular include"));
}
#[test]
fn include_cannot_escape_template_root() {
use std::fs;
let dir = tempdir();
let sibling_name = format!(
"{}-outside",
dir.path().file_name().unwrap().to_string_lossy()
);
let outside_dir = dir.path().parent().unwrap().join(&sibling_name);
fs::create_dir_all(&outside_dir).unwrap();
fs::write(outside_dir.join("secret.prompt"), "secret").unwrap();
let parent = dir.path().join("main.prompt");
fs::write(
&parent,
format!(r#"{{{{ include "../{sibling_name}/secret.prompt" }}}}"#),
)
.unwrap();
let src = fs::read_to_string(&parent).unwrap();
let r = render_template_result(&src, None, Some(dir.path()), Some(&parent));
let _ = fs::remove_dir_all(outside_dir);
assert!(r.is_err());
assert!(r.unwrap_err().kind.contains("escapes template root"));
}
fn tempdir() -> tempfile::TempDir {
tempfile::tempdir().unwrap()
}