use serde_json::Value;
pub const MISSING_PLACEHOLDER: &str = "<missing>";
pub fn render_template(template: &str, context: &Value) -> String {
let mut out = String::with_capacity(template.len());
let bytes = template.as_bytes();
let mut i = 0;
while i < bytes.len() {
if i + 1 < bytes.len() && bytes[i] == b'{' && bytes[i + 1] == b'{' {
if let Some(end) = find_close(bytes, i + 2) {
let raw = &template[i + 2..end];
let path = raw.trim();
if path.is_empty() {
out.push_str(&template[i..end + 2]);
} else {
out.push_str(&resolve_path(context, path));
}
i = end + 2;
continue;
}
}
out.push(template[i..].chars().next().unwrap());
i += template[i..].chars().next().unwrap().len_utf8();
}
out
}
fn resolve_path(context: &Value, path: &str) -> String {
let mut current = context;
for segment in path.split('.') {
if segment.is_empty() {
return MISSING_PLACEHOLDER.to_string();
}
current = match (current, segment.parse::<usize>()) {
(Value::Object(map), _) => match map.get(segment) {
Some(v) => v,
None => return MISSING_PLACEHOLDER.to_string(),
},
(Value::Array(items), Ok(idx)) => match items.get(idx) {
Some(v) => v,
None => return MISSING_PLACEHOLDER.to_string(),
},
_ => return MISSING_PLACEHOLDER.to_string(),
};
}
match current {
Value::String(s) => s.clone(),
Value::Null => MISSING_PLACEHOLDER.to_string(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
Value::Object(_) | Value::Array(_) => MISSING_PLACEHOLDER.to_string(),
}
}
fn find_close(bytes: &[u8], start: usize) -> Option<usize> {
let mut i = start;
while i + 1 < bytes.len() {
if bytes[i] == b'}' && bytes[i + 1] == b'}' {
return Some(i);
}
i += 1;
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn literal_passthrough_no_placeholders() {
let ctx = serde_json::json!({});
assert_eq!(render_template("hello world", &ctx), "hello world");
}
#[test]
fn single_substitution() {
let ctx = serde_json::json!({ "name": "ana" });
assert_eq!(render_template("hi {{name}}", &ctx), "hi ana");
}
#[test]
fn deep_path() {
let ctx = serde_json::json!({
"body_json": {
"repository": { "full_name": "anthropic/repo" }
}
});
let out = render_template("repo: {{body_json.repository.full_name}}", &ctx);
assert_eq!(out, "repo: anthropic/repo");
}
#[test]
fn missing_path_renders_placeholder() {
let ctx = serde_json::json!({ "body": {} });
assert_eq!(
render_template("nope: {{body.does.not.exist}}", &ctx),
"nope: <missing>"
);
}
#[test]
fn array_index_path() {
let ctx = serde_json::json!({
"tags": ["alpha", "beta", "gamma"]
});
assert_eq!(render_template("first: {{tags.0}}", &ctx), "first: alpha");
assert_eq!(render_template("third: {{tags.2}}", &ctx), "third: gamma");
assert_eq!(render_template("oob: {{tags.99}}", &ctx), "oob: <missing>");
}
#[test]
fn null_leaf_renders_placeholder() {
let ctx = serde_json::json!({ "value": null });
assert_eq!(render_template("v={{value}}", &ctx), "v=<missing>");
}
#[test]
fn unmatched_braces_preserved_literal() {
let ctx = serde_json::json!({});
assert_eq!(
render_template("text {{ never closes", &ctx),
"text {{ never closes"
);
}
#[test]
fn empty_template_returns_empty() {
let ctx = serde_json::json!({});
assert_eq!(render_template("", &ctx), "");
}
#[test]
fn empty_braces_preserved_verbatim() {
let ctx = serde_json::json!({ "x": "y" });
assert_eq!(render_template("a {{ }} b", &ctx), "a {{ }} b");
}
#[test]
fn number_and_bool_leaves_render() {
let ctx = serde_json::json!({
"count": 42,
"enabled": true
});
assert_eq!(
render_template("c={{count}} e={{enabled}}", &ctx),
"c=42 e=true"
);
}
#[test]
fn object_leaf_renders_placeholder_not_json() {
let ctx = serde_json::json!({ "obj": { "a": 1 } });
assert_eq!(render_template("o={{obj}}", &ctx), "o=<missing>");
}
}