sherpack_core/
values.rs

1//! Values handling with deep merge support
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value as JsonValue;
5use std::path::Path;
6
7use crate::error::{CoreError, Result};
8
9/// Values container with deep merge capability
10#[derive(Debug, Clone, Default, Serialize, Deserialize)]
11#[serde(transparent)]
12pub struct Values(pub JsonValue);
13
14impl Values {
15    /// Create empty values
16    pub fn new() -> Self {
17        Self(JsonValue::Object(serde_json::Map::new()))
18    }
19
20    /// Load values from a YAML file
21    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
22        let content = std::fs::read_to_string(path.as_ref())?;
23        Self::from_yaml(&content)
24    }
25
26    /// Parse values from YAML string
27    pub fn from_yaml(yaml: &str) -> Result<Self> {
28        let value: JsonValue = serde_yaml::from_str(yaml)?;
29        Ok(Self(value))
30    }
31
32    /// Parse values from JSON string
33    pub fn from_json(json: &str) -> Result<Self> {
34        let value: JsonValue = serde_json::from_str(json)?;
35        Ok(Self(value))
36    }
37
38    /// Deep merge another Values into this one
39    ///
40    /// Rules:
41    /// - Scalars: overlay replaces base
42    /// - Objects: recursive merge
43    /// - Arrays: overlay replaces base (not appended)
44    pub fn merge(&mut self, overlay: &Values) {
45        deep_merge(&mut self.0, &overlay.0);
46    }
47
48    /// Merge multiple values in order
49    pub fn merge_all(values: Vec<Values>) -> Self {
50        let mut result = Values::new();
51        for v in values {
52            result.merge(&v);
53        }
54        result
55    }
56
57    /// Set a value by dotted path (e.g., "image.tag")
58    pub fn set(&mut self, path: &str, value: JsonValue) -> Result<()> {
59        let parts: Vec<&str> = path.split('.').collect();
60        set_nested(&mut self.0, &parts, value)
61    }
62
63    /// Get a value by dotted path
64    pub fn get(&self, path: &str) -> Option<&JsonValue> {
65        let parts: Vec<&str> = path.split('.').collect();
66        get_nested(&self.0, &parts)
67    }
68
69    /// Get the inner JSON value
70    pub fn inner(&self) -> &JsonValue {
71        &self.0
72    }
73
74    /// Convert to JSON value
75    pub fn into_inner(self) -> JsonValue {
76        self.0
77    }
78
79    /// Check if values are empty
80    pub fn is_empty(&self) -> bool {
81        match &self.0 {
82            JsonValue::Object(map) => map.is_empty(),
83            JsonValue::Null => true,
84            _ => false,
85        }
86    }
87
88    /// Merge with schema defaults applied first
89    ///
90    /// The merge order is: schema defaults (lowest priority) -> base values (higher priority)
91    /// This ensures that schema defaults are only used when values are not explicitly set.
92    pub fn with_schema_defaults(schema_defaults: Values, base: Values) -> Self {
93        // Start with schema defaults, then merge base on top
94        let mut result = schema_defaults;
95        result.merge(&base);
96        result
97    }
98
99    // =========================================================================
100    // Subchart Value Scoping
101    // =========================================================================
102
103    /// Scope values for a subchart
104    ///
105    /// When rendering a subchart, it should only see:
106    /// 1. Values under `<subchart_name>.*` in the parent, as its root values
107    /// 2. Global values under `global.*` preserved as-is
108    ///
109    /// The subchart's own `values.yaml` defaults are merged separately before this.
110    ///
111    /// # Example
112    ///
113    /// Parent values:
114    /// ```yaml
115    /// global:
116    ///   imageRegistry: docker.io
117    /// redis:
118    ///   enabled: true
119    ///   replicas: 3
120    /// postgresql:
121    ///   enabled: false
122    /// ```
123    ///
124    /// Calling `scope_for_subchart("redis")` produces:
125    /// ```yaml
126    /// global:
127    ///   imageRegistry: docker.io
128    /// enabled: true
129    /// replicas: 3
130    /// ```
131    pub fn scope_for_subchart(&self, subchart_name: &str) -> Values {
132        let mut scoped = serde_json::Map::new();
133
134        if let JsonValue::Object(parent_obj) = &self.0 {
135            // 1. Copy global values if present
136            if let Some(global) = parent_obj.get("global") {
137                scoped.insert("global".to_string(), global.clone());
138            }
139
140            // 2. Extract subchart-specific values as root values
141            if let Some(JsonValue::Object(subchart_obj)) = parent_obj.get(subchart_name) {
142                for (k, v) in subchart_obj {
143                    scoped.insert(k.clone(), v.clone());
144                }
145            }
146        }
147
148        Values(JsonValue::Object(scoped))
149    }
150
151    /// Merge subchart defaults with scoped parent values
152    ///
153    /// This is the complete subchart value resolution:
154    /// 1. Start with subchart's own `values.yaml` defaults
155    /// 2. Merge in the scoped values from parent
156    ///
157    /// # Arguments
158    /// * `subchart_defaults` - Values from the subchart's `values.yaml`
159    /// * `parent_values` - Parent's merged values
160    /// * `subchart_name` - Name of the subchart (for scoping)
161    pub fn for_subchart(
162        subchart_defaults: Values,
163        parent_values: &Values,
164        subchart_name: &str,
165    ) -> Values {
166        let mut result = subchart_defaults;
167        let scoped = parent_values.scope_for_subchart(subchart_name);
168        result.merge(&scoped);
169        result
170    }
171
172    /// Export subchart values back to parent namespace
173    ///
174    /// This is the inverse of `scope_for_subchart` - takes subchart-scoped values
175    /// and wraps them under the subchart's namespace for parent access.
176    ///
177    /// # Example
178    ///
179    /// Subchart values:
180    /// ```yaml
181    /// global:
182    ///   imageRegistry: docker.io
183    /// enabled: true
184    /// replicas: 3
185    /// ```
186    ///
187    /// Calling `export_to_parent("redis")` produces:
188    /// ```yaml
189    /// global:
190    ///   imageRegistry: docker.io
191    /// redis:
192    ///   enabled: true
193    ///   replicas: 3
194    /// ```
195    pub fn export_to_parent(&self, subchart_name: &str) -> Values {
196        let mut parent = serde_json::Map::new();
197        let mut subchart_values = serde_json::Map::new();
198
199        if let JsonValue::Object(obj) = &self.0 {
200            for (k, v) in obj {
201                if k == "global" {
202                    // Global stays at parent root level
203                    parent.insert(k.clone(), v.clone());
204                } else {
205                    // Everything else goes under subchart namespace
206                    subchart_values.insert(k.clone(), v.clone());
207                }
208            }
209        }
210
211        if !subchart_values.is_empty() {
212            parent.insert(
213                subchart_name.to_string(),
214                JsonValue::Object(subchart_values),
215            );
216        }
217
218        Values(JsonValue::Object(parent))
219    }
220
221    // =========================================================================
222    // JsonValue-based methods (for use with TemplateContext which stores JsonValue)
223    // =========================================================================
224
225    /// Scope values for a subchart from raw JsonValue
226    ///
227    /// Same as `scope_for_subchart` but works with `&JsonValue` directly,
228    /// useful when values are already in JsonValue form (e.g., from TemplateContext).
229    pub fn scope_json_for_subchart(parent_json: &JsonValue, subchart_name: &str) -> Values {
230        let mut scoped = serde_json::Map::new();
231
232        if let JsonValue::Object(parent_obj) = parent_json {
233            // 1. Copy global values if present
234            if let Some(global) = parent_obj.get("global") {
235                scoped.insert("global".to_string(), global.clone());
236            }
237
238            // 2. Extract subchart-specific values as root values
239            if let Some(JsonValue::Object(subchart_obj)) = parent_obj.get(subchart_name) {
240                for (k, v) in subchart_obj {
241                    scoped.insert(k.clone(), v.clone());
242                }
243            }
244        }
245
246        Values(JsonValue::Object(scoped))
247    }
248
249    /// Merge subchart defaults with scoped parent values from JsonValue
250    ///
251    /// Same as `for_subchart` but accepts `&JsonValue` for parent values.
252    pub fn for_subchart_json(
253        subchart_defaults: Values,
254        parent_json: &JsonValue,
255        subchart_name: &str,
256    ) -> Values {
257        let mut result = subchart_defaults;
258        let scoped = Self::scope_json_for_subchart(parent_json, subchart_name);
259        result.merge(&scoped);
260        result
261    }
262}
263
264/// Deep merge two JSON values
265fn deep_merge(base: &mut JsonValue, overlay: &JsonValue) {
266    match (base, overlay) {
267        (JsonValue::Object(base_map), JsonValue::Object(overlay_map)) => {
268            for (key, overlay_value) in overlay_map {
269                match base_map.get_mut(key) {
270                    Some(base_value) => deep_merge(base_value, overlay_value),
271                    None => {
272                        base_map.insert(key.clone(), overlay_value.clone());
273                    }
274                }
275            }
276        }
277        (base, overlay) => {
278            *base = overlay.clone();
279        }
280    }
281}
282
283/// Set a nested value by path
284fn set_nested(value: &mut JsonValue, path: &[&str], new_value: JsonValue) -> Result<()> {
285    if path.is_empty() {
286        *value = new_value;
287        return Ok(());
288    }
289
290    let key = path[0];
291    let remaining = &path[1..];
292
293    // Ensure we have an object
294    if !value.is_object() {
295        *value = JsonValue::Object(serde_json::Map::new());
296    }
297
298    // SAFETY: We just ensured it's an object above
299    let map = value
300        .as_object_mut()
301        .expect("value should be an object after initialization");
302
303    if remaining.is_empty() {
304        map.insert(key.to_string(), new_value);
305    } else {
306        let entry = map
307            .entry(key.to_string())
308            .or_insert_with(|| JsonValue::Object(serde_json::Map::new()));
309        set_nested(entry, remaining, new_value)?;
310    }
311
312    Ok(())
313}
314
315/// Get a nested value by path
316fn get_nested<'a>(value: &'a JsonValue, path: &[&str]) -> Option<&'a JsonValue> {
317    if path.is_empty() {
318        return Some(value);
319    }
320
321    let key = path[0];
322    let remaining = &path[1..];
323
324    match value {
325        JsonValue::Object(map) => map.get(key).and_then(|v| get_nested(v, remaining)),
326        _ => None,
327    }
328}
329
330/// Parse --set arguments (key=value format)
331pub fn parse_set_values(set_args: &[String]) -> Result<Values> {
332    let mut values = Values::new();
333
334    for arg in set_args {
335        let (key, val) = arg.split_once('=').ok_or_else(|| CoreError::ValuesMerge {
336            message: format!("Invalid --set format: '{}'. Expected key=value", arg),
337        })?;
338
339        // Try to parse as JSON, fallback to string
340        let json_value = if val == "true" {
341            JsonValue::Bool(true)
342        } else if val == "false" {
343            JsonValue::Bool(false)
344        } else if val == "null" {
345            JsonValue::Null
346        } else if let Ok(num) = val.parse::<i64>() {
347            JsonValue::Number(num.into())
348        } else if let Ok(num) = val.parse::<f64>() {
349            JsonValue::Number(serde_json::Number::from_f64(num).unwrap_or(0.into()))
350        } else if val.starts_with('[') || val.starts_with('{') {
351            serde_json::from_str(val).unwrap_or(JsonValue::String(val.to_string()))
352        } else {
353            JsonValue::String(val.to_string())
354        };
355
356        values.set(key, json_value)?;
357    }
358
359    Ok(values)
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    #[test]
367    fn test_deep_merge() {
368        let mut base = Values::from_yaml(
369            r#"
370image:
371  repository: nginx
372  tag: "1.0"
373replicas: 1
374"#,
375        )
376        .unwrap();
377
378        let overlay = Values::from_yaml(
379            r#"
380image:
381  tag: "2.0"
382  pullPolicy: Always
383replicas: 3
384"#,
385        )
386        .unwrap();
387
388        base.merge(&overlay);
389
390        assert_eq!(base.get("image.repository").unwrap(), "nginx");
391        assert_eq!(base.get("image.tag").unwrap(), "2.0");
392        assert_eq!(base.get("image.pullPolicy").unwrap(), "Always");
393        assert_eq!(base.get("replicas").unwrap(), 3);
394    }
395
396    #[test]
397    fn test_set_nested() {
398        let mut values = Values::new();
399        values
400            .set("image.tag", JsonValue::String("v1".into()))
401            .unwrap();
402        values.set("replicas", JsonValue::Number(3.into())).unwrap();
403
404        assert_eq!(values.get("image.tag").unwrap(), "v1");
405        assert_eq!(values.get("replicas").unwrap(), 3);
406    }
407
408    #[test]
409    fn test_parse_set_values() {
410        let args = vec![
411            "image.tag=v2".to_string(),
412            "replicas=5".to_string(),
413            "debug=true".to_string(),
414        ];
415
416        let values = parse_set_values(&args).unwrap();
417
418        assert_eq!(values.get("image.tag").unwrap(), "v2");
419        assert_eq!(values.get("replicas").unwrap(), 5);
420        assert_eq!(values.get("debug").unwrap(), true);
421    }
422
423    #[test]
424    fn test_scope_for_subchart_basic() {
425        let parent = Values::from_yaml(
426            r#"
427global:
428  imageRegistry: docker.io
429redis:
430  enabled: true
431  replicas: 3
432postgresql:
433  enabled: false
434"#,
435        )
436        .unwrap();
437
438        let scoped = parent.scope_for_subchart("redis");
439
440        // Global should be preserved
441        assert_eq!(scoped.get("global.imageRegistry").unwrap(), "docker.io");
442
443        // Redis values should be at root level
444        assert_eq!(scoped.get("enabled").unwrap(), true);
445        assert_eq!(scoped.get("replicas").unwrap(), 3);
446
447        // PostgreSQL should NOT be present
448        assert!(scoped.get("postgresql").is_none());
449        assert!(scoped.get("redis").is_none());
450    }
451
452    #[test]
453    fn test_scope_for_subchart_no_global() {
454        let parent = Values::from_yaml(
455            r#"
456redis:
457  host: localhost
458  port: 6379
459"#,
460        )
461        .unwrap();
462
463        let scoped = parent.scope_for_subchart("redis");
464
465        assert_eq!(scoped.get("host").unwrap(), "localhost");
466        assert_eq!(scoped.get("port").unwrap(), 6379);
467        assert!(scoped.get("global").is_none());
468    }
469
470    #[test]
471    fn test_scope_for_subchart_missing_subchart() {
472        let parent = Values::from_yaml(
473            r#"
474global:
475  debug: true
476redis:
477  enabled: true
478"#,
479        )
480        .unwrap();
481
482        let scoped = parent.scope_for_subchart("postgresql");
483
484        // Only global should be present
485        assert_eq!(scoped.get("global.debug").unwrap(), true);
486        assert!(scoped.get("enabled").is_none());
487    }
488
489    #[test]
490    fn test_for_subchart_with_defaults() {
491        let subchart_defaults = Values::from_yaml(
492            r#"
493enabled: false
494replicas: 1
495image:
496  repository: redis
497  tag: "7.0"
498"#,
499        )
500        .unwrap();
501
502        let parent = Values::from_yaml(
503            r#"
504global:
505  pullPolicy: Always
506redis:
507  enabled: true
508  replicas: 3
509"#,
510        )
511        .unwrap();
512
513        let result = Values::for_subchart(subchart_defaults, &parent, "redis");
514
515        // Global from parent
516        assert_eq!(result.get("global.pullPolicy").unwrap(), "Always");
517
518        // Overridden by parent's redis.*
519        assert_eq!(result.get("enabled").unwrap(), true);
520        assert_eq!(result.get("replicas").unwrap(), 3);
521
522        // Default values not overridden
523        assert_eq!(result.get("image.repository").unwrap(), "redis");
524        assert_eq!(result.get("image.tag").unwrap(), "7.0");
525    }
526
527    #[test]
528    fn test_export_to_parent() {
529        let subchart = Values::from_yaml(
530            r#"
531global:
532  imageRegistry: docker.io
533enabled: true
534replicas: 3
535image:
536  tag: "7.0"
537"#,
538        )
539        .unwrap();
540
541        let exported = subchart.export_to_parent("redis");
542
543        // Global at root
544        assert_eq!(exported.get("global.imageRegistry").unwrap(), "docker.io");
545
546        // Other values under redis namespace
547        assert_eq!(exported.get("redis.enabled").unwrap(), true);
548        assert_eq!(exported.get("redis.replicas").unwrap(), 3);
549        assert_eq!(exported.get("redis.image.tag").unwrap(), "7.0");
550    }
551
552    #[test]
553    fn test_scope_and_export_roundtrip() {
554        let original_parent = Values::from_yaml(
555            r#"
556global:
557  env: production
558redis:
559  enabled: true
560  maxMemory: 256mb
561"#,
562        )
563        .unwrap();
564
565        // Scope for subchart
566        let scoped = original_parent.scope_for_subchart("redis");
567
568        // Export back to parent namespace
569        let exported = scoped.export_to_parent("redis");
570
571        // Should match original structure (for redis values)
572        assert_eq!(exported.get("global.env").unwrap(), "production");
573        assert_eq!(exported.get("redis.enabled").unwrap(), true);
574        assert_eq!(exported.get("redis.maxMemory").unwrap(), "256mb");
575    }
576
577    #[test]
578    fn test_scope_json_for_subchart() {
579        let parent_json = serde_json::json!({
580            "global": {
581                "imageRegistry": "docker.io"
582            },
583            "redis": {
584                "enabled": true,
585                "replicas": 3
586            },
587            "postgresql": {
588                "enabled": false
589            }
590        });
591
592        let scoped = Values::scope_json_for_subchart(&parent_json, "redis");
593
594        // Global should be preserved
595        assert_eq!(scoped.get("global.imageRegistry").unwrap(), "docker.io");
596
597        // Redis values should be at root level
598        assert_eq!(scoped.get("enabled").unwrap(), true);
599        assert_eq!(scoped.get("replicas").unwrap(), 3);
600
601        // PostgreSQL should NOT be present
602        assert!(scoped.get("postgresql").is_none());
603        assert!(scoped.get("redis").is_none());
604    }
605
606    #[test]
607    fn test_for_subchart_json() {
608        let subchart_defaults = Values::from_yaml(
609            r#"
610enabled: false
611replicas: 1
612image:
613  repository: redis
614  tag: "7.0"
615"#,
616        )
617        .unwrap();
618
619        let parent_json = serde_json::json!({
620            "global": {
621                "pullPolicy": "Always"
622            },
623            "redis": {
624                "enabled": true,
625                "replicas": 3
626            }
627        });
628
629        let result = Values::for_subchart_json(subchart_defaults, &parent_json, "redis");
630
631        // Global from parent
632        assert_eq!(result.get("global.pullPolicy").unwrap(), "Always");
633
634        // Overridden by parent's redis.*
635        assert_eq!(result.get("enabled").unwrap(), true);
636        assert_eq!(result.get("replicas").unwrap(), 3);
637
638        // Default values not overridden
639        assert_eq!(result.get("image.repository").unwrap(), "redis");
640        assert_eq!(result.get("image.tag").unwrap(), "7.0");
641    }
642}