Skip to main content

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