use minijinja::{Environment, UndefinedBehavior, Value};
use std::collections::BTreeSet;
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:#}"))
}
}
fn build_env() -> Environment<'static> {
let mut env = Environment::new();
env.set_auto_escape_callback(|_| minijinja::AutoEscape::None);
env.set_undefined_behavior(UndefinedBehavior::Strict);
env
}
pub fn render_system(template: &str, slots: &serde_json::Value) -> Result<String, RenderError> {
let env = build_env();
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: &serde_json::Value) -> serde_json::Value {
match prompt {
v @ serde_json::Value::Object(_) => v.clone(),
other => serde_json::json!({ "directive": other }),
}
}
pub fn template_variables(template: &str) -> Result<BTreeSet<String>, RenderError> {
let env = build_env();
let tmpl = env.template_from_str(template)?;
Ok(tmpl.undeclared_variables(false).into_iter().collect())
}
#[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(&json!({"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(&json!("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(&json!(["a", "b"]));
assert_eq!(v["directive"], json!(["a", "b"]));
}
#[test]
fn template_variables_lists_two_undeclared_vars() {
let vars = template_variables("hello {{ directive }}, mode={{ mode }}").expect("parse ok");
let expected: BTreeSet<String> = ["directive", "mode"]
.into_iter()
.map(String::from)
.collect();
assert_eq!(vars, expected);
}
#[test]
fn template_variables_no_vars_is_empty() {
let vars = template_variables("hello, world").expect("parse ok");
assert!(vars.is_empty());
}
#[test]
fn template_variables_syntax_error_is_err() {
let err = template_variables("hello {{ unclosed").expect_err("syntax error");
let msg = format!("{err}");
assert!(
msg.contains("syntax") || msg.contains("unexpected"),
"got: {msg}"
);
}
#[test]
fn template_variables_for_loop_bound_var_not_enumerated() {
let vars =
template_variables("{% for x in items %}{{ x }},{% endfor %}").expect("parse ok");
let expected: BTreeSet<String> = ["items"].into_iter().map(String::from).collect();
assert_eq!(vars, expected);
}
}