sherpack_engine/
engine.rs

1//! Template engine based on MiniJinja
2//!
3//! This module provides the core rendering engine for Sherpack templates,
4//! built on top of MiniJinja with Helm-compatible filters and functions.
5
6use indexmap::IndexMap;
7use minijinja::Environment;
8use sherpack_core::{LoadedPack, SandboxedFileProvider, TemplateContext};
9use std::collections::HashMap;
10
11use crate::error::{EngineError, RenderReport, RenderResultWithReport, Result, TemplateError};
12use crate::files_object::create_files_value_from_provider;
13use crate::filters;
14use crate::functions;
15
16/// Prefix character for helper templates (skipped during rendering)
17const HELPER_TEMPLATE_PREFIX: char = '_';
18
19/// Pattern to identify NOTES templates
20const NOTES_TEMPLATE_PATTERN: &str = "notes";
21
22/// Result of rendering a pack
23#[derive(Debug)]
24pub struct RenderResult {
25    /// Rendered manifests by filename (IndexMap preserves insertion order)
26    pub manifests: IndexMap<String, String>,
27
28    /// Post-install notes (if NOTES.txt exists)
29    pub notes: Option<String>,
30}
31
32/// Template engine builder
33pub struct EngineBuilder {
34    strict_mode: bool,
35}
36
37impl Default for EngineBuilder {
38    fn default() -> Self {
39        Self::new()
40    }
41}
42
43impl EngineBuilder {
44    pub fn new() -> Self {
45        Self { strict_mode: true }
46    }
47
48    /// Set strict mode (fail on undefined variables)
49    pub fn strict(mut self, strict: bool) -> Self {
50        self.strict_mode = strict;
51        self
52    }
53
54    /// Build the engine
55    pub fn build(self) -> Engine {
56        Engine::new(self.strict_mode)
57    }
58}
59
60/// The template engine
61pub struct Engine {
62    strict_mode: bool,
63}
64
65impl Engine {
66    /// Create a new engine
67    ///
68    /// # Arguments
69    /// * `strict_mode` - If true, uses Chainable undefined behavior (Helm-compatible).
70    ///   If false, uses Lenient mode (empty strings for undefined).
71    ///
72    /// # Prefer using convenience methods
73    /// For clearer code, prefer `Engine::strict()` or `Engine::lenient()`.
74    pub fn new(strict_mode: bool) -> Self {
75        Self { strict_mode }
76    }
77
78    /// Create a strict mode engine (Helm-compatible, recommended)
79    ///
80    /// Uses `UndefinedBehavior::Chainable` which allows accessing properties
81    /// on undefined values, returning undefined instead of error.
82    #[must_use]
83    pub fn strict() -> Self {
84        Self { strict_mode: true }
85    }
86
87    /// Create a lenient mode engine
88    ///
89    /// Uses `UndefinedBehavior::Lenient` which returns empty strings
90    /// for undefined values.
91    #[must_use]
92    pub fn lenient() -> Self {
93        Self { strict_mode: false }
94    }
95
96    /// Create a builder for more configuration options
97    #[must_use]
98    pub fn builder() -> EngineBuilder {
99        EngineBuilder::new()
100    }
101
102    /// Create a configured MiniJinja environment
103    fn create_environment(&self) -> Environment<'static> {
104        let mut env = Environment::new();
105
106        // Configure behavior
107        // Use Chainable mode by default - allows accessing properties on undefined values
108        // (returns undefined instead of error), matching Helm's Go template behavior.
109        // This is essential for converted charts where values may be optional.
110        if self.strict_mode {
111            env.set_undefined_behavior(minijinja::UndefinedBehavior::Chainable);
112        } else {
113            env.set_undefined_behavior(minijinja::UndefinedBehavior::Lenient);
114        }
115
116        // Register custom filters
117        env.add_filter("toyaml", filters::toyaml);
118        env.add_filter("tojson", filters::tojson);
119        env.add_filter("tojson_pretty", filters::tojson_pretty);
120        env.add_filter("b64encode", filters::b64encode);
121        env.add_filter("b64decode", filters::b64decode);
122        env.add_filter("quote", filters::quote);
123        env.add_filter("squote", filters::squote);
124        env.add_filter("nindent", filters::nindent);
125        env.add_filter("indent", filters::indent);
126        env.add_filter("required", filters::required);
127        env.add_filter("empty", filters::empty);
128        env.add_filter("haskey", filters::haskey);
129        env.add_filter("keys", filters::keys);
130        env.add_filter("merge", filters::merge);
131        env.add_filter("sha256", filters::sha256sum);
132        env.add_filter("trunc", filters::trunc);
133        env.add_filter("trimprefix", filters::trimprefix);
134        env.add_filter("trimsuffix", filters::trimsuffix);
135        env.add_filter("snakecase", filters::snakecase);
136        env.add_filter("kebabcase", filters::kebabcase);
137        env.add_filter("tostrings", filters::tostrings);
138        env.add_filter("semver_match", filters::semver_match);
139        env.add_filter("int", filters::int);
140        env.add_filter("float", filters::float);
141        env.add_filter("abs", filters::abs);
142
143        // Path filters
144        env.add_filter("basename", filters::basename);
145        env.add_filter("dirname", filters::dirname);
146        env.add_filter("extname", filters::extname);
147        env.add_filter("cleanpath", filters::cleanpath);
148
149        // Regex filters
150        env.add_filter("regex_match", filters::regex_match);
151        env.add_filter("regex_replace", filters::regex_replace);
152        env.add_filter("regex_find", filters::regex_find);
153        env.add_filter("regex_find_all", filters::regex_find_all);
154
155        // Dict filters
156        env.add_filter("values", filters::values);
157        env.add_filter("pick", filters::pick);
158        env.add_filter("omit", filters::omit);
159
160        // List filters
161        env.add_filter("append", filters::append);
162        env.add_filter("prepend", filters::prepend);
163        env.add_filter("concat", filters::concat);
164        env.add_filter("without", filters::without);
165        env.add_filter("compact", filters::compact);
166
167        // Math filters
168        env.add_filter("floor", filters::floor);
169        env.add_filter("ceil", filters::ceil);
170
171        // Crypto filters
172        env.add_filter("sha1", filters::sha1sum);
173        env.add_filter("sha512", filters::sha512sum);
174        env.add_filter("md5", filters::md5sum);
175
176        // String filters
177        env.add_filter("repeat", filters::repeat);
178        env.add_filter("camelcase", filters::camelcase);
179        env.add_filter("pascalcase", filters::pascalcase);
180        env.add_filter("substr", filters::substr);
181        env.add_filter("wrap", filters::wrap);
182        env.add_filter("hasprefix", filters::hasprefix);
183        env.add_filter("hassuffix", filters::hassuffix);
184
185        // Register global functions
186        env.add_function("fail", functions::fail);
187        env.add_function("dict", functions::dict);
188        env.add_function("list", functions::list);
189        env.add_function("get", functions::get);
190        env.add_function("set", functions::set);
191        env.add_function("unset", functions::unset);
192        env.add_function("dig", functions::dig);
193        env.add_function("coalesce", functions::coalesce);
194        env.add_function("ternary", functions::ternary);
195        env.add_function("uuidv4", functions::uuidv4);
196        env.add_function("tostring", functions::tostring);
197        env.add_function("toint", functions::toint);
198        env.add_function("tofloat", functions::tofloat);
199        env.add_function("now", functions::now);
200        env.add_function("printf", functions::printf);
201        env.add_function("tpl", functions::tpl);
202        env.add_function("tpl_ctx", functions::tpl_ctx);
203        env.add_function("lookup", functions::lookup);
204
205        env
206    }
207
208    /// Render a single template string
209    pub fn render_string(
210        &self,
211        template: &str,
212        context: &TemplateContext,
213        template_name: &str,
214    ) -> Result<String> {
215        let env = self.create_environment();
216
217        // Add template to environment
218        let mut env = env;
219        env.add_template_owned(template_name.to_string(), template.to_string())
220            .map_err(|e| {
221                EngineError::Template(Box::new(TemplateError::from_minijinja(
222                    e,
223                    template_name,
224                    template,
225                )))
226            })?;
227
228        // Get template and render
229        let tmpl = env.get_template(template_name).map_err(|e| {
230            EngineError::Template(Box::new(TemplateError::from_minijinja(
231                e,
232                template_name,
233                template,
234            )))
235        })?;
236
237        // Build context
238        let ctx = minijinja::context! {
239            values => &context.values,
240            release => &context.release,
241            pack => &context.pack,
242            capabilities => &context.capabilities,
243            template => &context.template,
244        };
245
246        tmpl.render(ctx).map_err(|e| {
247            EngineError::Template(Box::new(TemplateError::from_minijinja(
248                e,
249                template_name,
250                template,
251            )))
252        })
253    }
254
255    /// Render all templates in a pack
256    ///
257    /// This is a convenience wrapper around `render_pack_collect_errors` that
258    /// returns the first error encountered, suitable for most use cases.
259    pub fn render_pack(
260        &self,
261        pack: &LoadedPack,
262        context: &TemplateContext,
263    ) -> Result<RenderResult> {
264        let result = self.render_pack_collect_errors(pack, context);
265
266        // If there were any errors, return the first one
267        if result.report.has_errors() {
268            // Get the first error from the report
269            let first_error = result
270                .report
271                .errors_by_template
272                .into_values()
273                .next()
274                .and_then(|errors| errors.into_iter().next());
275
276            return Err(match first_error {
277                Some(err) => EngineError::Template(Box::new(err)),
278                None => {
279                    EngineError::Template(Box::new(TemplateError::simple("Unknown template error")))
280                }
281            });
282        }
283
284        Ok(RenderResult {
285            manifests: result.manifests,
286            notes: result.notes,
287        })
288    }
289
290    /// Render all templates in a pack, collecting all errors instead of stopping at the first
291    ///
292    /// Unlike `render_pack`, this method continues after errors and returns
293    /// a comprehensive report of all issues found.
294    pub fn render_pack_collect_errors(
295        &self,
296        pack: &LoadedPack,
297        context: &TemplateContext,
298    ) -> RenderResultWithReport {
299        let mut report = RenderReport::new();
300        let mut manifests = IndexMap::new();
301        let mut notes = None;
302
303        let template_files = match pack.template_files() {
304            Ok(files) => files,
305            Err(e) => {
306                report.add_error(
307                    "<pack>".to_string(),
308                    TemplateError::simple(format!("Failed to list templates: {}", e)),
309                );
310                return RenderResultWithReport {
311                    manifests,
312                    notes,
313                    report,
314                };
315            }
316        };
317
318        // Create environment with all templates loaded
319        let mut env = self.create_environment();
320        let templates_dir = &pack.templates_dir;
321
322        // Track template sources for error reporting
323        let mut template_sources: HashMap<String, String> = HashMap::new();
324
325        // Load all templates - continue even if some fail to parse
326        for file_path in &template_files {
327            let rel_path = file_path.strip_prefix(templates_dir).unwrap_or(file_path);
328            let template_name = rel_path.to_string_lossy().into_owned();
329
330            let content = match std::fs::read_to_string(file_path) {
331                Ok(c) => c,
332                Err(e) => {
333                    report.add_error(
334                        template_name,
335                        TemplateError::simple(format!("Failed to read template: {}", e)),
336                    );
337                    continue;
338                }
339            };
340
341            // Store content first, then add to environment
342            // This avoids cloning content twice
343            if let Err(e) = env.add_template_owned(template_name.clone(), content.clone()) {
344                report.add_error(
345                    template_name.clone(),
346                    TemplateError::from_minijinja_enhanced(
347                        e,
348                        &template_name,
349                        &content,
350                        Some(&context.values),
351                    ),
352                );
353            }
354            // Store after attempting to add (content is still valid)
355            template_sources.insert(template_name, content);
356        }
357
358        // Add context as globals so imported macros can access them
359        // This is necessary because MiniJinja macros don't automatically get the render context
360        env.add_global("values", minijinja::Value::from_serialize(&context.values));
361        env.add_global(
362            "release",
363            minijinja::Value::from_serialize(&context.release),
364        );
365        env.add_global("pack", minijinja::Value::from_serialize(&context.pack));
366        env.add_global(
367            "capabilities",
368            minijinja::Value::from_serialize(&context.capabilities),
369        );
370        env.add_global(
371            "template",
372            minijinja::Value::from_serialize(&context.template),
373        );
374
375        // Add Files API - provides sandboxed access to pack files from templates
376        // Usage: {{ files.get("config/app.conf") }}, files.exists(), files.glob(), files.lines()
377        match SandboxedFileProvider::new(&pack.root) {
378            Ok(provider) => {
379                env.add_global("files", create_files_value_from_provider(provider));
380            }
381            Err(e) => {
382                report.add_warning(
383                    "files_api",
384                    format!(
385                        "Files API unavailable: {}. Templates using `files.*` will fail.",
386                        e
387                    ),
388                );
389            }
390        }
391
392        // Build render context (still needed for direct template rendering)
393        let ctx = minijinja::context! {
394            values => &context.values,
395            release => &context.release,
396            pack => &context.pack,
397            capabilities => &context.capabilities,
398            template => &context.template,
399        };
400
401        // Render each non-helper template, collecting errors
402        for file_path in &template_files {
403            let rel_path = file_path.strip_prefix(templates_dir).unwrap_or(file_path);
404            let template_name = rel_path.to_string_lossy().into_owned();
405
406            // Skip helper templates (prefixed with '_')
407            let file_stem = rel_path
408                .file_name()
409                .map(|s| s.to_string_lossy())
410                .unwrap_or_default();
411
412            if file_stem.starts_with(HELPER_TEMPLATE_PREFIX) {
413                continue;
414            }
415
416            // Try to get template (may have failed during loading)
417            let tmpl = match env.get_template(&template_name) {
418                Ok(t) => t,
419                Err(_) => {
420                    // Error already recorded during loading
421                    continue;
422                }
423            };
424
425            // Try to render
426            match tmpl.render(&ctx) {
427                Ok(rendered) => {
428                    // Process successful render
429                    if template_name
430                        .to_lowercase()
431                        .contains(NOTES_TEMPLATE_PATTERN)
432                    {
433                        notes = Some(rendered);
434                    } else {
435                        let trimmed = rendered.trim();
436                        if !trimmed.is_empty() && trimmed != "---" {
437                            let output_name = template_name
438                                .trim_end_matches(".j2")
439                                .trim_end_matches(".jinja2");
440                            manifests.insert(output_name.to_string(), rendered);
441                        }
442                    }
443                    report.add_success(template_name);
444                }
445                Err(e) => {
446                    // Get template source for error context
447                    // Use empty string only if template was never loaded (shouldn't happen)
448                    let content = template_sources
449                        .get(&template_name)
450                        .map(String::as_str)
451                        .unwrap_or("");
452
453                    report.add_error(
454                        template_name.clone(),
455                        TemplateError::from_minijinja_enhanced(
456                            e,
457                            &template_name,
458                            content,
459                            Some(&context.values),
460                        ),
461                    );
462                }
463            }
464        }
465
466        RenderResultWithReport {
467            manifests,
468            notes,
469            report,
470        }
471    }
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477    use semver::Version;
478    use sherpack_core::{PackMetadata, ReleaseInfo, Values};
479
480    fn create_test_context() -> TemplateContext {
481        let values = Values::from_yaml(
482            r#"
483image:
484  repository: nginx
485  tag: "1.25"
486replicas: 3
487"#,
488        )
489        .unwrap();
490
491        let release = ReleaseInfo::for_install("myapp", "default");
492
493        let pack = PackMetadata {
494            name: "mypack".to_string(),
495            version: Version::new(1, 0, 0),
496            description: None,
497            app_version: Some("2.0.0".to_string()),
498            kube_version: None,
499            home: None,
500            icon: None,
501            sources: vec![],
502            keywords: vec![],
503            maintainers: vec![],
504            annotations: Default::default(),
505        };
506
507        TemplateContext::new(values, release, &pack)
508    }
509
510    #[test]
511    fn test_render_simple() {
512        let engine = Engine::new(true);
513        let ctx = create_test_context();
514
515        let template = "replicas: {{ values.replicas }}";
516        let result = engine.render_string(template, &ctx, "test.yaml").unwrap();
517
518        assert_eq!(result, "replicas: 3");
519    }
520
521    #[test]
522    fn test_render_with_filters() {
523        let engine = Engine::new(true);
524        let ctx = create_test_context();
525
526        let template = r#"image: {{ values.image | toyaml | nindent(2) }}"#;
527        let result = engine.render_string(template, &ctx, "test.yaml").unwrap();
528
529        assert!(result.contains("repository: nginx"));
530        assert!(result.contains("tag:"));
531    }
532
533    #[test]
534    fn test_render_release_info() {
535        let engine = Engine::new(true);
536        let ctx = create_test_context();
537
538        let template = "name: {{ release.name }}\nnamespace: {{ release.namespace }}";
539        let result = engine.render_string(template, &ctx, "test.yaml").unwrap();
540
541        assert!(result.contains("name: myapp"));
542        assert!(result.contains("namespace: default"));
543    }
544
545    #[test]
546    fn test_chainable_undefined_returns_empty() {
547        // With UndefinedBehavior::Chainable, undefined keys return empty string
548        // This matches Helm's behavior for optional values
549        let engine = Engine::new(true);
550        let ctx = create_test_context();
551
552        let template = "value: {{ values.undefined_key }}";
553        let result = engine.render_string(template, &ctx, "test.yaml");
554
555        // Chainable mode: undefined attributes return empty, not error
556        assert!(result.is_ok());
557        let output = result.unwrap();
558        assert_eq!(output.trim(), "value:");
559    }
560
561    #[test]
562    fn test_chainable_typo_returns_empty() {
563        // With UndefinedBehavior::Chainable, even top-level undefined vars return empty
564        // This is intentional for Helm compatibility (optional values pattern)
565        let engine = Engine::new(true);
566        let ctx = create_test_context();
567
568        // Common typo: "value" instead of "values"
569        let template = "name: {{ value.app.name }}";
570        let result = engine.render_string(template, &ctx, "test.yaml");
571
572        // Chainable mode allows this - returns empty
573        // Users should rely on linting and unknown filter errors to catch typos
574        assert!(result.is_ok());
575        let output = result.unwrap();
576        assert_eq!(output.trim(), "name:");
577    }
578
579    #[test]
580    fn test_render_string_unknown_filter() {
581        let engine = Engine::new(true);
582        let ctx = create_test_context();
583
584        let template = "name: {{ values.image.repository | unknownfilter }}";
585        let result = engine.render_string(template, &ctx, "test.yaml");
586
587        assert!(result.is_err());
588    }
589
590    #[test]
591    fn test_render_result_with_report_structure() {
592        use crate::error::{RenderReport, RenderResultWithReport};
593
594        // Test successful result
595        let result = RenderResultWithReport {
596            manifests: {
597                let mut m = IndexMap::new();
598                m.insert("deployment.yaml".to_string(), "apiVersion: v1".to_string());
599                m
600            },
601            notes: Some("Install notes".to_string()),
602            report: RenderReport::new(),
603        };
604
605        assert!(result.is_success());
606        assert_eq!(result.manifests.len(), 1);
607        assert!(result.notes.is_some());
608    }
609
610    #[test]
611    fn test_render_result_partial_success() {
612        use crate::error::{RenderReport, RenderResultWithReport, TemplateError};
613
614        let mut report = RenderReport::new();
615        report.add_success("good.yaml".to_string());
616        report.add_error(
617            "bad.yaml".to_string(),
618            TemplateError::simple("undefined variable"),
619        );
620
621        let result = RenderResultWithReport {
622            manifests: {
623                let mut m = IndexMap::new();
624                m.insert("good.yaml".to_string(), "content".to_string());
625                m
626            },
627            notes: None,
628            report,
629        };
630
631        // Not a success because there was an error
632        assert!(!result.is_success());
633        // But we still have partial results
634        assert_eq!(result.manifests.len(), 1);
635        assert!(result.manifests.contains_key("good.yaml"));
636    }
637}