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