use minijinja::{Environment, UndefinedBehavior, Value};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum RenderError {
#[error("template render failed: {0}")]
Template(String),
}
impl From<minijinja::Error> for RenderError {
fn from(e: minijinja::Error) -> Self {
RenderError::Template(format!("{e:#}"))
}
}
pub fn render_system(template: &str, slots: &serde_json::Value) -> Result<String, RenderError> {
let mut env = Environment::new();
env.set_auto_escape_callback(|_| minijinja::AutoEscape::None);
env.set_undefined_behavior(UndefinedBehavior::Strict);
let tmpl = env.template_from_str(template)?;
let value = Value::from_serialize(slots);
let rendered = if let serde_json::Value::Object(_) = slots {
tmpl.render(value)?
} else {
tmpl.render(minijinja::context! { value => value })?
};
Ok(rendered)
}
pub fn slots_from_prompt(prompt: &str) -> serde_json::Value {
match serde_json::from_str::<serde_json::Value>(prompt) {
Ok(v @ serde_json::Value::Object(_)) => v,
_ => serde_json::json!({ "directive": prompt }),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn expands_simple_variable() {
let out = render_system("hello {{ directive }}", &json!({ "directive": "world" }))
.expect("render ok");
assert_eq!(out, "hello world");
}
#[test]
fn supports_if_branch() {
let tmpl = "{% if intent %}intent={{ intent }}{% else %}no-intent{% endif %}";
let with = render_system(tmpl, &json!({ "intent": "fix-bug" })).unwrap();
assert_eq!(with, "intent=fix-bug");
let without = render_system(tmpl, &json!({ "intent": null })).unwrap();
assert_eq!(without, "no-intent");
}
#[test]
fn supports_filter() {
let out = render_system("{{ name | upper }}", &json!({ "name": "shi" })).unwrap();
assert_eq!(out, "SHI");
}
#[test]
fn undefined_variable_errors_strict() {
let err = render_system("hello {{ missing }}", &json!({ "directive": "x" }))
.expect_err("strict undef must fail");
let msg = format!("{err}");
assert!(
msg.contains("undefined") || msg.contains("missing"),
"expected strict undef error, got: {msg}"
);
}
#[test]
fn syntax_error_returns_err() {
let err = render_system("hello {{ unclosed", &json!({})).expect_err("syntax error");
let msg = format!("{err}");
assert!(
msg.contains("syntax") || msg.contains("unexpected"),
"got: {msg}"
);
}
#[test]
fn html_chars_not_escaped() {
let out = render_system("{{ snippet }}", &json!({ "snippet": "<tag>&" })).unwrap();
assert_eq!(out, "<tag>&");
}
#[test]
fn supports_for_loop() {
let tmpl = "{% for x in xs %}{{ x }},{% endfor %}";
let out = render_system(tmpl, &json!({ "xs": ["a", "b", "c"] })).unwrap();
assert_eq!(out, "a,b,c,");
}
#[test]
fn slots_from_prompt_object() {
let v = slots_from_prompt(r#"{"directive":"do-X","intent":"fix"}"#);
assert_eq!(v["directive"], "do-X");
assert_eq!(v["intent"], "fix");
}
#[test]
fn slots_from_prompt_plain_string() {
let v = slots_from_prompt("just a plain instruction");
assert_eq!(v["directive"], "just a plain instruction");
}
#[test]
fn slots_from_prompt_json_array_falls_back_to_directive() {
let v = slots_from_prompt(r#"["a","b"]"#);
assert_eq!(v["directive"], r#"["a","b"]"#);
}
}