anvil_engine/
engine.rs

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                // Remove .tera extension: package.json.tera -> package.json
175                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    /*
231    Processes a ComposedTemplate (from composition engine) into a ProcessedTemplate
232    by rendering all template content with the given context.
233    */
234    pub async fn process_composed_template(
235        &mut self,
236        composed: crate::composition::ComposedTemplate,
237        context: &Context,
238    ) -> EngineResult<ProcessedTemplate> {
239        // Build comprehensive shared context
240        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        // First, split by common delimiters and handle camelCase/PascalCase
330        let mut words = Vec::new();
331        for segment in s.split(&[' ', '_', '-'][..]) {
332            if segment.is_empty() {
333                continue;
334            }
335            
336            // Split camelCase/PascalCase by detecting uppercase letters
337            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    /*
382    Builds a comprehensive shared context that combines user variables, service context,
383    template metadata, and build information for template rendering.
384    */
385    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        // Add template metadata
393        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        // Add build context
401        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        // Add merged dependencies to context for template rendering
411        tera_context.insert("merged_dependencies", &composed.merged_dependencies);
412        
413        // Add environment variables to context
414        tera_context.insert("environment_variables", &composed.environment_variables);
415        
416        // Add service context for dependency injection (flattened for Tera compatibility)
417        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        // Add shared config
425        for (key, value) in &composed.service_context.shared_config {
426            tera_context.insert(key, value);
427        }
428        
429        // Add service summary for easy template access
430        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        // Add utility flags
440        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}