1use 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
16const HELPER_TEMPLATE_PREFIX: char = '_';
18
19const NOTES_TEMPLATE_PATTERN: &str = "notes";
21
22#[derive(Debug)]
24pub struct RenderResult {
25 pub manifests: IndexMap<String, String>,
27
28 pub notes: Option<String>,
30}
31
32pub 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 pub fn strict(mut self, strict: bool) -> Self {
54 self.strict_mode = strict;
55 self
56 }
57
58 pub fn with_secret_state(mut self, state: crate::secrets::SecretFunctionState) -> Self {
63 self.secret_state = Some(state);
64 self
65 }
66
67 pub fn build(self) -> Engine {
69 Engine {
70 strict_mode: self.strict_mode,
71 secret_state: self.secret_state,
72 }
73 }
74}
75
76pub struct Engine {
78 strict_mode: bool,
79 secret_state: Option<crate::secrets::SecretFunctionState>,
80}
81
82impl Engine {
83 pub fn new(strict_mode: bool) -> Self {
92 Self {
93 strict_mode,
94 secret_state: None,
95 }
96 }
97
98 #[must_use]
103 pub fn strict() -> Self {
104 Self {
105 strict_mode: true,
106 secret_state: None,
107 }
108 }
109
110 #[must_use]
115 pub fn lenient() -> Self {
116 Self {
117 strict_mode: false,
118 secret_state: None,
119 }
120 }
121
122 #[must_use]
124 pub fn builder() -> EngineBuilder {
125 EngineBuilder::new()
126 }
127
128 pub fn secret_state(&self) -> Option<&crate::secrets::SecretFunctionState> {
130 self.secret_state.as_ref()
131 }
132
133 fn create_environment(&self) -> Environment<'static> {
135 let mut env = Environment::new();
136
137 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 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 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 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 env.add_filter("values", filters::values);
188 env.add_filter("pick", filters::pick);
189 env.add_filter("omit", filters::omit);
190
191 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 env.add_filter("floor", filters::floor);
200 env.add_filter("ceil", filters::ceil);
201
202 env.add_filter("sha1", filters::sha1sum);
204 env.add_filter("sha512", filters::sha512sum);
205 env.add_filter("md5", filters::md5sum);
206
207 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 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 if let Some(ref secret_state) = self.secret_state {
238 secret_state.register(&mut env);
239 }
240
241 env
242 }
243
244 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 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 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 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 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 result.report.has_errors() {
304 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 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 let mut env = self.create_environment();
356 let templates_dir = &pack.templates_dir;
357
358 let mut template_sources: HashMap<String, String> = HashMap::new();
360
361 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 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 template_sources.insert(template_name, content);
392 }
393
394 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 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 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 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 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 let tmpl = match env.get_template(&template_name) {
454 Ok(t) => t,
455 Err(_) => {
456 continue;
458 }
459 };
460
461 match tmpl.render(&ctx) {
463 Ok(rendered) => {
464 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 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 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 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 let engine = Engine::new(true);
602 let ctx = create_test_context();
603
604 let template = "name: {{ value.app.name }}";
606 let result = engine.render_string(template, &ctx, "test.yaml");
607
608 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 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 assert!(!result.is_success());
669 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 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 let template = r#"password: {{ generate_secret("db-password", 16) }}"#;
689 let result = engine.render_string(template, &ctx, "test.yaml").unwrap();
690
691 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 assert!(secret_state.is_dirty());
699
700 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 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 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 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 let persisted = secret_state1.take_state();
735 let json = serde_json::to_string(&persisted).unwrap();
736
737 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 let secret2 = engine2.render_string(template, &ctx, "test.yaml").unwrap();
746 assert_eq!(secret, secret2);
747
748 assert!(!secret_state2.is_dirty());
750 }
751}