1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use tera::Tera;
4use walkdir::WalkDir;
5use serde_yaml::Value;
6use chrono::{DateTime, Utc};
7
8use crate::config::TemplateConfig;
9use crate::error::{EngineError, EngineResult};
10
11#[derive(Debug, Clone)]
12pub struct Context {
13 variables: HashMap<String, Value>,
14 features: Vec<String>,
15}
16
17impl Context {
18 pub fn new() -> Self {
19 Self {
20 variables: HashMap::new(),
21 features: Vec::new(),
22 }
23 }
24
25 pub fn builder() -> ContextBuilder {
26 ContextBuilder::new()
27 }
28
29 pub fn add_variable(&mut self, name: String, value: Value) {
30 self.variables.insert(name, value);
31 }
32
33 pub fn get_variable(&self, name: &str) -> Option<&Value> {
34 self.variables.get(name)
35 }
36
37 pub fn add_feature(&mut self, feature: String) {
38 if !self.features.contains(&feature) {
39 self.features.push(feature);
40 }
41 }
42
43 pub fn has_feature(&self, feature: &str) -> bool {
44 self.features.contains(&feature.to_string())
45 }
46
47 pub fn variables(&self) -> &HashMap<String, Value> {
48 &self.variables
49 }
50
51 pub fn features(&self) -> &[String] {
52 &self.features
53 }
54
55 pub fn to_tera_context(&self) -> tera::Context {
56 let mut tera_context = tera::Context::new();
57
58 for (key, value) in &self.variables {
59 tera_context.insert(key, value);
60 }
61
62 tera_context.insert("features", &self.features);
63 for feature in &self.features {
64 tera_context.insert(&format!("feature_{}", feature), &true);
65 }
66
67 tera_context
68 }
69}
70
71impl Default for Context {
72 fn default() -> Self {
73 Self::new()
74 }
75}
76
77#[derive(Debug)]
78pub struct ContextBuilder {
79 context: Context,
80}
81
82impl ContextBuilder {
83 pub fn new() -> Self {
84 Self {
85 context: Context::new(),
86 }
87 }
88
89 pub fn variable(mut self, name: impl Into<String>, value: impl Into<Value>) -> Self {
90 self.context.add_variable(name.into(), value.into());
91 self
92 }
93
94 pub fn feature(mut self, feature: impl Into<String>) -> Self {
95 self.context.add_feature(feature.into());
96 self
97 }
98
99 pub fn build(self) -> Context {
100 self.context
101 }
102}
103
104#[derive(Debug, Clone)]
105pub struct TemplateFile {
106 pub source_path: PathBuf,
107 pub relative_path: PathBuf,
108 pub output_path: PathBuf,
109 pub content: String,
110}
111
112#[derive(Debug)]
113pub struct ProcessedTemplate {
114 pub files: Vec<ProcessedFile>,
115}
116
117#[derive(Debug)]
118pub struct ProcessedFile {
119 pub output_path: PathBuf,
120 pub content: String,
121 pub executable: bool,
122}
123
124pub struct TemplateEngine {
125 tera: Tera,
126}
127
128impl TemplateEngine {
129 pub fn new() -> EngineResult<Self> {
130 let mut tera = Tera::new("templates/**/*").map_err(EngineError::ProcessingError)?;
131
132 tera.register_filter("snake_case", Self::snake_case_filter);
133 tera.register_filter("pascal_case", Self::pascal_case_filter);
134 tera.register_filter("kebab_case", Self::kebab_case_filter);
135 tera.register_filter("rust_module_name", Self::rust_module_name_filter);
136
137 Ok(Self { tera })
138 }
139
140 pub fn new_for_testing() -> EngineResult<Self> {
141 let mut tera = Tera::default();
142
143 tera.register_filter("snake_case", Self::snake_case_filter);
144 tera.register_filter("pascal_case", Self::pascal_case_filter);
145 tera.register_filter("kebab_case", Self::kebab_case_filter);
146 tera.register_filter("rust_module_name", Self::rust_module_name_filter);
147
148 Ok(Self { tera })
149 }
150
151 pub fn discover_template_files(
152 &self,
153 template_dir: &Path,
154 ) -> EngineResult<Vec<TemplateFile>> {
155 let mut files = Vec::new();
156
157 for entry in WalkDir::new(template_dir)
158 .into_iter()
159 .filter_map(|e| e.ok())
160 .filter(|e| e.file_type().is_file())
161 {
162 let source_path = entry.path().to_path_buf();
163
164 if source_path.file_name().and_then(|n| n.to_str()) == Some("anvil.yaml") {
165 continue;
166 }
167
168 let relative_path = source_path
169 .strip_prefix(template_dir)
170 .map_err(|_| EngineError::invalid_config("Invalid template path"))?
171 .to_path_buf();
172
173 let output_path = if relative_path.extension().and_then(|e| e.to_str()) == Some("tera") {
174 let file_name = relative_path.file_name()
176 .and_then(|n| n.to_str())
177 .unwrap_or("")
178 .trim_end_matches(".tera");
179 relative_path.with_file_name(file_name)
180 } else {
181 relative_path.clone()
182 };
183
184 let content = std::fs::read_to_string(&source_path)
185 .map_err(|e| EngineError::file_error(&source_path, e))?;
186
187 files.push(TemplateFile {
188 source_path,
189 relative_path,
190 output_path,
191 content,
192 });
193 }
194
195 Ok(files)
196 }
197
198 pub async fn process_template(
199 &mut self,
200 template_dir: &Path,
201 context: &Context,
202 ) -> EngineResult<ProcessedTemplate> {
203 let template_files = self.discover_template_files(template_dir)?;
204 let tera_context = context.to_tera_context();
205
206 let mut processed_files = Vec::new();
207
208 for template_file in template_files {
209 let processed_content = if template_file.source_path.extension().and_then(|e| e.to_str()) == Some("tera") {
210 self.tera.render_str(&template_file.content, &tera_context)
211 .map_err(EngineError::ProcessingError)?
212 } else {
213 template_file.content
214 };
215
216 let executable = self.should_be_executable(&template_file.output_path);
217
218 processed_files.push(ProcessedFile {
219 output_path: template_file.output_path,
220 content: processed_content,
221 executable,
222 });
223 }
224
225 Ok(ProcessedTemplate {
226 files: processed_files,
227 })
228 }
229
230 pub async fn process_composed_template(
235 &mut self,
236 composed: crate::composition::ComposedTemplate,
237 context: &Context,
238 ) -> EngineResult<ProcessedTemplate> {
239 let tera_context = self.build_shared_context(context, &composed)?;
241
242 let mut processed_files = Vec::new();
243
244 for composed_file in composed.files {
245 let processed_content = if composed_file.is_template {
246 self.tera.render_str(&composed_file.content, &tera_context)
247 .map_err(EngineError::ProcessingError)?
248 } else {
249 composed_file.content
250 };
251
252 let executable = self.should_be_executable(&composed_file.path);
253
254 processed_files.push(ProcessedFile {
255 output_path: composed_file.path,
256 content: processed_content,
257 executable,
258 });
259 }
260
261 Ok(ProcessedTemplate {
262 files: processed_files,
263 })
264 }
265
266 pub fn render_string(&mut self, template: &str, context: &Context) -> EngineResult<String> {
267 let tera_context = context.to_tera_context();
268 self.tera.render_str(template, &tera_context)
269 .map_err(EngineError::ProcessingError)
270 }
271
272 pub fn validate_context(
273 &self,
274 context: &Context,
275 config: &TemplateConfig,
276 ) -> EngineResult<()> {
277 for variable in &config.variables {
278 if variable.required {
279 if !context.variables.contains_key(&variable.name) {
280 return Err(EngineError::variable_error(
281 &variable.name,
282 "Required variable not provided",
283 ));
284 }
285 }
286
287 if let Some(value) = context.get_variable(&variable.name) {
288 variable.validate_value(value)?;
289 }
290 }
291
292 Ok(())
293 }
294
295 fn should_be_executable(&self, path: &Path) -> bool {
296 if let Some(extension) = path.extension().and_then(|e| e.to_str()) {
297 matches!(extension, "sh" | "py" | "rb" | "pl")
298 } else {
299 if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
300 matches!(filename, "gradlew" | "mvnw" | "install" | "configure" | "bootstrap")
301 } else {
302 false
303 }
304 }
305 }
306
307
308 fn snake_case_filter(value: &tera::Value, _: &HashMap<String, tera::Value>) -> tera::Result<tera::Value> {
309 let s = value.as_str().ok_or_else(|| tera::Error::msg("Value must be a string"))?;
310 let snake_case = s
311 .chars()
312 .enumerate()
313 .map(|(i, c)| {
314 if c.is_uppercase() && i > 0 {
315 format!("_{}", c.to_lowercase())
316 } else {
317 c.to_lowercase().to_string()
318 }
319 })
320 .collect::<String>()
321 .replace(' ', "_")
322 .replace('-', "_");
323 Ok(tera::Value::String(snake_case))
324 }
325
326 fn pascal_case_filter(value: &tera::Value, _: &HashMap<String, tera::Value>) -> tera::Result<tera::Value> {
327 let s = value.as_str().ok_or_else(|| tera::Error::msg("Value must be a string"))?;
328
329 let mut words = Vec::new();
331 for segment in s.split(&[' ', '_', '-'][..]) {
332 if segment.is_empty() {
333 continue;
334 }
335
336 let mut current_word = String::new();
338 for ch in segment.chars() {
339 if ch.is_uppercase() && !current_word.is_empty() {
340 words.push(current_word.clone());
341 current_word.clear();
342 }
343 current_word.push(ch);
344 }
345 if !current_word.is_empty() {
346 words.push(current_word);
347 }
348 }
349
350 let pascal_case = words
351 .iter()
352 .map(|word| {
353 let mut chars = word.chars();
354 match chars.next() {
355 None => String::new(),
356 Some(first) => first.to_uppercase().collect::<String>() + &chars.as_str().to_lowercase(),
357 }
358 })
359 .collect::<String>();
360 Ok(tera::Value::String(pascal_case))
361 }
362
363 fn kebab_case_filter(value: &tera::Value, _: &HashMap<String, tera::Value>) -> tera::Result<tera::Value> {
364 let s = value.as_str().ok_or_else(|| tera::Error::msg("Value must be a string"))?;
365 let kebab_case = s
366 .chars()
367 .enumerate()
368 .map(|(i, c)| {
369 if c.is_uppercase() && i > 0 {
370 format!("-{}", c.to_lowercase())
371 } else {
372 c.to_lowercase().to_string()
373 }
374 })
375 .collect::<String>()
376 .replace(' ', "-")
377 .replace('_', "-");
378 Ok(tera::Value::String(kebab_case))
379 }
380
381 fn build_shared_context(
386 &self,
387 user_context: &Context,
388 composed: &crate::composition::ComposedTemplate,
389 ) -> EngineResult<tera::Context> {
390 let mut tera_context = user_context.to_tera_context();
391
392 tera_context.insert("template", &serde_json::json!({
394 "name": composed.base_config.name,
395 "description": composed.base_config.description,
396 "version": composed.base_config.version,
397 "min_anvil_version": composed.base_config.min_anvil_version
398 }));
399
400 let now: DateTime<Utc> = Utc::now();
402 tera_context.insert("build", &serde_json::json!({
403 "timestamp": now.format("%Y-%m-%d %H:%M:%S UTC").to_string(),
404 "timestamp_iso": now.to_rfc3339(),
405 "year": now.format("%Y").to_string(),
406 "generator": "Anvil Template Engine",
407 "generator_version": env!("CARGO_PKG_VERSION")
408 }));
409
410 tera_context.insert("merged_dependencies", &composed.merged_dependencies);
412
413 tera_context.insert("environment_variables", &composed.environment_variables);
415
416 for (service_name, service_info) in &composed.service_context.services {
418 tera_context.insert(&format!("service_{}", service_name.to_lowercase()), &service_info.provider);
419 for (export_key, export_value) in &service_info.exports {
420 tera_context.insert(&format!("{}_{}", service_name.to_lowercase(), export_key), export_value);
421 }
422 }
423
424 for (key, value) in &composed.service_context.shared_config {
426 tera_context.insert(key, value);
427 }
428
429 let service_summary: Vec<serde_json::Value> = composed.service_context.services.iter()
431 .map(|(name, info)| serde_json::json!({
432 "category": name,
433 "provider": info.provider,
434 "has_config": !info.config.is_empty()
435 }))
436 .collect();
437 tera_context.insert("active_services", &service_summary);
438
439 tera_context.insert("has_services", &(!composed.service_context.services.is_empty()));
441 tera_context.insert("has_dependencies", &(!composed.merged_dependencies.is_empty()));
442 tera_context.insert("has_environment_variables", &(!composed.environment_variables.is_empty()));
443
444 Ok(tera_context)
445 }
446
447 fn rust_module_name_filter(value: &tera::Value, _: &HashMap<String, tera::Value>) -> tera::Result<tera::Value> {
448 let s = value.as_str().ok_or_else(|| tera::Error::msg("Value must be a string"))?;
449 let module_name = s
450 .chars()
451 .enumerate()
452 .map(|(i, c)| {
453 if c.is_uppercase() && i > 0 {
454 format!("_{}", c.to_lowercase())
455 } else if c.is_alphanumeric() || c == '_' {
456 c.to_lowercase().to_string()
457 } else {
458 "_".to_string()
459 }
460 })
461 .collect::<String>()
462 .replace(' ', "_")
463 .replace('-', "_");
464
465 let module_name = if module_name.chars().next().map_or(false, |c| c.is_numeric()) {
466 format!("_{}", module_name)
467 } else {
468 module_name
469 };
470
471 Ok(tera::Value::String(module_name))
472 }
473}
474
475impl Default for TemplateEngine {
476 fn default() -> Self {
477 Self::new().expect("Failed to create default template engine")
478 }
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484 use tempfile::TempDir;
485 use std::fs;
486
487 #[test]
488 fn test_context_builder() {
489 let context = Context::builder()
490 .variable("project_name", "test-project")
491 .variable("author", "Test Author")
492 .feature("database")
493 .feature("auth")
494 .build();
495
496 assert_eq!(context.get_variable("project_name").unwrap().as_str().unwrap(), "test-project");
497 assert_eq!(context.get_variable("author").unwrap().as_str().unwrap(), "Test Author");
498 assert!(context.has_feature("database"));
499 assert!(context.has_feature("auth"));
500 assert!(!context.has_feature("nonexistent"));
501 }
502
503 #[test]
504 fn test_template_engine_creation() {
505 let _engine = TemplateEngine::new_for_testing().unwrap();
506 }
507
508 #[test]
509 fn test_render_string() {
510 let mut engine = TemplateEngine::new_for_testing().unwrap();
511 let context = Context::builder()
512 .variable("name", "World")
513 .build();
514
515 let result = engine.render_string("Hello, {{ name }}!", &context).unwrap();
516 assert_eq!(result, "Hello, World!");
517 }
518
519 #[test]
520 fn test_custom_filters() {
521 let mut engine = TemplateEngine::new_for_testing().unwrap();
522 let context = Context::builder()
523 .variable("project_name", "MyAwesomeProject")
524 .build();
525
526 let result = engine.render_string("{{ project_name | snake_case }}", &context).unwrap();
527 assert_eq!(result, "my_awesome_project");
528
529 let result = engine.render_string("{{ project_name | pascal_case }}", &context).unwrap();
530 assert_eq!(result, "MyAwesomeProject");
531
532 let result = engine.render_string("{{ project_name | kebab_case }}", &context).unwrap();
533 assert_eq!(result, "my-awesome-project");
534
535 let result = engine.render_string("{{ project_name | rust_module_name }}", &context).unwrap();
536 assert_eq!(result, "my_awesome_project");
537 }
538
539 #[tokio::test]
540 async fn test_template_file_discovery() {
541 let temp_dir = TempDir::new().unwrap();
542 let template_dir = temp_dir.path().join("template");
543 fs::create_dir_all(&template_dir).unwrap();
544
545 fs::write(template_dir.join("file.txt.tera"), "Hello {{ name }}").unwrap();
546 fs::write(template_dir.join("static.md"), "# README").unwrap();
547
548 let engine = TemplateEngine::new_for_testing().unwrap();
549 let files = engine.discover_template_files(&template_dir).unwrap();
550
551
552 let template_file = files.iter().find(|f| f.relative_path.to_str().unwrap() == "file.txt.tera").unwrap();
553 assert_eq!(template_file.output_path, PathBuf::from("file.txt"));
554
555 let static_file = files.iter().find(|f| f.relative_path.to_str().unwrap() == "static.md").unwrap();
556 assert_eq!(static_file.output_path, PathBuf::from("static.md"));
557 }
558
559 #[tokio::test]
560 async fn test_template_processing() {
561 let temp_dir = TempDir::new().unwrap();
562 let template_dir = temp_dir.path().join("template");
563 std::fs::create_dir_all(&template_dir).unwrap();
564
565 std::fs::write(
566 template_dir.join("main.rs.tera"),
567 r#"fn main() {
568 println!("Hello from {{ project_name | pascal_case }}!");
569}
570"#,
571 ).unwrap();
572
573 let mut engine = TemplateEngine::new_for_testing().unwrap();
574 let context = Context::builder()
575 .variable("project_name", "my-project")
576 .build();
577
578 let result = engine.process_template(&template_dir, &context).await.unwrap();
579
580 assert_eq!(result.files.len(), 1);
581 let file = &result.files[0];
582 assert_eq!(file.output_path, PathBuf::from("main.rs"));
583 assert!(file.content.contains("Hello from MyProject!"));
584 }
585}