use anyhow::{Context as _, Result};
use heck::{ToLowerCamelCase, ToPascalCase, ToSnakeCase, ToTitleCase};
use romance_templates::Templates;
use std::collections::HashMap;
use tera::{Context, Tera, Value};
pub struct TemplateEngine {
tera: Tera,
}
impl TemplateEngine {
pub fn new() -> Result<Self> {
let mut tera = Tera::default();
for file in Templates::iter() {
let path = file.as_ref();
if !path.ends_with(".tera") {
continue;
}
if let Some(content) = Templates::get(path) {
let content_str = std::str::from_utf8(content.data.as_ref())?;
tera.add_raw_template(path, content_str)?;
}
}
tera.register_filter("snake_case", snake_case_filter);
tera.register_filter("pascal_case", pascal_case_filter);
tera.register_filter("camel_case", camel_case_filter);
tera.register_filter("plural", plural_filter);
tera.register_filter("title_case", title_case_filter);
tera.register_filter("rust_ident", rust_ident_filter);
Ok(TemplateEngine { tera })
}
pub fn render(&self, template_name: &str, context: &Context) -> Result<String> {
let result = self
.tera
.render(template_name, context)
.with_context(|| format!("Failed to render template '{}'", template_name))?;
Ok(result)
}
pub fn get_raw(&self, path: &str) -> Result<String> {
let content = Templates::get(path)
.with_context(|| format!("Embedded file '{}' not found", path))?;
let s = std::str::from_utf8(content.data.as_ref())
.with_context(|| format!("Invalid UTF-8 in '{}'", path))?;
Ok(s.to_string())
}
}
fn snake_case_filter(
value: &Value,
_args: &HashMap<String, Value>,
) -> tera::Result<Value> {
match value.as_str() {
Some(s) => Ok(Value::String(s.to_snake_case())),
None => Err(tera::Error::msg("snake_case filter expects a string")),
}
}
fn pascal_case_filter(
value: &Value,
_args: &HashMap<String, Value>,
) -> tera::Result<Value> {
match value.as_str() {
Some(s) => Ok(Value::String(s.to_pascal_case())),
None => Err(tera::Error::msg("pascal_case filter expects a string")),
}
}
fn camel_case_filter(
value: &Value,
_args: &HashMap<String, Value>,
) -> tera::Result<Value> {
match value.as_str() {
Some(s) => Ok(Value::String(s.to_lower_camel_case())),
None => Err(tera::Error::msg("camel_case filter expects a string")),
}
}
fn plural_filter(
value: &Value,
_args: &HashMap<String, Value>,
) -> tera::Result<Value> {
match value.as_str() {
Some(s) => Ok(Value::String(crate::utils::pluralize(s))),
None => Err(tera::Error::msg("plural filter expects a string")),
}
}
fn title_case_filter(
value: &Value,
_args: &HashMap<String, Value>,
) -> tera::Result<Value> {
match value.as_str() {
Some(s) => Ok(Value::String(s.to_title_case())),
None => Err(tera::Error::msg("title_case filter expects a string")),
}
}
fn rust_ident_filter(
value: &Value,
_args: &HashMap<String, Value>,
) -> tera::Result<Value> {
match value.as_str() {
Some(s) => Ok(Value::String(crate::utils::rust_ident(s))),
None => Err(tera::Error::msg("rust_ident filter expects a string")),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn val(s: &str) -> Value {
Value::String(s.to_string())
}
fn empty_args() -> HashMap<String, Value> {
HashMap::new()
}
#[test]
fn snake_case_filter_works() {
let result = snake_case_filter(&val("ProductCategory"), &empty_args()).unwrap();
assert_eq!(result.as_str().unwrap(), "product_category");
}
#[test]
fn snake_case_filter_single_word() {
let result = snake_case_filter(&val("Post"), &empty_args()).unwrap();
assert_eq!(result.as_str().unwrap(), "post");
}
#[test]
fn pascal_case_filter_works() {
let result = pascal_case_filter(&val("product_category"), &empty_args()).unwrap();
assert_eq!(result.as_str().unwrap(), "ProductCategory");
}
#[test]
fn pascal_case_filter_from_snake() {
let result = pascal_case_filter(&val("blog_post"), &empty_args()).unwrap();
assert_eq!(result.as_str().unwrap(), "BlogPost");
}
#[test]
fn camel_case_filter_works() {
let result = camel_case_filter(&val("ProductCategory"), &empty_args()).unwrap();
assert_eq!(result.as_str().unwrap(), "productCategory");
}
#[test]
fn camel_case_filter_from_snake() {
let result = camel_case_filter(&val("blog_post"), &empty_args()).unwrap();
assert_eq!(result.as_str().unwrap(), "blogPost");
}
#[test]
fn plural_filter_regular() {
let result = plural_filter(&val("post"), &empty_args()).unwrap();
assert_eq!(result.as_str().unwrap(), "posts");
}
#[test]
fn plural_filter_category() {
let result = plural_filter(&val("Category"), &empty_args()).unwrap();
assert_eq!(result.as_str().unwrap(), "Categories");
}
#[test]
fn plural_filter_box() {
let result = plural_filter(&val("box"), &empty_args()).unwrap();
assert_eq!(result.as_str().unwrap(), "boxes");
}
#[test]
fn title_case_filter_works() {
let result = title_case_filter(&val("product_category"), &empty_args()).unwrap();
assert_eq!(result.as_str().unwrap(), "Product Category");
}
#[test]
fn rust_ident_filter_works() {
let result = rust_ident_filter(&val("type"), &empty_args()).unwrap();
assert_eq!(result.as_str().unwrap(), "r#type");
}
#[test]
fn rust_ident_filter_non_reserved() {
let result = rust_ident_filter(&val("title"), &empty_args()).unwrap();
assert_eq!(result.as_str().unwrap(), "title");
}
#[test]
fn snake_case_filter_rejects_non_string() {
let num = Value::Number(serde_json::Number::from(42));
assert!(snake_case_filter(&num, &empty_args()).is_err());
}
#[test]
fn pascal_case_filter_rejects_non_string() {
let num = Value::Number(serde_json::Number::from(42));
assert!(pascal_case_filter(&num, &empty_args()).is_err());
}
#[test]
fn camel_case_filter_rejects_non_string() {
let num = Value::Number(serde_json::Number::from(42));
assert!(camel_case_filter(&num, &empty_args()).is_err());
}
#[test]
fn plural_filter_rejects_non_string() {
let num = Value::Number(serde_json::Number::from(42));
assert!(plural_filter(&num, &empty_args()).is_err());
}
#[test]
fn template_engine_creates_successfully() {
let _engine = TemplateEngine::new().unwrap();
}
#[test]
fn render_inline_template_with_snake_case() {
let mut tera = Tera::default();
tera.register_filter("snake_case", snake_case_filter);
tera.add_raw_template("test", "{{ name | snake_case }}").unwrap();
let mut ctx = Context::new();
ctx.insert("name", "ProductCategory");
let result = tera.render("test", &ctx).unwrap();
assert_eq!(result, "product_category");
}
#[test]
fn render_inline_template_with_plural() {
let mut tera = Tera::default();
tera.register_filter("plural", plural_filter);
tera.add_raw_template("test", "{{ name | plural }}").unwrap();
let mut ctx = Context::new();
ctx.insert("name", "Category");
let result = tera.render("test", &ctx).unwrap();
assert_eq!(result, "Categories");
}
#[test]
fn render_inline_template_with_multiple_filters() {
let mut tera = Tera::default();
tera.register_filter("snake_case", snake_case_filter);
tera.register_filter("pascal_case", pascal_case_filter);
tera.register_filter("camel_case", camel_case_filter);
tera.add_raw_template(
"test",
"snake={{ name | snake_case }} pascal={{ name | pascal_case }} camel={{ name | camel_case }}",
)
.unwrap();
let mut ctx = Context::new();
ctx.insert("name", "blog_post");
let result = tera.render("test", &ctx).unwrap();
assert_eq!(result, "snake=blog_post pascal=BlogPost camel=blogPost");
}
#[test]
fn render_inline_template_with_context_variables() {
let mut tera = Tera::default();
tera.add_raw_template("test", "Hello {{ name }}, port {{ port }}").unwrap();
let mut ctx = Context::new();
ctx.insert("name", "Romance");
ctx.insert("port", &3000);
let result = tera.render("test", &ctx).unwrap();
assert_eq!(result, "Hello Romance, port 3000");
}
}