use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use std::path::Path;
use crate::error::{CoreError, Result};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Values(pub JsonValue);
impl Values {
pub fn new() -> Self {
Self(JsonValue::Object(serde_json::Map::new()))
}
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
let content = std::fs::read_to_string(path.as_ref())?;
Self::from_yaml(&content)
}
pub fn from_yaml(yaml: &str) -> Result<Self> {
let value: JsonValue = serde_yaml::from_str(yaml)?;
Ok(Self(value))
}
pub fn from_json(json: &str) -> Result<Self> {
let value: JsonValue = serde_json::from_str(json)?;
Ok(Self(value))
}
pub fn merge(&mut self, overlay: &Values) {
deep_merge(&mut self.0, &overlay.0);
}
pub fn merge_all(values: Vec<Values>) -> Self {
let mut result = Values::new();
for v in values {
result.merge(&v);
}
result
}
pub fn set(&mut self, path: &str, value: JsonValue) -> Result<()> {
let parts: Vec<&str> = path.split('.').collect();
set_nested(&mut self.0, &parts, value)
}
pub fn get(&self, path: &str) -> Option<&JsonValue> {
let parts: Vec<&str> = path.split('.').collect();
get_nested(&self.0, &parts)
}
pub fn inner(&self) -> &JsonValue {
&self.0
}
pub fn into_inner(self) -> JsonValue {
self.0
}
pub fn is_empty(&self) -> bool {
match &self.0 {
JsonValue::Object(map) => map.is_empty(),
JsonValue::Null => true,
_ => false,
}
}
pub fn with_schema_defaults(schema_defaults: Values, base: Values) -> Self {
let mut result = schema_defaults;
result.merge(&base);
result
}
pub fn scope_for_subchart(&self, subchart_name: &str) -> Values {
let mut scoped = serde_json::Map::new();
if let JsonValue::Object(parent_obj) = &self.0 {
if let Some(global) = parent_obj.get("global") {
scoped.insert("global".to_string(), global.clone());
}
if let Some(JsonValue::Object(subchart_obj)) = parent_obj.get(subchart_name) {
for (k, v) in subchart_obj {
scoped.insert(k.clone(), v.clone());
}
}
}
Values(JsonValue::Object(scoped))
}
pub fn for_subchart(
subchart_defaults: Values,
parent_values: &Values,
subchart_name: &str,
) -> Values {
let mut result = subchart_defaults;
let scoped = parent_values.scope_for_subchart(subchart_name);
result.merge(&scoped);
result
}
pub fn export_to_parent(&self, subchart_name: &str) -> Values {
let mut parent = serde_json::Map::new();
let mut subchart_values = serde_json::Map::new();
if let JsonValue::Object(obj) = &self.0 {
for (k, v) in obj {
if k == "global" {
parent.insert(k.clone(), v.clone());
} else {
subchart_values.insert(k.clone(), v.clone());
}
}
}
if !subchart_values.is_empty() {
parent.insert(
subchart_name.to_string(),
JsonValue::Object(subchart_values),
);
}
Values(JsonValue::Object(parent))
}
pub fn scope_json_for_subchart(parent_json: &JsonValue, subchart_name: &str) -> Values {
let mut scoped = serde_json::Map::new();
if let JsonValue::Object(parent_obj) = parent_json {
if let Some(global) = parent_obj.get("global") {
scoped.insert("global".to_string(), global.clone());
}
if let Some(JsonValue::Object(subchart_obj)) = parent_obj.get(subchart_name) {
for (k, v) in subchart_obj {
scoped.insert(k.clone(), v.clone());
}
}
}
Values(JsonValue::Object(scoped))
}
pub fn for_subchart_json(
subchart_defaults: Values,
parent_json: &JsonValue,
subchart_name: &str,
) -> Values {
let mut result = subchart_defaults;
let scoped = Self::scope_json_for_subchart(parent_json, subchart_name);
result.merge(&scoped);
result
}
}
fn deep_merge(base: &mut JsonValue, overlay: &JsonValue) {
match (base, overlay) {
(JsonValue::Object(base_map), JsonValue::Object(overlay_map)) => {
for (key, overlay_value) in overlay_map {
match base_map.get_mut(key) {
Some(base_value) => deep_merge(base_value, overlay_value),
None => {
base_map.insert(key.clone(), overlay_value.clone());
}
}
}
}
(base, overlay) => {
*base = overlay.clone();
}
}
}
fn set_nested(value: &mut JsonValue, path: &[&str], new_value: JsonValue) -> Result<()> {
if path.is_empty() {
*value = new_value;
return Ok(());
}
let key = path[0];
let remaining = &path[1..];
if !value.is_object() {
*value = JsonValue::Object(serde_json::Map::new());
}
let map = value
.as_object_mut()
.expect("value should be an object after initialization");
if remaining.is_empty() {
map.insert(key.to_string(), new_value);
} else {
let entry = map
.entry(key.to_string())
.or_insert_with(|| JsonValue::Object(serde_json::Map::new()));
set_nested(entry, remaining, new_value)?;
}
Ok(())
}
fn get_nested<'a>(value: &'a JsonValue, path: &[&str]) -> Option<&'a JsonValue> {
if path.is_empty() {
return Some(value);
}
let key = path[0];
let remaining = &path[1..];
match value {
JsonValue::Object(map) => map.get(key).and_then(|v| get_nested(v, remaining)),
_ => None,
}
}
pub fn parse_set_values(set_args: &[String]) -> Result<Values> {
let mut values = Values::new();
for arg in set_args {
let (key, val) = arg.split_once('=').ok_or_else(|| CoreError::ValuesMerge {
message: format!("Invalid --set format: '{}'. Expected key=value", arg),
})?;
let json_value = if val == "true" {
JsonValue::Bool(true)
} else if val == "false" {
JsonValue::Bool(false)
} else if val == "null" {
JsonValue::Null
} else if let Ok(num) = val.parse::<i64>() {
JsonValue::Number(num.into())
} else if let Ok(num) = val.parse::<f64>() {
JsonValue::Number(serde_json::Number::from_f64(num).unwrap_or(0.into()))
} else if val.starts_with('[') || val.starts_with('{') {
serde_json::from_str(val).unwrap_or(JsonValue::String(val.to_string()))
} else {
JsonValue::String(val.to_string())
};
values.set(key, json_value)?;
}
Ok(values)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_deep_merge() {
let mut base = Values::from_yaml(
r#"
image:
repository: nginx
tag: "1.0"
replicas: 1
"#,
)
.unwrap();
let overlay = Values::from_yaml(
r#"
image:
tag: "2.0"
pullPolicy: Always
replicas: 3
"#,
)
.unwrap();
base.merge(&overlay);
assert_eq!(base.get("image.repository").unwrap(), "nginx");
assert_eq!(base.get("image.tag").unwrap(), "2.0");
assert_eq!(base.get("image.pullPolicy").unwrap(), "Always");
assert_eq!(base.get("replicas").unwrap(), 3);
}
#[test]
fn test_set_nested() {
let mut values = Values::new();
values
.set("image.tag", JsonValue::String("v1".into()))
.unwrap();
values.set("replicas", JsonValue::Number(3.into())).unwrap();
assert_eq!(values.get("image.tag").unwrap(), "v1");
assert_eq!(values.get("replicas").unwrap(), 3);
}
#[test]
fn test_parse_set_values() {
let args = vec![
"image.tag=v2".to_string(),
"replicas=5".to_string(),
"debug=true".to_string(),
];
let values = parse_set_values(&args).unwrap();
assert_eq!(values.get("image.tag").unwrap(), "v2");
assert_eq!(values.get("replicas").unwrap(), 5);
assert_eq!(values.get("debug").unwrap(), true);
}
#[test]
fn test_scope_for_subchart_basic() {
let parent = Values::from_yaml(
r#"
global:
imageRegistry: docker.io
redis:
enabled: true
replicas: 3
postgresql:
enabled: false
"#,
)
.unwrap();
let scoped = parent.scope_for_subchart("redis");
assert_eq!(scoped.get("global.imageRegistry").unwrap(), "docker.io");
assert_eq!(scoped.get("enabled").unwrap(), true);
assert_eq!(scoped.get("replicas").unwrap(), 3);
assert!(scoped.get("postgresql").is_none());
assert!(scoped.get("redis").is_none());
}
#[test]
fn test_scope_for_subchart_no_global() {
let parent = Values::from_yaml(
r#"
redis:
host: localhost
port: 6379
"#,
)
.unwrap();
let scoped = parent.scope_for_subchart("redis");
assert_eq!(scoped.get("host").unwrap(), "localhost");
assert_eq!(scoped.get("port").unwrap(), 6379);
assert!(scoped.get("global").is_none());
}
#[test]
fn test_scope_for_subchart_missing_subchart() {
let parent = Values::from_yaml(
r#"
global:
debug: true
redis:
enabled: true
"#,
)
.unwrap();
let scoped = parent.scope_for_subchart("postgresql");
assert_eq!(scoped.get("global.debug").unwrap(), true);
assert!(scoped.get("enabled").is_none());
}
#[test]
fn test_for_subchart_with_defaults() {
let subchart_defaults = Values::from_yaml(
r#"
enabled: false
replicas: 1
image:
repository: redis
tag: "7.0"
"#,
)
.unwrap();
let parent = Values::from_yaml(
r#"
global:
pullPolicy: Always
redis:
enabled: true
replicas: 3
"#,
)
.unwrap();
let result = Values::for_subchart(subchart_defaults, &parent, "redis");
assert_eq!(result.get("global.pullPolicy").unwrap(), "Always");
assert_eq!(result.get("enabled").unwrap(), true);
assert_eq!(result.get("replicas").unwrap(), 3);
assert_eq!(result.get("image.repository").unwrap(), "redis");
assert_eq!(result.get("image.tag").unwrap(), "7.0");
}
#[test]
fn test_export_to_parent() {
let subchart = Values::from_yaml(
r#"
global:
imageRegistry: docker.io
enabled: true
replicas: 3
image:
tag: "7.0"
"#,
)
.unwrap();
let exported = subchart.export_to_parent("redis");
assert_eq!(exported.get("global.imageRegistry").unwrap(), "docker.io");
assert_eq!(exported.get("redis.enabled").unwrap(), true);
assert_eq!(exported.get("redis.replicas").unwrap(), 3);
assert_eq!(exported.get("redis.image.tag").unwrap(), "7.0");
}
#[test]
fn test_scope_and_export_roundtrip() {
let original_parent = Values::from_yaml(
r#"
global:
env: production
redis:
enabled: true
maxMemory: 256mb
"#,
)
.unwrap();
let scoped = original_parent.scope_for_subchart("redis");
let exported = scoped.export_to_parent("redis");
assert_eq!(exported.get("global.env").unwrap(), "production");
assert_eq!(exported.get("redis.enabled").unwrap(), true);
assert_eq!(exported.get("redis.maxMemory").unwrap(), "256mb");
}
#[test]
fn test_scope_json_for_subchart() {
let parent_json = serde_json::json!({
"global": {
"imageRegistry": "docker.io"
},
"redis": {
"enabled": true,
"replicas": 3
},
"postgresql": {
"enabled": false
}
});
let scoped = Values::scope_json_for_subchart(&parent_json, "redis");
assert_eq!(scoped.get("global.imageRegistry").unwrap(), "docker.io");
assert_eq!(scoped.get("enabled").unwrap(), true);
assert_eq!(scoped.get("replicas").unwrap(), 3);
assert!(scoped.get("postgresql").is_none());
assert!(scoped.get("redis").is_none());
}
#[test]
fn test_for_subchart_json() {
let subchart_defaults = Values::from_yaml(
r#"
enabled: false
replicas: 1
image:
repository: redis
tag: "7.0"
"#,
)
.unwrap();
let parent_json = serde_json::json!({
"global": {
"pullPolicy": "Always"
},
"redis": {
"enabled": true,
"replicas": 3
}
});
let result = Values::for_subchart_json(subchart_defaults, &parent_json, "redis");
assert_eq!(result.get("global.pullPolicy").unwrap(), "Always");
assert_eq!(result.get("enabled").unwrap(), true);
assert_eq!(result.get("replicas").unwrap(), 3);
assert_eq!(result.get("image.repository").unwrap(), "redis");
assert_eq!(result.get("image.tag").unwrap(), "7.0");
}
}