use indexmap::IndexMap;
use std::collections::HashMap;
use serde_json::Value as JsonValue;
use sherpack_core::{Dependency, LoadedPack, TemplateContext, Values};
use crate::engine::Engine;
use crate::error::{EngineError, RenderIssue, RenderReport, TemplateError};
use crate::subchart::{DiscoveryResult, SubchartConfig, SubchartInfo};
#[derive(Debug)]
pub struct PackRenderResult {
pub manifests: IndexMap<String, String>,
pub notes: Option<String>,
pub discovery: DiscoveryResult,
}
pub struct PackRenderer {
engine: Engine,
config: SubchartConfig,
}
impl PackRenderer {
pub fn new(engine: Engine) -> Self {
Self {
engine,
config: SubchartConfig::default(),
}
}
pub fn with_config(engine: Engine, config: SubchartConfig) -> Self {
Self { engine, config }
}
pub fn builder() -> PackRendererBuilder {
PackRendererBuilder::default()
}
pub fn engine(&self) -> &Engine {
&self.engine
}
pub fn config(&self) -> &SubchartConfig {
&self.config
}
pub fn discover_subcharts(&self, pack: &LoadedPack, values: &JsonValue) -> DiscoveryResult {
let mut result = DiscoveryResult::new();
let subcharts_dir = pack.root.join(&self.config.subcharts_dir);
let deps_by_name: HashMap<&str, &Dependency> = pack
.pack
.dependencies
.iter()
.map(|d| (d.effective_name(), d))
.collect();
if !subcharts_dir.exists() {
return result;
}
let entries = match std::fs::read_dir(&subcharts_dir) {
Ok(e) => e,
Err(e) => {
result.warnings.push(format!(
"Failed to read subcharts directory '{}': {}",
subcharts_dir.display(),
e
));
return result;
}
};
for entry in entries {
let entry = match entry {
Ok(e) => e,
Err(e) => {
result
.warnings
.push(format!("Failed to read directory entry: {}", e));
continue;
}
};
let path = entry.path();
if !path.is_dir() {
continue;
}
let dir_name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n.to_string(),
None => continue,
};
let subchart_pack = match LoadedPack::load(&path) {
Ok(p) => p,
Err(e) => {
result
.warnings
.push(format!("Failed to load subchart '{}': {}", dir_name, e));
continue;
}
};
let dependency = deps_by_name.get(dir_name.as_str()).cloned().cloned();
let name = dependency
.as_ref()
.and_then(|d| d.alias.clone())
.unwrap_or_else(|| dir_name.clone());
let (enabled, disabled_reason) = self.evaluate_condition(&dependency, values);
result.subcharts.push(SubchartInfo {
name,
path,
pack: subchart_pack,
enabled,
dependency,
disabled_reason,
});
}
for dep in &pack.pack.dependencies {
if dep.enabled {
let name = dep.effective_name();
let found = result.subcharts.iter().any(|s| s.name == name);
if !found {
result.missing.push(name.to_string());
}
}
}
result.subcharts.sort_by(|a, b| a.name.cmp(&b.name));
result
}
fn evaluate_condition(
&self,
dependency: &Option<Dependency>,
values: &JsonValue,
) -> (bool, Option<String>) {
let Some(dep) = dependency else {
return (true, None);
};
if !dep.enabled {
return (
false,
Some("Statically disabled (enabled: false)".to_string()),
);
}
if let Some(condition) = &dep.condition {
let condition_met = evaluate_condition_path(condition, values);
if !condition_met {
return (
false,
Some(format!("Condition '{}' evaluated to false", condition)),
);
}
}
(true, None)
}
pub fn render(
&self,
pack: &LoadedPack,
context: &TemplateContext,
) -> Result<PackRenderResult, EngineError> {
let result = self.render_collect_errors(pack, context);
if result.report.has_errors() {
let first_error = result
.report
.errors_by_template
.into_values()
.next()
.and_then(|errors| errors.into_iter().next());
return Err(match first_error {
Some(err) => EngineError::Template(Box::new(err)),
None => EngineError::Template(Box::new(TemplateError::simple(
"Unknown template error during subchart rendering",
))),
});
}
Ok(PackRenderResult {
manifests: result.manifests,
notes: result.notes,
discovery: result.discovery,
})
}
pub fn render_collect_errors(
&self,
pack: &LoadedPack,
context: &TemplateContext,
) -> PackRenderResultWithReport {
self.render_recursive(pack, context, 0)
}
fn render_recursive(
&self,
pack: &LoadedPack,
context: &TemplateContext,
depth: usize,
) -> PackRenderResultWithReport {
let mut report = RenderReport::new();
let mut all_manifests = IndexMap::new();
let mut notes = None;
if depth > self.config.max_depth {
report.add_warning(
"subchart",
format!(
"Maximum subchart depth ({}) exceeded, stopping recursion",
self.config.max_depth
),
);
return PackRenderResultWithReport {
manifests: all_manifests,
notes,
report,
discovery: DiscoveryResult::new(),
};
}
let discovery = self.discover_subcharts(pack, &context.values);
for warning in &discovery.warnings {
report.add_warning("subchart_discovery", warning.clone());
}
for missing in &discovery.missing {
if self.config.strict {
report.add_error(
format!("<subchart:{}>", missing),
TemplateError::simple(format!(
"Missing subchart '{}' referenced in dependencies",
missing
)),
);
} else {
report.add_warning(
"subchart_missing",
format!(
"Subchart '{}' not found in {}/",
missing, self.config.subcharts_dir
),
);
}
}
for subchart in &discovery.subcharts {
if !subchart.enabled {
if let Some(reason) = &subchart.disabled_reason {
report.add_issue(RenderIssue::warning(
"subchart_disabled",
format!("Subchart '{}' disabled: {}", subchart.name, reason),
));
}
continue;
}
let subchart_defaults = if subchart.pack.values_path.exists() {
match Values::from_file(&subchart.pack.values_path) {
Ok(v) => v,
Err(e) => {
report.add_warning(
"subchart_values",
format!("Failed to load values.yaml for '{}': {}", subchart.name, e),
);
Values::new()
}
}
} else {
Values::new()
};
let scoped_values =
Values::for_subchart_json(subchart_defaults, &context.values, &subchart.name);
let subchart_context = TemplateContext::new(
scoped_values,
context.release.clone(),
&subchart.pack.pack.metadata,
);
let subchart_result =
self.render_recursive(&subchart.pack, &subchart_context, depth + 1);
for (name, manifest) in subchart_result.manifests {
let prefixed_name = format!("{}/{}", subchart.name, name);
all_manifests.insert(prefixed_name, manifest);
}
for (template, errors) in subchart_result.report.errors_by_template {
let prefixed = format!("{}/{}", subchart.name, template);
for error in errors {
report.add_error(prefixed.clone(), error);
}
}
for issue in subchart_result.report.issues {
report.add_issue(issue);
}
}
let parent_result = self.engine.render_pack_collect_errors(pack, context);
all_manifests.extend(parent_result.manifests);
notes = parent_result.notes;
for (template, errors) in parent_result.report.errors_by_template {
for error in errors {
report.add_error(template.clone(), error);
}
}
for issue in parent_result.report.issues {
report.add_issue(issue);
}
for success in parent_result.report.successful_templates {
report.add_success(success);
}
PackRenderResultWithReport {
manifests: all_manifests,
notes,
report,
discovery,
}
}
}
#[derive(Debug)]
pub struct PackRenderResultWithReport {
pub manifests: IndexMap<String, String>,
pub notes: Option<String>,
pub report: RenderReport,
pub discovery: DiscoveryResult,
}
impl PackRenderResultWithReport {
pub fn is_success(&self) -> bool {
!self.report.has_errors()
}
}
#[derive(Default)]
pub struct PackRendererBuilder {
strict_mode: bool,
max_depth: Option<usize>,
subcharts_dir: Option<String>,
}
impl PackRendererBuilder {
pub fn strict(mut self, strict: bool) -> Self {
self.strict_mode = strict;
self
}
pub fn max_depth(mut self, depth: usize) -> Self {
self.max_depth = Some(depth);
self
}
pub fn subcharts_dir(mut self, dir: impl Into<String>) -> Self {
self.subcharts_dir = Some(dir.into());
self
}
pub fn build(self) -> PackRenderer {
let engine = if self.strict_mode {
Engine::strict()
} else {
Engine::lenient()
};
let mut config = SubchartConfig::default();
if let Some(depth) = self.max_depth {
config.max_depth = depth;
}
if let Some(dir) = self.subcharts_dir {
config.subcharts_dir = dir;
}
if self.strict_mode {
config.strict = true;
}
PackRenderer { engine, config }
}
}
fn evaluate_condition_path(condition: &str, values: &serde_json::Value) -> bool {
let parts: Vec<&str> = condition.split('.').collect();
let mut current = values;
for part in &parts {
match current.get(part) {
Some(v) => current = v,
None => return false,
}
}
match current {
serde_json::Value::Bool(b) => *b,
serde_json::Value::Null => false,
serde_json::Value::String(s) => !s.is_empty() && s != "false" && s != "0",
serde_json::Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
serde_json::Value::Array(a) => !a.is_empty(),
serde_json::Value::Object(o) => !o.is_empty(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_evaluate_condition_path_bool() {
let values = serde_json::json!({
"redis": {
"enabled": true
},
"postgresql": {
"enabled": false
}
});
assert!(evaluate_condition_path("redis.enabled", &values));
assert!(!evaluate_condition_path("postgresql.enabled", &values));
}
#[test]
fn test_evaluate_condition_path_missing() {
let values = serde_json::json!({
"redis": {}
});
assert!(!evaluate_condition_path("redis.enabled", &values));
assert!(!evaluate_condition_path("nonexistent.path", &values));
}
#[test]
fn test_evaluate_condition_path_truthy() {
let values = serde_json::json!({
"string_yes": "yes",
"string_empty": "",
"number_one": 1,
"number_zero": 0,
"array_full": [1, 2],
"array_empty": []
});
assert!(evaluate_condition_path("string_yes", &values));
assert!(!evaluate_condition_path("string_empty", &values));
assert!(evaluate_condition_path("number_one", &values));
assert!(!evaluate_condition_path("number_zero", &values));
assert!(evaluate_condition_path("array_full", &values));
assert!(!evaluate_condition_path("array_empty", &values));
}
#[test]
fn test_pack_renderer_builder() {
let renderer = PackRenderer::builder()
.strict(true)
.max_depth(5)
.subcharts_dir("deps")
.build();
assert_eq!(renderer.config.max_depth, 5);
assert_eq!(renderer.config.subcharts_dir, "deps");
assert!(renderer.config.strict);
}
#[test]
fn test_pack_render_result_with_report_success() {
let result = PackRenderResultWithReport {
manifests: IndexMap::new(),
notes: None,
report: RenderReport::new(),
discovery: DiscoveryResult::new(),
};
assert!(result.is_success());
}
#[test]
fn test_discover_subcharts_with_fixture() {
use std::path::PathBuf;
let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.join("fixtures/pack-with-subcharts");
if !fixture_path.exists() {
return;
}
let pack = LoadedPack::load(&fixture_path).expect("Failed to load fixture");
let renderer = PackRenderer::new(Engine::lenient());
let values = serde_json::json!({
"redis": { "enabled": true },
"postgresql": { "enabled": false }
});
let discovery = renderer.discover_subcharts(&pack, &values);
assert_eq!(discovery.subcharts.len(), 2);
let redis = discovery.subcharts.iter().find(|s| s.name == "redis");
assert!(redis.is_some());
assert!(redis.unwrap().enabled);
let pg = discovery.subcharts.iter().find(|s| s.name == "postgresql");
assert!(pg.is_some());
assert!(!pg.unwrap().enabled);
}
#[test]
fn test_render_pack_with_subcharts() {
use sherpack_core::ReleaseInfo;
use std::path::PathBuf;
let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.join("fixtures/pack-with-subcharts");
if !fixture_path.exists() {
return;
}
let pack = LoadedPack::load(&fixture_path).expect("Failed to load fixture");
let renderer = PackRenderer::new(Engine::lenient());
let values = Values::from_yaml(
r#"
global:
imageRegistry: docker.io
pullPolicy: IfNotPresent
app:
name: my-application
replicas: 2
image:
repository: myapp
tag: "1.0.0"
redis:
enabled: true
replicas: 3
auth:
enabled: true
password: secret123
postgresql:
enabled: false
"#,
)
.expect("Failed to parse values");
let release = ReleaseInfo::for_install("test-release", "default");
let context = TemplateContext::new(values, release, &pack.pack.metadata);
let result = renderer.render(&pack, &context).expect("Render failed");
assert!(result.manifests.contains_key("deployment.yaml"));
assert!(result.manifests.contains_key("redis/deployment.yaml"));
let has_postgresql = result
.manifests
.keys()
.any(|k| k.starts_with("postgresql/"));
assert!(!has_postgresql, "PostgreSQL should be disabled");
let redis_manifest = result.manifests.get("redis/deployment.yaml").unwrap();
assert!(
redis_manifest.contains("replicas: 3"),
"Should use parent's redis.replicas=3"
);
assert!(
redis_manifest.contains("REDIS_PASSWORD"),
"Auth should be enabled"
);
let parent_manifest = result.manifests.get("deployment.yaml").unwrap();
assert!(parent_manifest.contains("test-release-my-application"));
assert!(parent_manifest.contains("REDIS_HOST"));
assert!(
!parent_manifest.contains("DATABASE_HOST"),
"PostgreSQL env should not be present"
);
}
#[test]
fn test_subchart_global_values_passed() {
use sherpack_core::ReleaseInfo;
use std::path::PathBuf;
let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.join("fixtures/pack-with-subcharts");
if !fixture_path.exists() {
return;
}
let pack = LoadedPack::load(&fixture_path).expect("Failed to load fixture");
let renderer = PackRenderer::new(Engine::lenient());
let values = Values::from_yaml(
r#"
global:
imageRegistry: my-registry.io
pullPolicy: Always
app:
name: my-app
replicas: 1
image:
repository: myapp
tag: "1.0"
redis:
enabled: true
postgresql:
enabled: false
"#,
)
.expect("Failed to parse values");
let release = ReleaseInfo::for_install("test", "default");
let context = TemplateContext::new(values, release, &pack.pack.metadata);
let result = renderer.render(&pack, &context).expect("Render failed");
let redis_manifest = result.manifests.get("redis/deployment.yaml").unwrap();
assert!(
redis_manifest.contains("my-registry.io"),
"Should use global imageRegistry"
);
}
}