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}
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 pub fn strict(mut self, strict: bool) -> Self {
50 self.strict_mode = strict;
51 self
52 }
53
54 pub fn build(self) -> Engine {
56 Engine::new(self.strict_mode)
57 }
58}
59
60pub struct Engine {
62 strict_mode: bool,
63}
64
65impl Engine {
66 pub fn new(strict_mode: bool) -> Self {
75 Self { strict_mode }
76 }
77
78 #[must_use]
83 pub fn strict() -> Self {
84 Self { strict_mode: true }
85 }
86
87 #[must_use]
92 pub fn lenient() -> Self {
93 Self { strict_mode: false }
94 }
95
96 #[must_use]
98 pub fn builder() -> EngineBuilder {
99 EngineBuilder::new()
100 }
101
102 fn create_environment(&self) -> Environment<'static> {
104 let mut env = Environment::new();
105
106 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 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 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 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 env.add_filter("values", filters::values);
157 env.add_filter("pick", filters::pick);
158 env.add_filter("omit", filters::omit);
159
160 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 env.add_filter("floor", filters::floor);
169 env.add_filter("ceil", filters::ceil);
170
171 env.add_filter("sha1", filters::sha1sum);
173 env.add_filter("sha512", filters::sha512sum);
174 env.add_filter("md5", filters::md5sum);
175
176 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 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 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 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 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 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 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 result.report.has_errors() {
268 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 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 let mut env = self.create_environment();
320 let templates_dir = &pack.templates_dir;
321
322 let mut template_sources: HashMap<String, String> = HashMap::new();
324
325 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 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 template_sources.insert(template_name, content);
356 }
357
358 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 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 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 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 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 let tmpl = match env.get_template(&template_name) {
418 Ok(t) => t,
419 Err(_) => {
420 continue;
422 }
423 };
424
425 match tmpl.render(&ctx) {
427 Ok(rendered) => {
428 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 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 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 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 let engine = Engine::new(true);
566 let ctx = create_test_context();
567
568 let template = "name: {{ value.app.name }}";
570 let result = engine.render_string(template, &ctx, "test.yaml");
571
572 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 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 assert!(!result.is_success());
633 assert_eq!(result.manifests.len(), 1);
635 assert!(result.manifests.contains_key("good.yaml"));
636 }
637}