1use std::path::Path;
5use tera::Tera;
6use walkdir::WalkDir;
7
8use crate::{collector, context::ForgeContext, error::ForgeError, filters, provide, slots, tier::Tier};
9
10pub struct Engine {
18 tera: Tera,
19}
20
21impl Engine {
22 pub fn new() -> Self {
24 let mut tera = Tera::default();
25 register_extensions(&mut tera);
26 Engine { tera }
27 }
28
29 pub fn load_dir(&mut self, dir: &Path) -> Result<(), ForgeError> {
32 if !dir.exists() {
33 return Ok(());
34 }
35 for entry in WalkDir::new(dir).into_iter().filter_map(|e| e.ok()) {
36 let path = entry.path();
37 if path.is_file() {
38 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
39 if ext == "jinja" || ext == "forge" {
40 let name = path
41 .strip_prefix(dir)
42 .map_err(|e| ForgeError::LoadError(e.to_string()))?
43 .to_string_lossy()
44 .replace('\\', "/");
45 let content = std::fs::read_to_string(path)
46 .map_err(|e| ForgeError::LoadError(e.to_string()))?;
47 self.tera
48 .add_raw_template(&name, &content)
49 .map_err(|e| ForgeError::LoadError(e.to_string()))?;
50 }
51 }
52 }
53 Ok(())
54 }
55
56 pub fn load_embedded(&mut self, templates: &[(&str, &str)]) -> Result<(), ForgeError> {
58 for (name, content) in templates {
59 self.tera
60 .add_raw_template(name, content)
61 .map_err(|e| ForgeError::LoadError(e.to_string()))?;
62 }
63 Ok(())
64 }
65
66 pub fn add_raw(&mut self, name: &str, content: &str) -> Result<(), ForgeError> {
68 self.tera
69 .add_raw_template(name, content)
70 .map_err(|e| ForgeError::LoadError(e.to_string()))?;
71 Ok(())
72 }
73
74 pub fn render(&self, name: &str, ctx: &ForgeContext) -> Result<String, ForgeError> {
77 collector::reset();
78 slots::reset();
79 provide::reset();
80 if let Some(slot_map) = ctx.slots() {
82 for (k, v) in &slot_map {
83 if let Some(content) = v.as_str() {
84 slots::fill(k, content);
85 }
86 }
87 }
88 if let Some(provides_map) = ctx.provides() {
90 for (k, v) in &provides_map {
91 if let Some(content) = v.as_str() {
92 provide::provide(k, content);
93 }
94 }
95 }
96 self.tera
97 .render(name, ctx.as_tera())
98 .map_err(|e| ForgeError::RenderError(format!("{name}: {e}")))
99 }
100
101 pub fn render_continue(&self, name: &str, ctx: &ForgeContext) -> Result<String, ForgeError> {
104 self.tera
105 .render(name, ctx.as_tera())
106 .map_err(|e| ForgeError::RenderError(format!("{name}: {e}")))
107 }
108
109 pub fn tier_of(&self, name: &str) -> Tier {
111 Tier::from_path(name)
112 }
113
114 pub fn has_template(&self, name: &str) -> bool {
116 self.tera.get_template_names().any(|n| n == name)
117 }
118}
119
120impl Default for Engine {
121 fn default() -> Self {
122 Self::new()
123 }
124}
125
126fn register_extensions(tera: &mut Tera) {
127 tera.register_filter("snake_case", filters::snake_case);
128 tera.register_filter("pascal_case", filters::pascal_case);
129 tera.register_filter("camel_case", filters::camel_case);
130 tera.register_filter("kebab_case", filters::kebab_case);
131 tera.register_filter("collect_import", filters::collect_import);
132 tera.register_filter("collect_import_priority", filters::collect_import_priority);
133 tera.register_function("render_imports", filters::render_imports_fn);
134 tera.register_function("slot", slots::make_slot_fn());
135 tera.register_function("inject", provide::make_inject_fn());
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141
142 #[test]
143 fn engine_renders_simple_template() {
144 let mut engine = Engine::new();
145 engine.add_raw("test.jinja", "Hello {{ name | pascal_case }}!").unwrap();
146 let ctx = ForgeContext::new().insert("name", "world");
147 let out = engine.render("test.jinja", &ctx).unwrap();
148 assert_eq!(out, "Hello World!");
149 }
150
151 #[test]
152 fn engine_collect_and_drain_imports() {
153 let mut engine = Engine::new();
154 engine
155 .add_raw(
156 "test.jinja",
157 "{{ 'import React from \"react\"' | collect_import_priority }}{{ 'import { z } from \"zod\"' | collect_import }}{{ render_imports() }}",
158 )
159 .unwrap();
160 let ctx = ForgeContext::new();
161 let out = engine.render("test.jinja", &ctx).unwrap();
162 let lines: Vec<&str> = out.lines().filter(|l| !l.is_empty()).collect();
163 assert_eq!(lines[0], "import React from \"react\"");
164 assert!(lines.iter().any(|l| l.contains("zod")));
165 }
166
167 #[test]
168 fn tier_infers_from_name() {
169 let engine = Engine::new();
170 assert_eq!(engine.tier_of("atoms/drizzle/column.jinja"), Tier::Atom);
171 assert_eq!(engine.tier_of("features/schema.jinja"), Tier::Feature);
172 }
173}