1use indexmap::IndexMap;
7use std::collections::HashMap;
8
9use serde_json::Value as JsonValue;
10use sherpack_core::{Dependency, LoadedPack, TemplateContext, Values};
11
12use crate::engine::Engine;
13use crate::error::{EngineError, RenderIssue, RenderReport, TemplateError};
14use crate::subchart::{DiscoveryResult, SubchartConfig, SubchartInfo};
15
16#[derive(Debug)]
18pub struct PackRenderResult {
19 pub manifests: IndexMap<String, String>,
22
23 pub notes: Option<String>,
25
26 pub discovery: DiscoveryResult,
28}
29
30pub struct PackRenderer {
32 engine: Engine,
33 config: SubchartConfig,
34}
35
36impl PackRenderer {
37 pub fn new(engine: Engine) -> Self {
39 Self {
40 engine,
41 config: SubchartConfig::default(),
42 }
43 }
44
45 pub fn with_config(engine: Engine, config: SubchartConfig) -> Self {
47 Self { engine, config }
48 }
49
50 pub fn builder() -> PackRendererBuilder {
52 PackRendererBuilder::default()
53 }
54
55 pub fn engine(&self) -> &Engine {
57 &self.engine
58 }
59
60 pub fn config(&self) -> &SubchartConfig {
62 &self.config
63 }
64
65 pub fn discover_subcharts(&self, pack: &LoadedPack, values: &JsonValue) -> DiscoveryResult {
70 let mut result = DiscoveryResult::new();
71 let subcharts_dir = pack.root.join(&self.config.subcharts_dir);
72
73 let deps_by_name: HashMap<&str, &Dependency> = pack
75 .pack
76 .dependencies
77 .iter()
78 .map(|d| (d.effective_name(), d))
79 .collect();
80
81 if !subcharts_dir.exists() {
83 return result;
85 }
86
87 let entries = match std::fs::read_dir(&subcharts_dir) {
89 Ok(e) => e,
90 Err(e) => {
91 result.warnings.push(format!(
92 "Failed to read subcharts directory '{}': {}",
93 subcharts_dir.display(),
94 e
95 ));
96 return result;
97 }
98 };
99
100 for entry in entries {
101 let entry = match entry {
102 Ok(e) => e,
103 Err(e) => {
104 result
105 .warnings
106 .push(format!("Failed to read directory entry: {}", e));
107 continue;
108 }
109 };
110
111 let path = entry.path();
112 if !path.is_dir() {
113 continue;
114 }
115
116 let dir_name = match path.file_name().and_then(|n| n.to_str()) {
117 Some(n) => n.to_string(),
118 None => continue,
119 };
120
121 let subchart_pack = match LoadedPack::load(&path) {
123 Ok(p) => p,
124 Err(e) => {
125 result
126 .warnings
127 .push(format!("Failed to load subchart '{}': {}", dir_name, e));
128 continue;
129 }
130 };
131
132 let dependency = deps_by_name.get(dir_name.as_str()).cloned().cloned();
134
135 let name = dependency
137 .as_ref()
138 .and_then(|d| d.alias.clone())
139 .unwrap_or_else(|| dir_name.clone());
140
141 let (enabled, disabled_reason) = self.evaluate_condition(&dependency, values);
143
144 result.subcharts.push(SubchartInfo {
145 name,
146 path,
147 pack: subchart_pack,
148 enabled,
149 dependency,
150 disabled_reason,
151 });
152 }
153
154 for dep in &pack.pack.dependencies {
156 if dep.enabled {
157 let name = dep.effective_name();
158 let found = result.subcharts.iter().any(|s| s.name == name);
159 if !found {
160 result.missing.push(name.to_string());
161 }
162 }
163 }
164
165 result.subcharts.sort_by(|a, b| a.name.cmp(&b.name));
167
168 result
169 }
170
171 fn evaluate_condition(
173 &self,
174 dependency: &Option<Dependency>,
175 values: &JsonValue,
176 ) -> (bool, Option<String>) {
177 let Some(dep) = dependency else {
178 return (true, None);
180 };
181
182 if !dep.enabled {
184 return (
185 false,
186 Some("Statically disabled (enabled: false)".to_string()),
187 );
188 }
189
190 if let Some(condition) = &dep.condition {
192 let condition_met = evaluate_condition_path(condition, values);
193 if !condition_met {
194 return (
195 false,
196 Some(format!("Condition '{}' evaluated to false", condition)),
197 );
198 }
199 }
200
201 (true, None)
202 }
203
204 pub fn render(
213 &self,
214 pack: &LoadedPack,
215 context: &TemplateContext,
216 ) -> Result<PackRenderResult, EngineError> {
217 let result = self.render_collect_errors(pack, context);
218
219 if result.report.has_errors() {
220 let first_error = result
222 .report
223 .errors_by_template
224 .into_values()
225 .next()
226 .and_then(|errors| errors.into_iter().next());
227
228 return Err(match first_error {
229 Some(err) => EngineError::Template(Box::new(err)),
230 None => EngineError::Template(Box::new(TemplateError::simple(
231 "Unknown template error during subchart rendering",
232 ))),
233 });
234 }
235
236 Ok(PackRenderResult {
237 manifests: result.manifests,
238 notes: result.notes,
239 discovery: result.discovery,
240 })
241 }
242
243 pub fn render_collect_errors(
245 &self,
246 pack: &LoadedPack,
247 context: &TemplateContext,
248 ) -> PackRenderResultWithReport {
249 self.render_recursive(pack, context, 0)
250 }
251
252 fn render_recursive(
254 &self,
255 pack: &LoadedPack,
256 context: &TemplateContext,
257 depth: usize,
258 ) -> PackRenderResultWithReport {
259 let mut report = RenderReport::new();
260 let mut all_manifests = IndexMap::new();
261 let mut notes = None;
262
263 if depth > self.config.max_depth {
265 report.add_warning(
266 "subchart",
267 format!(
268 "Maximum subchart depth ({}) exceeded, stopping recursion",
269 self.config.max_depth
270 ),
271 );
272 return PackRenderResultWithReport {
273 manifests: all_manifests,
274 notes,
275 report,
276 discovery: DiscoveryResult::new(),
277 };
278 }
279
280 let discovery = self.discover_subcharts(pack, &context.values);
282
283 for warning in &discovery.warnings {
285 report.add_warning("subchart_discovery", warning.clone());
286 }
287
288 for missing in &discovery.missing {
290 if self.config.strict {
291 report.add_error(
292 format!("<subchart:{}>", missing),
293 TemplateError::simple(format!(
294 "Missing subchart '{}' referenced in dependencies",
295 missing
296 )),
297 );
298 } else {
299 report.add_warning(
300 "subchart_missing",
301 format!(
302 "Subchart '{}' not found in {}/",
303 missing, self.config.subcharts_dir
304 ),
305 );
306 }
307 }
308
309 for subchart in &discovery.subcharts {
311 if !subchart.enabled {
312 if let Some(reason) = &subchart.disabled_reason {
314 report.add_issue(RenderIssue::warning(
315 "subchart_disabled",
316 format!("Subchart '{}' disabled: {}", subchart.name, reason),
317 ));
318 }
319 continue;
320 }
321
322 let subchart_defaults = if subchart.pack.values_path.exists() {
324 match Values::from_file(&subchart.pack.values_path) {
325 Ok(v) => v,
326 Err(e) => {
327 report.add_warning(
328 "subchart_values",
329 format!("Failed to load values.yaml for '{}': {}", subchart.name, e),
330 );
331 Values::new()
332 }
333 }
334 } else {
335 Values::new()
336 };
337
338 let scoped_values =
340 Values::for_subchart_json(subchart_defaults, &context.values, &subchart.name);
341
342 let subchart_context = TemplateContext::new(
344 scoped_values,
345 context.release.clone(),
346 &subchart.pack.pack.metadata,
347 );
348
349 let subchart_result =
351 self.render_recursive(&subchart.pack, &subchart_context, depth + 1);
352
353 for (name, manifest) in subchart_result.manifests {
355 let prefixed_name = format!("{}/{}", subchart.name, name);
356 all_manifests.insert(prefixed_name, manifest);
357 }
358
359 for (template, errors) in subchart_result.report.errors_by_template {
361 let prefixed = format!("{}/{}", subchart.name, template);
362 for error in errors {
363 report.add_error(prefixed.clone(), error);
364 }
365 }
366
367 for issue in subchart_result.report.issues {
369 report.add_issue(issue);
370 }
371
372 }
374
375 let parent_result = self.engine.render_pack_collect_errors(pack, context);
377
378 all_manifests.extend(parent_result.manifests);
380 notes = parent_result.notes;
381
382 for (template, errors) in parent_result.report.errors_by_template {
384 for error in errors {
385 report.add_error(template.clone(), error);
386 }
387 }
388 for issue in parent_result.report.issues {
389 report.add_issue(issue);
390 }
391 for success in parent_result.report.successful_templates {
392 report.add_success(success);
393 }
394
395 PackRenderResultWithReport {
396 manifests: all_manifests,
397 notes,
398 report,
399 discovery,
400 }
401 }
402}
403
404#[derive(Debug)]
406pub struct PackRenderResultWithReport {
407 pub manifests: IndexMap<String, String>,
409
410 pub notes: Option<String>,
412
413 pub report: RenderReport,
415
416 pub discovery: DiscoveryResult,
418}
419
420impl PackRenderResultWithReport {
421 pub fn is_success(&self) -> bool {
423 !self.report.has_errors()
424 }
425}
426
427#[derive(Default)]
429pub struct PackRendererBuilder {
430 strict_mode: bool,
431 max_depth: Option<usize>,
432 subcharts_dir: Option<String>,
433}
434
435impl PackRendererBuilder {
436 pub fn strict(mut self, strict: bool) -> Self {
438 self.strict_mode = strict;
439 self
440 }
441
442 pub fn max_depth(mut self, depth: usize) -> Self {
444 self.max_depth = Some(depth);
445 self
446 }
447
448 pub fn subcharts_dir(mut self, dir: impl Into<String>) -> Self {
450 self.subcharts_dir = Some(dir.into());
451 self
452 }
453
454 pub fn build(self) -> PackRenderer {
456 let engine = if self.strict_mode {
457 Engine::strict()
458 } else {
459 Engine::lenient()
460 };
461
462 let mut config = SubchartConfig::default();
463 if let Some(depth) = self.max_depth {
464 config.max_depth = depth;
465 }
466 if let Some(dir) = self.subcharts_dir {
467 config.subcharts_dir = dir;
468 }
469 if self.strict_mode {
470 config.strict = true;
471 }
472
473 PackRenderer { engine, config }
474 }
475}
476
477fn evaluate_condition_path(condition: &str, values: &serde_json::Value) -> bool {
481 let parts: Vec<&str> = condition.split('.').collect();
482
483 let mut current = values;
484 for part in &parts {
485 match current.get(part) {
486 Some(v) => current = v,
487 None => return false,
488 }
489 }
490
491 match current {
493 serde_json::Value::Bool(b) => *b,
494 serde_json::Value::Null => false,
495 serde_json::Value::String(s) => !s.is_empty() && s != "false" && s != "0",
496 serde_json::Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
497 serde_json::Value::Array(a) => !a.is_empty(),
498 serde_json::Value::Object(o) => !o.is_empty(),
499 }
500}
501
502#[cfg(test)]
503mod tests {
504 use super::*;
505
506 #[test]
507 fn test_evaluate_condition_path_bool() {
508 let values = serde_json::json!({
509 "redis": {
510 "enabled": true
511 },
512 "postgresql": {
513 "enabled": false
514 }
515 });
516
517 assert!(evaluate_condition_path("redis.enabled", &values));
518 assert!(!evaluate_condition_path("postgresql.enabled", &values));
519 }
520
521 #[test]
522 fn test_evaluate_condition_path_missing() {
523 let values = serde_json::json!({
524 "redis": {}
525 });
526
527 assert!(!evaluate_condition_path("redis.enabled", &values));
528 assert!(!evaluate_condition_path("nonexistent.path", &values));
529 }
530
531 #[test]
532 fn test_evaluate_condition_path_truthy() {
533 let values = serde_json::json!({
534 "string_yes": "yes",
535 "string_empty": "",
536 "number_one": 1,
537 "number_zero": 0,
538 "array_full": [1, 2],
539 "array_empty": []
540 });
541
542 assert!(evaluate_condition_path("string_yes", &values));
543 assert!(!evaluate_condition_path("string_empty", &values));
544 assert!(evaluate_condition_path("number_one", &values));
545 assert!(!evaluate_condition_path("number_zero", &values));
546 assert!(evaluate_condition_path("array_full", &values));
547 assert!(!evaluate_condition_path("array_empty", &values));
548 }
549
550 #[test]
551 fn test_pack_renderer_builder() {
552 let renderer = PackRenderer::builder()
553 .strict(true)
554 .max_depth(5)
555 .subcharts_dir("deps")
556 .build();
557
558 assert_eq!(renderer.config.max_depth, 5);
559 assert_eq!(renderer.config.subcharts_dir, "deps");
560 assert!(renderer.config.strict);
561 }
562
563 #[test]
564 fn test_pack_render_result_with_report_success() {
565 let result = PackRenderResultWithReport {
566 manifests: IndexMap::new(),
567 notes: None,
568 report: RenderReport::new(),
569 discovery: DiscoveryResult::new(),
570 };
571
572 assert!(result.is_success());
573 }
574
575 #[test]
576 fn test_discover_subcharts_with_fixture() {
577 use std::path::PathBuf;
578
579 let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
580 .parent()
581 .unwrap()
582 .parent()
583 .unwrap()
584 .join("fixtures/pack-with-subcharts");
585
586 if !fixture_path.exists() {
587 return;
589 }
590
591 let pack = LoadedPack::load(&fixture_path).expect("Failed to load fixture");
592 let renderer = PackRenderer::new(Engine::lenient());
593
594 let values = serde_json::json!({
595 "redis": { "enabled": true },
596 "postgresql": { "enabled": false }
597 });
598
599 let discovery = renderer.discover_subcharts(&pack, &values);
600
601 assert_eq!(discovery.subcharts.len(), 2);
603
604 let redis = discovery.subcharts.iter().find(|s| s.name == "redis");
606 assert!(redis.is_some());
607 assert!(redis.unwrap().enabled);
608
609 let pg = discovery.subcharts.iter().find(|s| s.name == "postgresql");
611 assert!(pg.is_some());
612 assert!(!pg.unwrap().enabled);
613 }
614
615 #[test]
616 fn test_render_pack_with_subcharts() {
617 use sherpack_core::ReleaseInfo;
618 use std::path::PathBuf;
619
620 let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
621 .parent()
622 .unwrap()
623 .parent()
624 .unwrap()
625 .join("fixtures/pack-with-subcharts");
626
627 if !fixture_path.exists() {
628 return;
629 }
630
631 let pack = LoadedPack::load(&fixture_path).expect("Failed to load fixture");
632 let renderer = PackRenderer::new(Engine::lenient());
633
634 let values = Values::from_yaml(
635 r#"
636global:
637 imageRegistry: docker.io
638 pullPolicy: IfNotPresent
639app:
640 name: my-application
641 replicas: 2
642 image:
643 repository: myapp
644 tag: "1.0.0"
645redis:
646 enabled: true
647 replicas: 3
648 auth:
649 enabled: true
650 password: secret123
651postgresql:
652 enabled: false
653"#,
654 )
655 .expect("Failed to parse values");
656
657 let release = ReleaseInfo::for_install("test-release", "default");
658 let context = TemplateContext::new(values, release, &pack.pack.metadata);
659
660 let result = renderer.render(&pack, &context).expect("Render failed");
661
662 assert!(result.manifests.contains_key("deployment.yaml"));
664
665 assert!(result.manifests.contains_key("redis/deployment.yaml"));
667
668 let has_postgresql = result
670 .manifests
671 .keys()
672 .any(|k| k.starts_with("postgresql/"));
673 assert!(!has_postgresql, "PostgreSQL should be disabled");
674
675 let redis_manifest = result.manifests.get("redis/deployment.yaml").unwrap();
677 assert!(
678 redis_manifest.contains("replicas: 3"),
679 "Should use parent's redis.replicas=3"
680 );
681 assert!(
682 redis_manifest.contains("REDIS_PASSWORD"),
683 "Auth should be enabled"
684 );
685
686 let parent_manifest = result.manifests.get("deployment.yaml").unwrap();
688 assert!(parent_manifest.contains("test-release-my-application"));
689 assert!(parent_manifest.contains("REDIS_HOST"));
690 assert!(
691 !parent_manifest.contains("DATABASE_HOST"),
692 "PostgreSQL env should not be present"
693 );
694 }
695
696 #[test]
697 fn test_subchart_global_values_passed() {
698 use sherpack_core::ReleaseInfo;
699 use std::path::PathBuf;
700
701 let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
702 .parent()
703 .unwrap()
704 .parent()
705 .unwrap()
706 .join("fixtures/pack-with-subcharts");
707
708 if !fixture_path.exists() {
709 return;
710 }
711
712 let pack = LoadedPack::load(&fixture_path).expect("Failed to load fixture");
713 let renderer = PackRenderer::new(Engine::lenient());
714
715 let values = Values::from_yaml(
716 r#"
717global:
718 imageRegistry: my-registry.io
719 pullPolicy: Always
720app:
721 name: my-app
722 replicas: 1
723 image:
724 repository: myapp
725 tag: "1.0"
726redis:
727 enabled: true
728postgresql:
729 enabled: false
730"#,
731 )
732 .expect("Failed to parse values");
733
734 let release = ReleaseInfo::for_install("test", "default");
735 let context = TemplateContext::new(values, release, &pack.pack.metadata);
736
737 let result = renderer.render(&pack, &context).expect("Render failed");
738
739 let redis_manifest = result.manifests.get("redis/deployment.yaml").unwrap();
741 assert!(
742 redis_manifest.contains("my-registry.io"),
743 "Should use global imageRegistry"
744 );
745 }
746}