use std::collections::HashMap;
#[derive(Debug, thiserror::Error)]
pub enum TemplateError {
#[error("undefined variable: `{0}`")]
UndefinedVariable(String),
#[error("mismatched block tag: `{0}`")]
MismatchedBlock(String),
#[error("unclosed block: `{0}`")]
UnclosedBlock(String),
}
#[derive(Debug, Clone)]
pub struct TemplateVar {
pub name: String,
pub value: String,
pub description: Option<String>,
}
impl TemplateVar {
pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
Self { name: name.into(), value: value.into(), description: None }
}
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
self.description = Some(desc.into());
self
}
}
#[derive(Debug, Clone)]
pub struct Template {
pub raw: String,
}
impl Template {
pub fn new(raw: impl Into<String>) -> Self {
Self { raw: raw.into() }
}
}
pub struct TemplateEngine;
impl TemplateEngine {
pub fn render(
template: &Template,
vars: &HashMap<String, String>,
) -> Result<String, TemplateError> {
Self::render_str(&template.raw, vars)
}
fn render_str(
src: &str,
vars: &HashMap<String, String>,
) -> Result<String, TemplateError> {
let mut out = String::with_capacity(src.len());
let mut rest = src;
while !rest.is_empty() {
if let Some(open) = rest.find("{{") {
out.push_str(&rest[..open]);
rest = &rest[open + 2..];
let close = rest
.find("}}")
.ok_or_else(|| TemplateError::UnclosedBlock("{{".into()))?;
let tag = rest[..close].trim();
rest = &rest[close + 2..];
if let Some(var_name) = tag.strip_prefix("#if ") {
let var_name = var_name.trim();
let (inner, after) = Self::extract_block(rest, "if", var_name)?;
let truthy = vars
.get(var_name)
.map(|v| !v.is_empty() && v != "false" && v != "0")
.unwrap_or(false);
if truthy {
let inner_rendered = Self::render_str(inner, vars)?;
out.push_str(&inner_rendered);
}
rest = after;
} else if let Some(list_name) = tag.strip_prefix("#each ") {
let list_name = list_name.trim();
let (inner, after) = Self::extract_block(rest, "each", list_name)?;
if let Some(list_val) = vars.get(list_name) {
for item in list_val.split(',') {
let item = item.trim();
let mut item_vars = vars.clone();
item_vars.insert("item".into(), item.into());
let rendered = Self::render_str(inner, &item_vars)?;
out.push_str(&rendered);
}
}
rest = after;
} else if tag.starts_with('/') {
return Err(TemplateError::MismatchedBlock(tag.into()));
} else {
let value = vars
.get(tag)
.ok_or_else(|| TemplateError::UndefinedVariable(tag.into()))?;
out.push_str(value);
}
} else {
out.push_str(rest);
break;
}
}
Ok(out)
}
fn extract_block<'a>(
src: &'a str,
block_type: &str,
block_name: &str,
) -> Result<(&'a str, &'a str), TemplateError> {
let open_pat = format!("{{{{#{} ", block_type);
let close_pat = format!("{{{{/{}}}}}", block_type);
let mut depth: usize = 1;
let mut pos = 0;
while pos < src.len() {
let remaining = &src[pos..];
let next_open = remaining.find(open_pat.as_str());
let next_close = remaining.find(close_pat.as_str());
match (next_open, next_close) {
(_, None) => {
return Err(TemplateError::UnclosedBlock(format!(
"#{}…/{}",
block_name, block_type
)));
}
(Some(no), Some(nc)) if no < nc => {
depth += 1;
pos += no + open_pat.len();
}
(_, Some(nc)) => {
depth -= 1;
if depth == 0 {
let inner = &src[..pos + nc];
let after = &src[pos + nc + close_pat.len()..];
return Ok((inner, after));
}
pos += nc + close_pat.len();
}
}
}
Err(TemplateError::UnclosedBlock(format!("#{}…/{}", block_name, block_type)))
}
pub fn validate(template: &Template) -> Vec<String> {
let mut vars = Vec::new();
let mut rest: &str = &template.raw;
while let Some(open) = rest.find("{{") {
rest = &rest[open + 2..];
if let Some(close) = rest.find("}}") {
let tag = rest[..close].trim();
rest = &rest[close + 2..];
if tag.starts_with('#') || tag.starts_with('/') {
continue;
}
if !vars.contains(&tag.to_string()) {
vars.push(tag.to_string());
}
} else {
break;
}
}
vars
}
}
pub struct TemplateLibrary {
templates: HashMap<String, Template>,
}
impl TemplateLibrary {
pub fn new() -> Self {
Self { templates: HashMap::new() }
}
pub fn register(&mut self, name: impl Into<String>, template: Template) {
self.templates.insert(name.into(), template);
}
pub fn get(&self, name: &str) -> Option<&Template> {
self.templates.get(name)
}
pub fn names(&self) -> Vec<&str> {
self.templates.keys().map(String::as_str).collect()
}
pub fn len(&self) -> usize {
self.templates.len()
}
pub fn is_empty(&self) -> bool {
self.templates.is_empty()
}
}
impl Default for TemplateLibrary {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn vars(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
}
#[test]
fn simple_substitution() {
let t = Template::new("Hello, {{name}}!");
let v = vars(&[("name", "World")]);
let out = TemplateEngine::render(&t, &v).unwrap();
assert_eq!(out, "Hello, World!");
}
#[test]
fn multiple_substitutions() {
let t = Template::new("{{greeting}}, {{name}}. You have {{count}} messages.");
let v = vars(&[("greeting", "Hi"), ("name", "Alice"), ("count", "3")]);
let out = TemplateEngine::render(&t, &v).unwrap();
assert_eq!(out, "Hi, Alice. You have 3 messages.");
}
#[test]
fn undefined_variable_error() {
let t = Template::new("Hello {{missing}}");
let v = vars(&[]);
assert!(matches!(
TemplateEngine::render(&t, &v),
Err(TemplateError::UndefinedVariable(_))
));
}
#[test]
fn if_block_truthy() {
let t = Template::new("Before{{#if show}} VISIBLE{{/if}} After");
let v = vars(&[("show", "true")]);
let out = TemplateEngine::render(&t, &v).unwrap();
assert_eq!(out, "Before VISIBLE After");
}
#[test]
fn if_block_falsy_empty_string() {
let t = Template::new("Before{{#if show}} VISIBLE{{/if}} After");
let v = vars(&[("show", "")]);
let out = TemplateEngine::render(&t, &v).unwrap();
assert_eq!(out, "Before After");
}
#[test]
fn if_block_falsy_missing_var() {
let t = Template::new("{{#if flag}}YES{{/if}}");
let v = vars(&[]);
let out = TemplateEngine::render(&t, &v).unwrap();
assert_eq!(out, "");
}
#[test]
fn if_block_false_literal() {
let t = Template::new("{{#if flag}}YES{{/if}}");
let v = vars(&[("flag", "false")]);
let out = TemplateEngine::render(&t, &v).unwrap();
assert_eq!(out, "");
}
#[test]
fn nested_if_blocks() {
let t = Template::new("{{#if a}}A{{#if b}}B{{/if}}C{{/if}}D");
let v = vars(&[("a", "1"), ("b", "1")]);
let out = TemplateEngine::render(&t, &v).unwrap();
assert_eq!(out, "ABCD");
}
#[test]
fn nested_if_inner_false() {
let t = Template::new("{{#if a}}A{{#if b}}B{{/if}}C{{/if}}D");
let v = vars(&[("a", "1"), ("b", "")]);
let out = TemplateEngine::render(&t, &v).unwrap();
assert_eq!(out, "ACD");
}
#[test]
fn each_block() {
let t = Template::new("Items:{{#each fruits}} [{{item}}]{{/each}}");
let v = vars(&[("fruits", "apple,banana,cherry")]);
let out = TemplateEngine::render(&t, &v).unwrap();
assert_eq!(out, "Items: [apple] [banana] [cherry]");
}
#[test]
fn each_block_missing_list() {
let t = Template::new("{{#each items}}{{item}}{{/each}}");
let v = vars(&[]);
let out = TemplateEngine::render(&t, &v).unwrap();
assert_eq!(out, "");
}
#[test]
fn validate_extracts_vars() {
let t = Template::new("{{greeting}} {{name}}! {{#if show}}{{extra}}{{/if}}");
let mut required = TemplateEngine::validate(&t);
required.sort();
assert!(required.contains(&"greeting".to_string()));
assert!(required.contains(&"name".to_string()));
assert!(required.contains(&"extra".to_string()) || required.len() >= 2);
}
#[test]
fn template_library_register_get() {
let mut lib = TemplateLibrary::new();
lib.register("greeting", Template::new("Hello, {{name}}!"));
assert_eq!(lib.len(), 1);
let t = lib.get("greeting").unwrap();
let v = vars(&[("name", "Bob")]);
let out = TemplateEngine::render(t, &v).unwrap();
assert_eq!(out, "Hello, Bob!");
}
#[test]
fn template_library_get_missing() {
let lib = TemplateLibrary::new();
assert!(lib.get("nonexistent").is_none());
}
#[test]
fn template_library_names() {
let mut lib = TemplateLibrary::new();
lib.register("a", Template::new("{{x}}"));
lib.register("b", Template::new("{{y}}"));
let mut names: Vec<&str> = lib.names();
names.sort();
assert_eq!(names, vec!["a", "b"]);
}
#[test]
fn no_tags_passthrough() {
let t = Template::new("Plain text with no placeholders.");
let out = TemplateEngine::render(&t, &vars(&[])).unwrap();
assert_eq!(out, "Plain text with no placeholders.");
}
}