holoconf_core/
config.rs

1//! Main Config type for holoconf
2//!
3//! The Config type is the primary interface for loading and accessing
4//! configuration values with lazy resolution.
5
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use std::sync::{Arc, RwLock};
9
10use crate::error::{Error, Result};
11use crate::interpolation::{self, Interpolation, InterpolationArg};
12use crate::resolver::{ResolvedValue, ResolverContext, ResolverRegistry};
13use crate::value::Value;
14
15/// Configuration options for loading configs
16#[derive(Debug, Clone, Default)]
17pub struct ConfigOptions {
18    /// Base path for relative file references
19    pub base_path: Option<PathBuf>,
20    /// Allow HTTP resolver (disabled by default for security)
21    pub allow_http: bool,
22    /// HTTP URL allowlist (glob patterns)
23    pub http_allowlist: Vec<String>,
24    /// Additional file roots for file resolver sandboxing
25    pub file_roots: Vec<PathBuf>,
26}
27
28/// The main configuration container
29///
30/// Config provides lazy resolution of interpolation expressions
31/// and caches resolved values for efficiency.
32pub struct Config {
33    /// The raw (unresolved) configuration data
34    raw: Arc<Value>,
35    /// Cache of resolved values
36    cache: Arc<RwLock<HashMap<String, ResolvedValue>>>,
37    /// Resolver registry
38    resolvers: Arc<ResolverRegistry>,
39    /// Configuration options
40    options: ConfigOptions,
41}
42
43impl Config {
44    /// Create a new Config from a Value
45    pub fn new(value: Value) -> Self {
46        Self {
47            raw: Arc::new(value),
48            cache: Arc::new(RwLock::new(HashMap::new())),
49            resolvers: Arc::new(ResolverRegistry::with_builtins()),
50            options: ConfigOptions::default(),
51        }
52    }
53
54    /// Create a Config with custom options
55    pub fn with_options(value: Value, options: ConfigOptions) -> Self {
56        Self {
57            raw: Arc::new(value),
58            cache: Arc::new(RwLock::new(HashMap::new())),
59            resolvers: Arc::new(ResolverRegistry::with_builtins()),
60            options,
61        }
62    }
63
64    /// Create a Config with a custom resolver registry
65    pub fn with_resolvers(value: Value, resolvers: ResolverRegistry) -> Self {
66        Self {
67            raw: Arc::new(value),
68            cache: Arc::new(RwLock::new(HashMap::new())),
69            resolvers: Arc::new(resolvers),
70            options: ConfigOptions::default(),
71        }
72    }
73
74    /// Load configuration from a YAML string
75    pub fn from_yaml(yaml: &str) -> Result<Self> {
76        let value: Value = serde_yaml::from_str(yaml).map_err(|e| Error::parse(e.to_string()))?;
77        Ok(Self::new(value))
78    }
79
80    /// Load configuration from a YAML string with options
81    pub fn from_yaml_with_options(yaml: &str, options: ConfigOptions) -> Result<Self> {
82        let value: Value = serde_yaml::from_str(yaml).map_err(|e| Error::parse(e.to_string()))?;
83        Ok(Self::with_options(value, options))
84    }
85
86    /// Load configuration from a YAML file
87    pub fn from_yaml_file(path: impl AsRef<Path>) -> Result<Self> {
88        let path = path.as_ref();
89        let content = std::fs::read_to_string(path).map_err(|e| {
90            Error::parse(format!("Failed to read file '{}': {}", path.display(), e))
91        })?;
92
93        let value: Value =
94            serde_yaml::from_str(&content).map_err(|e| Error::parse(e.to_string()))?;
95
96        let mut options = ConfigOptions::default();
97        options.base_path = path.parent().map(|p| p.to_path_buf());
98
99        Ok(Self::with_options(value, options))
100    }
101
102    /// Load and merge multiple YAML files
103    ///
104    /// Files are merged in order, with later files overriding earlier ones.
105    /// Per ADR-004:
106    /// - Mappings are deep-merged
107    /// - Scalars use last-writer-wins
108    /// - Arrays are replaced (not concatenated)
109    /// - Null values remove keys
110    pub fn load_merged<P: AsRef<Path>>(paths: &[P]) -> Result<Self> {
111        if paths.is_empty() {
112            return Ok(Self::new(Value::Mapping(indexmap::IndexMap::new())));
113        }
114
115        let mut merged_value: Option<Value> = None;
116        let mut last_base_path: Option<PathBuf> = None;
117
118        for path in paths {
119            let path = path.as_ref();
120            let content = std::fs::read_to_string(path)
121                .map_err(|_e| Error::file_not_found(path.display().to_string(), None))?;
122
123            let value: Value =
124                serde_yaml::from_str(&content).map_err(|e| Error::parse(e.to_string()))?;
125
126            last_base_path = path.parent().map(|p| p.to_path_buf());
127
128            match &mut merged_value {
129                Some(base) => base.merge(value),
130                None => merged_value = Some(value),
131            }
132        }
133
134        let mut options = ConfigOptions::default();
135        options.base_path = last_base_path;
136
137        Ok(Self::with_options(
138            merged_value.unwrap_or(Value::Mapping(indexmap::IndexMap::new())),
139            options,
140        ))
141    }
142
143    /// Merge another config into this one
144    ///
145    /// The other config's values override this config's values per ADR-004 merge semantics.
146    pub fn merge(&mut self, other: Config) {
147        // Get a mutable reference to our raw value
148        if let Some(raw) = Arc::get_mut(&mut self.raw) {
149            raw.merge((*other.raw).clone());
150        } else {
151            // Need to clone and replace
152            let mut new_raw = (*self.raw).clone();
153            new_raw.merge((*other.raw).clone());
154            self.raw = Arc::new(new_raw);
155        }
156        // Clear the cache since values may have changed
157        self.clear_cache();
158    }
159
160    /// Load configuration from a JSON string
161    pub fn from_json(json: &str) -> Result<Self> {
162        let value: Value = serde_json::from_str(json).map_err(|e| Error::parse(e.to_string()))?;
163        Ok(Self::new(value))
164    }
165
166    /// Get the raw (unresolved) value at a path
167    pub fn get_raw(&self, path: &str) -> Result<&Value> {
168        self.raw.get_path(path)
169    }
170
171    /// Get a resolved value at a path
172    ///
173    /// This resolves any interpolation expressions in the value.
174    /// Resolved values are cached for subsequent accesses.
175    pub fn get(&self, path: &str) -> Result<Value> {
176        // Check cache first
177        {
178            let cache = self.cache.read().unwrap();
179            if let Some(cached) = cache.get(path) {
180                return Ok(cached.value.clone());
181            }
182        }
183
184        // Get raw value
185        let raw_value = self.raw.get_path(path)?;
186
187        // Resolve the value
188        let resolved = self.resolve_value(raw_value, path)?;
189
190        // Cache the result
191        {
192            let mut cache = self.cache.write().unwrap();
193            cache.insert(path.to_string(), resolved.clone());
194        }
195
196        Ok(resolved.value)
197    }
198
199    /// Get a resolved string value, with type coercion if needed
200    pub fn get_string(&self, path: &str) -> Result<String> {
201        let value = self.get(path)?;
202        match value {
203            Value::String(s) => Ok(s),
204            Value::Integer(i) => Ok(i.to_string()),
205            Value::Float(f) => Ok(f.to_string()),
206            Value::Bool(b) => Ok(b.to_string()),
207            Value::Null => Ok("null".to_string()),
208            _ => Err(Error::type_coercion(path, "string", value.type_name())),
209        }
210    }
211
212    /// Get a resolved integer value, with type coercion if needed
213    pub fn get_i64(&self, path: &str) -> Result<i64> {
214        let value = self.get(path)?;
215        match value {
216            Value::Integer(i) => Ok(i),
217            Value::String(s) => s
218                .parse()
219                .map_err(|_| Error::type_coercion(path, "integer", format!("string (\"{}\")", s))),
220            _ => Err(Error::type_coercion(path, "integer", value.type_name())),
221        }
222    }
223
224    /// Get a resolved float value, with type coercion if needed
225    pub fn get_f64(&self, path: &str) -> Result<f64> {
226        let value = self.get(path)?;
227        match value {
228            Value::Float(f) => Ok(f),
229            Value::Integer(i) => Ok(i as f64),
230            Value::String(s) => s
231                .parse()
232                .map_err(|_| Error::type_coercion(path, "float", format!("string (\"{}\")", s))),
233            _ => Err(Error::type_coercion(path, "float", value.type_name())),
234        }
235    }
236
237    /// Get a resolved boolean value, with strict coercion per ADR-012
238    pub fn get_bool(&self, path: &str) -> Result<bool> {
239        let value = self.get(path)?;
240        match value {
241            Value::Bool(b) => Ok(b),
242            Value::String(s) => {
243                // Strict boolean coercion: only "true" and "false"
244                match s.to_lowercase().as_str() {
245                    "true" => Ok(true),
246                    "false" => Ok(false),
247                    _ => Err(Error::type_coercion(
248                        path,
249                        "boolean",
250                        format!("string (\"{}\") - only \"true\" or \"false\" allowed", s),
251                    )),
252                }
253            }
254            _ => Err(Error::type_coercion(path, "boolean", value.type_name())),
255        }
256    }
257
258    /// Resolve all values in the configuration eagerly
259    pub fn resolve_all(&self) -> Result<()> {
260        self.resolve_value_recursive(&self.raw, "")?;
261        Ok(())
262    }
263
264    /// Export the configuration as a resolved Value
265    pub fn to_value(&self) -> Result<Value> {
266        self.resolve_value_to_value(&self.raw, "")
267    }
268
269    /// Export the raw (unresolved) configuration as a Value
270    ///
271    /// This shows the configuration with interpolation placeholders (${...})
272    pub fn to_value_raw(&self) -> Value {
273        (*self.raw).clone()
274    }
275
276    /// Export the resolved configuration with optional redaction
277    ///
278    /// When redact=true, sensitive values are replaced with "[REDACTED]"
279    pub fn to_value_redacted(&self, redact: bool) -> Result<Value> {
280        if redact {
281            self.resolve_value_to_value_redacted(&self.raw, "")
282        } else {
283            self.resolve_value_to_value(&self.raw, "")
284        }
285    }
286
287    /// Export the configuration as YAML
288    ///
289    /// By default, resolves all values. Use to_yaml_raw() for unresolved output.
290    pub fn to_yaml(&self) -> Result<String> {
291        let value = self.to_value()?;
292        serde_yaml::to_string(&value).map_err(|e| Error::parse(e.to_string()))
293    }
294
295    /// Export the raw (unresolved) configuration as YAML
296    ///
297    /// Shows interpolation placeholders (${...}) without resolution.
298    pub fn to_yaml_raw(&self) -> Result<String> {
299        serde_yaml::to_string(&*self.raw).map_err(|e| Error::parse(e.to_string()))
300    }
301
302    /// Export the resolved configuration as YAML with optional redaction
303    ///
304    /// When redact=true, sensitive values are replaced with "[REDACTED]"
305    pub fn to_yaml_redacted(&self, redact: bool) -> Result<String> {
306        let value = self.to_value_redacted(redact)?;
307        serde_yaml::to_string(&value).map_err(|e| Error::parse(e.to_string()))
308    }
309
310    /// Export the configuration as JSON
311    ///
312    /// By default, resolves all values. Use to_json_raw() for unresolved output.
313    pub fn to_json(&self) -> Result<String> {
314        let value = self.to_value()?;
315        serde_json::to_string_pretty(&value).map_err(|e| Error::parse(e.to_string()))
316    }
317
318    /// Export the raw (unresolved) configuration as JSON
319    ///
320    /// Shows interpolation placeholders (${...}) without resolution.
321    pub fn to_json_raw(&self) -> Result<String> {
322        serde_json::to_string_pretty(&*self.raw).map_err(|e| Error::parse(e.to_string()))
323    }
324
325    /// Export the resolved configuration as JSON with optional redaction
326    ///
327    /// When redact=true, sensitive values are replaced with "[REDACTED]"
328    pub fn to_json_redacted(&self, redact: bool) -> Result<String> {
329        let value = self.to_value_redacted(redact)?;
330        serde_json::to_string_pretty(&value).map_err(|e| Error::parse(e.to_string()))
331    }
332
333    /// Clear the resolution cache
334    pub fn clear_cache(&self) {
335        let mut cache = self.cache.write().unwrap();
336        cache.clear();
337    }
338
339    /// Register a custom resolver
340    pub fn register_resolver(&mut self, resolver: Arc<dyn crate::resolver::Resolver>) {
341        // We need to get mutable access to the registry
342        // This is safe because we're the only owner at this point
343        if let Some(registry) = Arc::get_mut(&mut self.resolvers) {
344            registry.register(resolver);
345        }
346    }
347
348    /// Validate the raw (unresolved) configuration against a schema
349    ///
350    /// This performs structural validation (Phase 1 per ADR-007):
351    /// - Required keys are present
352    /// - Object/array structure matches
353    /// - Interpolations (${...}) are allowed as placeholders
354    pub fn validate_raw(&self, schema: &crate::schema::Schema) -> Result<()> {
355        schema.validate(&self.raw)
356    }
357
358    /// Validate the resolved configuration against a schema
359    ///
360    /// This performs type/value validation (Phase 2 per ADR-007):
361    /// - Resolved values match expected types
362    /// - Constraints (min, max, pattern, enum) are checked
363    pub fn validate(&self, schema: &crate::schema::Schema) -> Result<()> {
364        let resolved = self.to_value()?;
365        schema.validate(&resolved)
366    }
367
368    /// Validate and collect all errors (instead of failing on first)
369    pub fn validate_collect(
370        &self,
371        schema: &crate::schema::Schema,
372    ) -> Vec<crate::schema::ValidationError> {
373        match self.to_value() {
374            Ok(resolved) => schema.validate_collect(&resolved),
375            Err(e) => vec![crate::schema::ValidationError {
376                path: String::new(),
377                message: e.to_string(),
378            }],
379        }
380    }
381
382    /// Resolve a single value
383    fn resolve_value(&self, value: &Value, path: &str) -> Result<ResolvedValue> {
384        match value {
385            Value::String(s) => {
386                // Use needs_processing to handle both interpolations AND escape sequences
387                if interpolation::needs_processing(s) {
388                    let parsed = interpolation::parse(s)?;
389                    self.resolve_interpolation(&parsed, path)
390                } else {
391                    Ok(ResolvedValue::new(value.clone()))
392                }
393            }
394            _ => Ok(ResolvedValue::new(value.clone())),
395        }
396    }
397
398    /// Resolve an interpolation expression
399    fn resolve_interpolation(&self, interp: &Interpolation, path: &str) -> Result<ResolvedValue> {
400        match interp {
401            Interpolation::Literal(s) => Ok(ResolvedValue::new(Value::String(s.clone()))),
402
403            Interpolation::Resolver { name, args, kwargs } => {
404                // Create resolver context
405                let mut ctx = ResolverContext::new(path);
406                ctx.config_root = Some(Arc::clone(&self.raw));
407                if let Some(base) = &self.options.base_path {
408                    ctx.base_path = Some(base.clone());
409                }
410
411                // Resolve arguments
412                let resolved_args: Vec<String> = args
413                    .iter()
414                    .map(|arg| self.resolve_arg(arg, path))
415                    .collect::<Result<Vec<_>>>()?;
416
417                let resolved_kwargs: HashMap<String, String> = kwargs
418                    .iter()
419                    .map(|(k, v)| Ok((k.clone(), self.resolve_arg(v, path)?)))
420                    .collect::<Result<HashMap<_, _>>>()?;
421
422                // Call the resolver
423                self.resolvers
424                    .resolve(name, &resolved_args, &resolved_kwargs, &ctx)
425            }
426
427            Interpolation::SelfRef {
428                path: ref_path,
429                relative,
430            } => {
431                let full_path = if *relative {
432                    self.resolve_relative_path(path, ref_path)
433                } else {
434                    ref_path.clone()
435                };
436
437                // Check for circular reference
438                // For now, simple implementation - full cycle detection would need context tracking
439                if full_path == path {
440                    return Err(Error::circular_reference(
441                        path,
442                        vec![path.to_string(), full_path],
443                    ));
444                }
445
446                // Get the referenced value
447                let ref_value = self
448                    .raw
449                    .get_path(&full_path)
450                    .map_err(|_| Error::ref_not_found(&full_path, Some(path.to_string())))?;
451
452                // Resolve it recursively
453                self.resolve_value(ref_value, &full_path)
454            }
455
456            Interpolation::Concat(parts) => {
457                let mut result = String::new();
458                let mut any_sensitive = false;
459
460                for part in parts {
461                    let resolved = self.resolve_interpolation(part, path)?;
462                    any_sensitive = any_sensitive || resolved.sensitive;
463
464                    match resolved.value {
465                        Value::String(s) => result.push_str(&s),
466                        other => result.push_str(&other.to_string()),
467                    }
468                }
469
470                if any_sensitive {
471                    Ok(ResolvedValue::sensitive(Value::String(result)))
472                } else {
473                    Ok(ResolvedValue::new(Value::String(result)))
474                }
475            }
476        }
477    }
478
479    /// Resolve an interpolation argument
480    fn resolve_arg(&self, arg: &InterpolationArg, path: &str) -> Result<String> {
481        match arg {
482            InterpolationArg::Literal(s) => Ok(s.clone()),
483            InterpolationArg::Nested(interp) => {
484                let resolved = self.resolve_interpolation(interp, path)?;
485                match resolved.value {
486                    Value::String(s) => Ok(s),
487                    other => Ok(other.to_string()),
488                }
489            }
490        }
491    }
492
493    /// Resolve a relative path reference
494    fn resolve_relative_path(&self, current_path: &str, ref_path: &str) -> String {
495        let mut ref_chars = ref_path.chars().peekable();
496        let mut levels_up = 0;
497
498        // Count leading dots for parent references
499        while ref_chars.peek() == Some(&'.') {
500            ref_chars.next();
501            levels_up += 1;
502        }
503
504        // Get the remaining path
505        let remaining: String = ref_chars.collect();
506
507        if levels_up == 0 {
508            // No dots - shouldn't happen for relative paths
509            return ref_path.to_string();
510        }
511
512        // Split current path into segments
513        let mut segments: Vec<&str> = current_path.split('.').collect();
514
515        // Remove segments based on levels up
516        // levels_up = 1 means sibling (remove last segment)
517        // levels_up = 2 means parent's sibling (remove last 2 segments)
518        for _ in 0..levels_up {
519            segments.pop();
520        }
521
522        // Append the remaining path
523        if remaining.is_empty() {
524            segments.join(".")
525        } else if segments.is_empty() {
526            remaining
527        } else {
528            format!("{}.{}", segments.join("."), remaining)
529        }
530    }
531
532    /// Recursively resolve all values
533    fn resolve_value_recursive(&self, value: &Value, path: &str) -> Result<ResolvedValue> {
534        match value {
535            Value::String(s) => {
536                if interpolation::needs_processing(s) {
537                    let parsed = interpolation::parse(s)?;
538                    let resolved = self.resolve_interpolation(&parsed, path)?;
539
540                    // Cache the result
541                    let mut cache = self.cache.write().unwrap();
542                    cache.insert(path.to_string(), resolved.clone());
543
544                    Ok(resolved)
545                } else {
546                    Ok(ResolvedValue::new(value.clone()))
547                }
548            }
549            Value::Sequence(seq) => {
550                for (i, item) in seq.iter().enumerate() {
551                    let item_path = format!("{}[{}]", path, i);
552                    self.resolve_value_recursive(item, &item_path)?;
553                }
554                Ok(ResolvedValue::new(value.clone()))
555            }
556            Value::Mapping(map) => {
557                for (key, val) in map {
558                    let key_path = if path.is_empty() {
559                        key.clone()
560                    } else {
561                        format!("{}.{}", path, key)
562                    };
563                    self.resolve_value_recursive(val, &key_path)?;
564                }
565                Ok(ResolvedValue::new(value.clone()))
566            }
567            _ => Ok(ResolvedValue::new(value.clone())),
568        }
569    }
570
571    /// Resolve a value tree to a new Value
572    fn resolve_value_to_value(&self, value: &Value, path: &str) -> Result<Value> {
573        match value {
574            Value::String(s) => {
575                if interpolation::needs_processing(s) {
576                    let parsed = interpolation::parse(s)?;
577                    let resolved = self.resolve_interpolation(&parsed, path)?;
578                    Ok(resolved.value)
579                } else {
580                    Ok(value.clone())
581                }
582            }
583            Value::Sequence(seq) => {
584                let resolved: Result<Vec<Value>> = seq
585                    .iter()
586                    .enumerate()
587                    .map(|(i, item)| {
588                        let item_path = format!("{}[{}]", path, i);
589                        self.resolve_value_to_value(item, &item_path)
590                    })
591                    .collect();
592                Ok(Value::Sequence(resolved?))
593            }
594            Value::Mapping(map) => {
595                let mut resolved = indexmap::IndexMap::new();
596                for (key, val) in map {
597                    let key_path = if path.is_empty() {
598                        key.clone()
599                    } else {
600                        format!("{}.{}", path, key)
601                    };
602                    resolved.insert(key.clone(), self.resolve_value_to_value(val, &key_path)?);
603                }
604                Ok(Value::Mapping(resolved))
605            }
606            _ => Ok(value.clone()),
607        }
608    }
609
610    /// Resolve a value tree to a new Value with sensitive value redaction
611    fn resolve_value_to_value_redacted(&self, value: &Value, path: &str) -> Result<Value> {
612        const REDACTED: &str = "[REDACTED]";
613
614        match value {
615            Value::String(s) => {
616                if interpolation::needs_processing(s) {
617                    let parsed = interpolation::parse(s)?;
618                    let resolved = self.resolve_interpolation(&parsed, path)?;
619                    if resolved.sensitive {
620                        Ok(Value::String(REDACTED.to_string()))
621                    } else {
622                        Ok(resolved.value)
623                    }
624                } else {
625                    Ok(value.clone())
626                }
627            }
628            Value::Sequence(seq) => {
629                let resolved: Result<Vec<Value>> = seq
630                    .iter()
631                    .enumerate()
632                    .map(|(i, item)| {
633                        let item_path = format!("{}[{}]", path, i);
634                        self.resolve_value_to_value_redacted(item, &item_path)
635                    })
636                    .collect();
637                Ok(Value::Sequence(resolved?))
638            }
639            Value::Mapping(map) => {
640                let mut resolved = indexmap::IndexMap::new();
641                for (key, val) in map {
642                    let key_path = if path.is_empty() {
643                        key.clone()
644                    } else {
645                        format!("{}.{}", path, key)
646                    };
647                    resolved.insert(
648                        key.clone(),
649                        self.resolve_value_to_value_redacted(val, &key_path)?,
650                    );
651                }
652                Ok(Value::Mapping(resolved))
653            }
654            _ => Ok(value.clone()),
655        }
656    }
657}
658
659impl Clone for Config {
660    fn clone(&self) -> Self {
661        Self {
662            raw: Arc::clone(&self.raw),
663            cache: Arc::new(RwLock::new(HashMap::new())), // Fresh cache per ADR-010
664            resolvers: Arc::clone(&self.resolvers),
665            options: self.options.clone(),
666        }
667    }
668}
669
670#[cfg(test)]
671mod tests {
672    use super::*;
673
674    #[test]
675    fn test_load_yaml() {
676        let yaml = r#"
677database:
678  host: localhost
679  port: 5432
680"#;
681        let config = Config::from_yaml(yaml).unwrap();
682
683        assert_eq!(
684            config.get("database.host").unwrap().as_str(),
685            Some("localhost")
686        );
687        assert_eq!(config.get("database.port").unwrap().as_i64(), Some(5432));
688    }
689
690    #[test]
691    fn test_env_resolver() {
692        std::env::set_var("HOLOCONF_TEST_HOST", "prod-server");
693
694        let yaml = r#"
695server:
696  host: ${env:HOLOCONF_TEST_HOST}
697"#;
698        let config = Config::from_yaml(yaml).unwrap();
699
700        assert_eq!(
701            config.get("server.host").unwrap().as_str(),
702            Some("prod-server")
703        );
704
705        std::env::remove_var("HOLOCONF_TEST_HOST");
706    }
707
708    #[test]
709    fn test_env_resolver_with_default() {
710        std::env::remove_var("HOLOCONF_MISSING_VAR");
711
712        let yaml = r#"
713server:
714  host: ${env:HOLOCONF_MISSING_VAR,default-host}
715"#;
716        let config = Config::from_yaml(yaml).unwrap();
717
718        assert_eq!(
719            config.get("server.host").unwrap().as_str(),
720            Some("default-host")
721        );
722    }
723
724    #[test]
725    fn test_self_reference() {
726        let yaml = r#"
727defaults:
728  host: localhost
729database:
730  host: ${defaults.host}
731"#;
732        let config = Config::from_yaml(yaml).unwrap();
733
734        assert_eq!(
735            config.get("database.host").unwrap().as_str(),
736            Some("localhost")
737        );
738    }
739
740    #[test]
741    fn test_string_concatenation() {
742        std::env::set_var("HOLOCONF_PREFIX", "prod");
743
744        let yaml = r#"
745bucket: myapp-${env:HOLOCONF_PREFIX}-data
746"#;
747        let config = Config::from_yaml(yaml).unwrap();
748
749        assert_eq!(
750            config.get("bucket").unwrap().as_str(),
751            Some("myapp-prod-data")
752        );
753
754        std::env::remove_var("HOLOCONF_PREFIX");
755    }
756
757    #[test]
758    fn test_escaped_interpolation() {
759        // In YAML, we need to quote the value to preserve the backslash properly
760        // Or the backslash escapes the $
761        let yaml = r#"
762literal: '\${not_resolved}'
763"#;
764        let config = Config::from_yaml(yaml).unwrap();
765
766        // After parsing, the backslash-$ sequence becomes just ${
767        assert_eq!(
768            config.get("literal").unwrap().as_str(),
769            Some("${not_resolved}")
770        );
771    }
772
773    #[test]
774    fn test_type_coercion_string_to_int() {
775        std::env::set_var("HOLOCONF_PORT", "8080");
776
777        let yaml = r#"
778port: ${env:HOLOCONF_PORT}
779"#;
780        let config = Config::from_yaml(yaml).unwrap();
781
782        // get_i64 should coerce the string to integer
783        assert_eq!(config.get_i64("port").unwrap(), 8080);
784
785        std::env::remove_var("HOLOCONF_PORT");
786    }
787
788    #[test]
789    fn test_strict_boolean_coercion() {
790        std::env::set_var("HOLOCONF_ENABLED", "true");
791        std::env::set_var("HOLOCONF_INVALID", "1");
792
793        let yaml = r#"
794enabled: ${env:HOLOCONF_ENABLED}
795invalid: ${env:HOLOCONF_INVALID}
796"#;
797        let config = Config::from_yaml(yaml).unwrap();
798
799        // "true" should work
800        assert!(config.get_bool("enabled").unwrap());
801
802        // "1" should NOT work per ADR-012
803        assert!(config.get_bool("invalid").is_err());
804
805        std::env::remove_var("HOLOCONF_ENABLED");
806        std::env::remove_var("HOLOCONF_INVALID");
807    }
808
809    #[test]
810    fn test_caching() {
811        std::env::set_var("HOLOCONF_CACHED", "initial");
812
813        let yaml = r#"
814value: ${env:HOLOCONF_CACHED}
815"#;
816        let config = Config::from_yaml(yaml).unwrap();
817
818        // First access resolves and caches
819        assert_eq!(config.get("value").unwrap().as_str(), Some("initial"));
820
821        // Change the env var
822        std::env::set_var("HOLOCONF_CACHED", "changed");
823
824        // Second access returns cached value
825        assert_eq!(config.get("value").unwrap().as_str(), Some("initial"));
826
827        // Clear cache
828        config.clear_cache();
829
830        // Now returns new value
831        assert_eq!(config.get("value").unwrap().as_str(), Some("changed"));
832
833        std::env::remove_var("HOLOCONF_CACHED");
834    }
835
836    #[test]
837    fn test_path_not_found() {
838        let yaml = r#"
839database:
840  host: localhost
841"#;
842        let config = Config::from_yaml(yaml).unwrap();
843
844        let result = config.get("database.nonexistent");
845        assert!(result.is_err());
846    }
847
848    #[test]
849    fn test_to_yaml() {
850        std::env::set_var("HOLOCONF_EXPORT_HOST", "exported-host");
851
852        let yaml = r#"
853server:
854  host: ${env:HOLOCONF_EXPORT_HOST}
855  port: 8080
856"#;
857        let config = Config::from_yaml(yaml).unwrap();
858
859        let exported = config.to_yaml().unwrap();
860        assert!(exported.contains("exported-host"));
861        assert!(exported.contains("8080"));
862
863        std::env::remove_var("HOLOCONF_EXPORT_HOST");
864    }
865
866    #[test]
867    fn test_relative_path_sibling() {
868        let yaml = r#"
869database:
870  host: localhost
871  url: postgres://${.host}:5432/db
872"#;
873        let config = Config::from_yaml(yaml).unwrap();
874
875        assert_eq!(
876            config.get("database.url").unwrap().as_str(),
877            Some("postgres://localhost:5432/db")
878        );
879    }
880
881    #[test]
882    fn test_array_access() {
883        let yaml = r#"
884servers:
885  - host: server1
886  - host: server2
887primary: ${servers[0].host}
888"#;
889        let config = Config::from_yaml(yaml).unwrap();
890
891        assert_eq!(config.get("primary").unwrap().as_str(), Some("server1"));
892    }
893
894    #[test]
895    fn test_nested_interpolation() {
896        std::env::set_var("HOLOCONF_DEFAULT_HOST", "fallback-host");
897
898        let yaml = r#"
899host: ${env:UNDEFINED_HOST,${env:HOLOCONF_DEFAULT_HOST}}
900"#;
901        let config = Config::from_yaml(yaml).unwrap();
902
903        assert_eq!(config.get("host").unwrap().as_str(), Some("fallback-host"));
904
905        std::env::remove_var("HOLOCONF_DEFAULT_HOST");
906    }
907
908    #[test]
909    fn test_to_yaml_raw() {
910        let yaml = r#"
911server:
912  host: ${env:MY_HOST}
913  port: 8080
914"#;
915        let config = Config::from_yaml(yaml).unwrap();
916
917        let raw = config.to_yaml_raw().unwrap();
918        // Should contain the placeholder, not a resolved value
919        assert!(raw.contains("${env:MY_HOST}"));
920        assert!(raw.contains("8080"));
921    }
922
923    #[test]
924    fn test_to_json_raw() {
925        let yaml = r#"
926database:
927  url: ${env:DATABASE_URL}
928"#;
929        let config = Config::from_yaml(yaml).unwrap();
930
931        let raw = config.to_json_raw().unwrap();
932        // Should contain the placeholder
933        assert!(raw.contains("${env:DATABASE_URL}"));
934    }
935
936    #[test]
937    fn test_to_value_raw() {
938        let yaml = r#"
939key: ${env:SOME_VAR}
940"#;
941        let config = Config::from_yaml(yaml).unwrap();
942
943        let raw = config.to_value_raw();
944        assert_eq!(
945            raw.get_path("key").unwrap().as_str(),
946            Some("${env:SOME_VAR}")
947        );
948    }
949
950    #[test]
951    fn test_to_yaml_redacted_no_sensitive() {
952        std::env::set_var("HOLOCONF_NON_SENSITIVE", "public-value");
953
954        let yaml = r#"
955value: ${env:HOLOCONF_NON_SENSITIVE}
956"#;
957        let config = Config::from_yaml(yaml).unwrap();
958
959        // With redact=true, but no sensitive values, should show real values
960        let output = config.to_yaml_redacted(true).unwrap();
961        assert!(output.contains("public-value"));
962
963        std::env::remove_var("HOLOCONF_NON_SENSITIVE");
964    }
965}