Skip to main content

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::{global_registry, ResolvedValue, ResolverContext, ResolverRegistry};
13use crate::value::Value;
14
15/// Maximum depth for nested interpolation resolution to prevent stack overflow
16const MAX_RESOLUTION_DEPTH: usize = 100;
17
18/// Check if a path string contains glob metacharacters
19fn is_glob_pattern(path: &str) -> bool {
20    path.contains('*') || path.contains('?') || path.contains('[')
21}
22
23/// Expand a glob pattern to matching paths, sorted alphabetically
24fn expand_glob(pattern: &str) -> Result<Vec<PathBuf>> {
25    let mut paths: Vec<PathBuf> = glob::glob(pattern)
26        .map_err(|e| Error::parse(format!("Invalid glob pattern '{}': {}", pattern, e)))?
27        .filter_map(|r| r.ok())
28        .collect();
29    paths.sort();
30    Ok(paths)
31}
32
33/// Configuration options for loading configs
34#[derive(Debug, Clone, Default)]
35pub struct ConfigOptions {
36    /// Base path for relative file references
37    pub base_path: Option<PathBuf>,
38    /// Allow HTTP resolver (disabled by default for security)
39    pub allow_http: bool,
40    /// HTTP URL allowlist (glob patterns)
41    pub http_allowlist: Vec<String>,
42    /// Additional file roots for file resolver sandboxing
43    pub file_roots: Vec<PathBuf>,
44
45    // --- TLS/Proxy Options ---
46    /// HTTP proxy URL (e.g., "http://proxy:8080" or "socks5://proxy:1080")
47    pub http_proxy: Option<String>,
48    /// Whether to auto-detect proxy from environment variables (HTTP_PROXY, HTTPS_PROXY)
49    pub http_proxy_from_env: bool,
50    /// CA bundle (file path or PEM content) - replaces default webpki-roots
51    pub http_ca_bundle: Option<crate::resolver::CertInput>,
52    /// Extra CA bundle (file path or PEM content) - appends to webpki-roots
53    pub http_extra_ca_bundle: Option<crate::resolver::CertInput>,
54    /// Client certificate for mTLS (file path, PEM content, or P12/PFX binary)
55    pub http_client_cert: Option<crate::resolver::CertInput>,
56    /// Client private key for mTLS (file path or PEM content, not needed for P12/PFX)
57    pub http_client_key: Option<crate::resolver::CertInput>,
58    /// Password for encrypted private key or P12/PFX file
59    pub http_client_key_password: Option<String>,
60    // NOTE: http_insecure was removed in v0.3.0 for security reasons.
61    // Use the insecure=true kwarg on individual http/https resolver calls instead.
62    // This adds friction to prevent accidental use in production.
63}
64
65/// The main configuration container
66///
67/// Config provides lazy resolution of interpolation expressions
68/// and caches resolved values for efficiency.
69pub struct Config {
70    /// The raw (unresolved) configuration data
71    raw: Arc<Value>,
72    /// Cache of resolved values
73    cache: Arc<RwLock<HashMap<String, ResolvedValue>>>,
74    /// Source file for each config path (tracks which file a value came from)
75    source_map: Arc<HashMap<String, String>>,
76    /// Resolver registry
77    resolvers: Arc<ResolverRegistry>,
78    /// Configuration options
79    options: ConfigOptions,
80    /// Optional schema for default value lookup
81    schema: Option<Arc<crate::schema::Schema>>,
82}
83
84/// Clone the global registry for use in a Config instance
85fn clone_global_registry() -> Arc<ResolverRegistry> {
86    let global = global_registry()
87        .read()
88        .expect("Global registry lock poisoned");
89    Arc::new(global.clone())
90}
91
92impl Config {
93    /// Create a new Config from a Value
94    ///
95    /// The config will use resolvers from the global registry.
96    pub fn new(value: Value) -> Self {
97        Self {
98            raw: Arc::new(value),
99            cache: Arc::new(RwLock::new(HashMap::new())),
100            source_map: Arc::new(HashMap::new()),
101            resolvers: clone_global_registry(),
102            options: ConfigOptions::default(),
103            schema: None,
104        }
105    }
106
107    /// Create a Config with custom options
108    ///
109    /// The config will use resolvers from the global registry.
110    pub fn with_options(value: Value, options: ConfigOptions) -> Self {
111        Self {
112            raw: Arc::new(value),
113            cache: Arc::new(RwLock::new(HashMap::new())),
114            source_map: Arc::new(HashMap::new()),
115            resolvers: clone_global_registry(),
116            options,
117            schema: None,
118        }
119    }
120
121    /// Create a Config with options and source map
122    fn with_options_and_sources(
123        value: Value,
124        options: ConfigOptions,
125        source_map: HashMap<String, String>,
126    ) -> Self {
127        Self {
128            raw: Arc::new(value),
129            cache: Arc::new(RwLock::new(HashMap::new())),
130            source_map: Arc::new(source_map),
131            resolvers: clone_global_registry(),
132            options,
133            schema: None,
134        }
135    }
136
137    /// Create a Config with a custom resolver registry
138    pub fn with_resolvers(value: Value, resolvers: ResolverRegistry) -> Self {
139        Self {
140            raw: Arc::new(value),
141            cache: Arc::new(RwLock::new(HashMap::new())),
142            source_map: Arc::new(HashMap::new()),
143            resolvers: Arc::new(resolvers),
144            options: ConfigOptions::default(),
145            schema: None,
146        }
147    }
148
149    /// Load configuration from a YAML string
150    pub fn from_yaml(yaml: &str) -> Result<Self> {
151        let value: Value = serde_yaml::from_str(yaml).map_err(|e| Error::parse(e.to_string()))?;
152        Ok(Self::new(value))
153    }
154
155    /// Load configuration from a YAML string with options
156    pub fn from_yaml_with_options(yaml: &str, options: ConfigOptions) -> Result<Self> {
157        let value: Value = serde_yaml::from_str(yaml).map_err(|e| Error::parse(e.to_string()))?;
158        Ok(Self::with_options(value, options))
159    }
160
161    /// Load configuration from a YAML file (required - errors if missing)
162    ///
163    /// This is the primary way to load configuration. Use `Config::optional()`
164    /// for files that may not exist.
165    ///
166    /// Supports glob patterns like `config/*.yaml` or `config/**/*.yaml`.
167    /// When a glob pattern is used, matching files are sorted alphabetically
168    /// and merged in order (later files override earlier ones).
169    ///
170    /// # Example
171    ///
172    /// ```ignore
173    /// let config = Config::load("config.yaml")?;
174    /// let merged = Config::load("config/*.yaml")?;
175    /// ```
176    pub fn load(path: impl AsRef<Path>) -> Result<Self> {
177        let path_str = path.as_ref().to_string_lossy();
178
179        if is_glob_pattern(&path_str) {
180            let paths = expand_glob(&path_str)?;
181            if paths.is_empty() {
182                return Err(Error::file_not_found(
183                    format!("No files matched glob pattern '{}'", path_str),
184                    None,
185                ));
186            }
187            // Load first file, then merge the rest
188            let mut config = Self::from_yaml_file(&paths[0])?;
189            for p in &paths[1..] {
190                let other = Self::from_yaml_file(p)?;
191                config.merge(other);
192            }
193            Ok(config)
194        } else {
195            Self::from_yaml_file(path)
196        }
197    }
198
199    /// Load a configuration file with custom options
200    ///
201    /// This is the main entry point for loading config with HTTP/TLS/proxy options.
202    /// Supports glob patterns like `config/*.yaml` or `config/**/*.yaml`.
203    /// When a glob pattern is used, matching files are sorted alphabetically
204    /// and merged in order.
205    ///
206    /// # Example
207    ///
208    /// ```ignore
209    /// let mut options = ConfigOptions::default();
210    /// options.allow_http = true;
211    /// options.http_proxy = Some("http://proxy:8080".into());
212    /// let config = Config::load_with_options("config.yaml", options)?;
213    /// ```
214    pub fn load_with_options(path: impl AsRef<Path>, options: ConfigOptions) -> Result<Self> {
215        let path_str = path.as_ref().to_string_lossy();
216
217        if is_glob_pattern(&path_str) {
218            let paths = expand_glob(&path_str)?;
219            if paths.is_empty() {
220                return Err(Error::file_not_found(
221                    format!("No files matched glob pattern '{}'", path_str),
222                    None,
223                ));
224            }
225            // Load first file with options, then merge the rest
226            let mut config = Self::from_yaml_file_with_options(&paths[0], options.clone())?;
227            for p in &paths[1..] {
228                let other = Self::from_yaml_file_with_options(p, options.clone())?;
229                config.merge(other);
230            }
231            Ok(config)
232        } else {
233            Self::from_yaml_file_with_options(path, options)
234        }
235    }
236
237    /// Alias for `load()` - load a required config file
238    ///
239    /// Provided for symmetry with `Config::optional()`.
240    pub fn required(path: impl AsRef<Path>) -> Result<Self> {
241        Self::load(path)
242    }
243
244    /// Load a required config file with custom options
245    pub fn required_with_options(path: impl AsRef<Path>, options: ConfigOptions) -> Result<Self> {
246        Self::load_with_options(path, options)
247    }
248
249    /// Load an optional configuration file
250    ///
251    /// Returns an empty Config if the file doesn't exist.
252    /// Use this for configuration files that may or may not be present,
253    /// such as local overrides.
254    ///
255    /// Supports glob patterns like `config/*.yaml` or `config/**/*.yaml`.
256    /// When a glob pattern is used, matching files are sorted alphabetically
257    /// and merged in order. Returns empty config if no files match.
258    ///
259    /// # Example
260    ///
261    /// ```ignore
262    /// let base = Config::load("base.yaml")?;
263    /// let local = Config::optional("local.yaml")?;
264    /// let overrides = Config::optional("config/*.yaml")?;
265    /// base.merge(&local);
266    /// ```
267    pub fn optional(path: impl AsRef<Path>) -> Result<Self> {
268        let path_str = path.as_ref().to_string_lossy();
269
270        if is_glob_pattern(&path_str) {
271            let paths = expand_glob(&path_str)?;
272            if paths.is_empty() {
273                // No files matched - return empty config (this is optional)
274                return Ok(Self::new(Value::Mapping(indexmap::IndexMap::new())));
275            }
276            // Load first file, then merge the rest
277            let mut config = Self::from_yaml_file(&paths[0])?;
278            for p in &paths[1..] {
279                let other = Self::from_yaml_file(p)?;
280                config.merge(other);
281            }
282            Ok(config)
283        } else {
284            Self::optional_single_file(path)
285        }
286    }
287
288    /// Load a single optional file (non-glob)
289    fn optional_single_file(path: impl AsRef<Path>) -> Result<Self> {
290        let path = path.as_ref();
291        match std::fs::read_to_string(path) {
292            Ok(content) => {
293                let value: Value =
294                    serde_yaml::from_str(&content).map_err(|e| Error::parse(e.to_string()))?;
295
296                let filename = path
297                    .file_name()
298                    .and_then(|n| n.to_str())
299                    .unwrap_or_default()
300                    .to_string();
301                let mut source_map = HashMap::new();
302                value.collect_leaf_paths("", &filename, &mut source_map);
303
304                let mut options = ConfigOptions::default();
305                options.base_path = path.parent().map(|p| p.to_path_buf());
306
307                Ok(Self::with_options_and_sources(value, options, source_map))
308            }
309            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
310                // File doesn't exist - return empty config
311                Ok(Self::new(Value::Mapping(indexmap::IndexMap::new())))
312            }
313            Err(e) => Err(Error::parse(format!(
314                "Failed to read file '{}': {}",
315                path.display(),
316                e
317            ))),
318        }
319    }
320
321    /// Load configuration from a YAML file
322    fn from_yaml_file(path: impl AsRef<Path>) -> Result<Self> {
323        Self::from_yaml_file_with_options(path, ConfigOptions::default())
324    }
325
326    /// Load configuration from a YAML file with custom options
327    fn from_yaml_file_with_options(
328        path: impl AsRef<Path>,
329        mut options: ConfigOptions,
330    ) -> Result<Self> {
331        let path = path.as_ref();
332        let content = std::fs::read_to_string(path).map_err(|e| {
333            if e.kind() == std::io::ErrorKind::NotFound {
334                Error::file_not_found(path.display().to_string(), None)
335            } else {
336                Error::parse(format!("Failed to read file '{}': {}", path.display(), e))
337            }
338        })?;
339
340        let value: Value =
341            serde_yaml::from_str(&content).map_err(|e| Error::parse(e.to_string()))?;
342
343        // Track source for all leaf paths
344        let filename = path
345            .file_name()
346            .and_then(|n| n.to_str())
347            .unwrap_or_default()
348            .to_string();
349        let mut source_map = HashMap::new();
350        value.collect_leaf_paths("", &filename, &mut source_map);
351
352        // Set base_path from file location if not already set
353        if options.base_path.is_none() {
354            options.base_path = path.parent().map(|p| p.to_path_buf());
355        }
356
357        // Auto-add parent directory to file_roots for path traversal protection
358        if let Some(parent) = path.parent() {
359            options.file_roots.push(parent.to_path_buf());
360        }
361
362        Ok(Self::with_options_and_sources(value, options, source_map))
363    }
364
365    /// Merge another config into this one
366    ///
367    /// The other config's values override this config's values per ADR-004 merge semantics.
368    /// File roots from both configs are unioned for path traversal protection.
369    pub fn merge(&mut self, other: Config) {
370        // Get a mutable reference to our raw value
371        if let Some(raw) = Arc::get_mut(&mut self.raw) {
372            raw.merge((*other.raw).clone());
373        } else {
374            // Need to clone and replace
375            let mut new_raw = (*self.raw).clone();
376            new_raw.merge((*other.raw).clone());
377            self.raw = Arc::new(new_raw);
378        }
379
380        // Union file_roots from both configs
381        for root in &other.options.file_roots {
382            if !self.options.file_roots.contains(root) {
383                self.options.file_roots.push(root.clone());
384            }
385        }
386
387        // Clear the cache since values may have changed
388        self.clear_cache();
389    }
390
391    /// Load configuration from a JSON string
392    pub fn from_json(json: &str) -> Result<Self> {
393        let value: Value = serde_json::from_str(json).map_err(|e| Error::parse(e.to_string()))?;
394        Ok(Self::new(value))
395    }
396
397    /// Set or replace the schema for default value lookup
398    ///
399    /// When a schema is attached, `get()` will return schema defaults for
400    /// missing paths instead of raising `PathNotFoundError`.
401    ///
402    /// Note: Setting a schema clears the value cache since defaults may
403    /// now affect lookups.
404    pub fn set_schema(&mut self, schema: crate::schema::Schema) {
405        self.schema = Some(Arc::new(schema));
406        self.clear_cache();
407    }
408
409    /// Get a reference to the attached schema, if any
410    pub fn get_schema(&self) -> Option<&crate::schema::Schema> {
411        self.schema.as_ref().map(|s| s.as_ref())
412    }
413
414    /// Get the raw (unresolved) value at a path
415    pub fn get_raw(&self, path: &str) -> Result<&Value> {
416        self.raw.get_path(path)
417    }
418
419    /// Get a resolved value at a path
420    ///
421    /// This resolves any interpolation expressions in the value.
422    /// Resolved values are cached for subsequent accesses.
423    ///
424    /// If a schema is attached and the path is not found (or is null when null
425    /// is not allowed), the schema default is returned instead.
426    pub fn get(&self, path: &str) -> Result<Value> {
427        // Check cache first
428        {
429            let cache = self.cache.read().expect("Cache lock poisoned");
430            if let Some(cached) = cache.get(path) {
431                return Ok(cached.value.clone());
432            }
433        }
434
435        // FIRST: Try to get raw value from the YAML tree
436        let raw_result = self.raw.get_path(path);
437
438        // If the path exists in raw YAML, use normal resolution
439        if let Ok(raw_value) = raw_result {
440            // Resolve the value with an empty resolution stack
441            let mut resolution_stack = Vec::new();
442            let resolved = self.resolve_value(raw_value, path, &mut resolution_stack)?;
443
444            // Materialize streams before caching to ensure concrete values
445            let materialized_value = resolved.value.materialize()?;
446
447            // Check for null value that should use schema default
448            if materialized_value.is_null() {
449                if let Some(ref schema) = self.schema {
450                    // Only use default if schema doesn't allow null
451                    if !schema.allows_null(path) {
452                        if let Some(default) = schema.get_default(path) {
453                            return Ok(default);
454                        }
455                    }
456                }
457            }
458
459            // Create resolved value with materialized data
460            let cached_value = crate::resolver::ResolvedValue {
461                value: materialized_value.clone(),
462                sensitive: resolved.sensitive,
463            };
464
465            // Cache the resolved value
466            {
467                let mut cache = self.cache.write().expect("Cache lock poisoned");
468                cache.insert(path.to_string(), cached_value);
469            }
470
471            return Ok(materialized_value);
472        }
473
474        // Path not in raw YAML - check if it's a nested path into a resolved structure
475        // This handles cases like: data resolves to ${json:...} (a mapping),
476        // and we're requesting data.name, items[0], or users[0].email
477        if !path.is_empty() {
478            // Generate possible parent paths by removing segments from the right
479            let mut candidate_splits = Vec::new();
480
481            // Try splitting at each '.' or '[' from right to left
482            for (i, ch) in path.char_indices().rev() {
483                if ch == '.' || ch == '[' {
484                    let parent = &path[..i];
485                    let child = if ch == '.' {
486                        &path[i + 1..]
487                    } else {
488                        &path[i..]
489                    };
490                    if !parent.is_empty() && !child.is_empty() {
491                        candidate_splits.push((parent.to_string(), child.to_string()));
492                    }
493                }
494            }
495
496            // Try each candidate split
497            for (parent_path, child_path) in candidate_splits {
498                // Try to get the parent value (this will resolve it if needed)
499                if let Ok(parent_value) = self.get(&parent_path) {
500                    // Try to navigate into the resolved parent value
501                    if let Ok(child_value) = parent_value.get_path(&child_path) {
502                        return Ok(child_value.clone());
503                    }
504                }
505            }
506        }
507
508        // Path not found in raw YAML or resolved structures - check schema defaults
509        if let Some(schema) = &self.schema {
510            if let Some(default_value) = schema.get_default(path) {
511                let resolved_default = ResolvedValue::new(default_value.clone());
512                // Cache the default
513                {
514                    let mut cache = self.cache.write().expect("Cache lock poisoned");
515                    cache.insert(path.to_string(), resolved_default);
516                }
517                return Ok(default_value);
518            }
519        }
520
521        // No schema or no default - path not found
522        Err(Error::path_not_found(path))
523    }
524
525    /// Get a resolved string value, with type coercion if needed
526    pub fn get_string(&self, path: &str) -> Result<String> {
527        let value = self.get(path)?;
528        match value {
529            Value::String(s) => Ok(s),
530            Value::Integer(i) => Ok(i.to_string()),
531            Value::Float(f) => Ok(f.to_string()),
532            Value::Bool(b) => Ok(b.to_string()),
533            Value::Null => Ok("null".to_string()),
534            _ => Err(Error::type_coercion(path, "string", value.type_name())),
535        }
536    }
537
538    /// Get a resolved integer value, with type coercion if needed
539    pub fn get_i64(&self, path: &str) -> Result<i64> {
540        let value = self.get(path)?;
541        match value {
542            Value::Integer(i) => Ok(i),
543            Value::String(s) => s
544                .parse()
545                .map_err(|_| Error::type_coercion(path, "integer", format!("string (\"{}\")", s))),
546            _ => Err(Error::type_coercion(path, "integer", value.type_name())),
547        }
548    }
549
550    /// Get a resolved float value, with type coercion if needed
551    pub fn get_f64(&self, path: &str) -> Result<f64> {
552        let value = self.get(path)?;
553        match value {
554            Value::Float(f) => Ok(f),
555            Value::Integer(i) => Ok(i as f64),
556            Value::String(s) => s
557                .parse()
558                .map_err(|_| Error::type_coercion(path, "float", format!("string (\"{}\")", s))),
559            _ => Err(Error::type_coercion(path, "float", value.type_name())),
560        }
561    }
562
563    /// Get a resolved boolean value, with strict coercion per ADR-012
564    pub fn get_bool(&self, path: &str) -> Result<bool> {
565        let value = self.get(path)?;
566        match value {
567            Value::Bool(b) => Ok(b),
568            Value::String(s) => {
569                // Strict boolean coercion: only "true" and "false"
570                match s.to_lowercase().as_str() {
571                    "true" => Ok(true),
572                    "false" => Ok(false),
573                    _ => Err(Error::type_coercion(
574                        path,
575                        "boolean",
576                        format!("string (\"{}\") - only \"true\" or \"false\" allowed", s),
577                    )),
578                }
579            }
580            _ => Err(Error::type_coercion(path, "boolean", value.type_name())),
581        }
582    }
583
584    /// Resolve all values in the configuration eagerly
585    pub fn resolve_all(&self) -> Result<()> {
586        let mut resolution_stack = Vec::new();
587        self.resolve_value_recursive(&self.raw, "", &mut resolution_stack)?;
588        Ok(())
589    }
590
591    /// Export the configuration as a Value
592    ///
593    /// # Arguments
594    /// * `resolve` - If true, resolve interpolations (${...}). If false, show placeholders.
595    /// * `redact` - If true, replace sensitive values with "[REDACTED]". Only applies when resolve=true.
596    ///
597    /// # Examples
598    /// ```ignore
599    /// // Show raw config with placeholders (safest, fastest)
600    /// let raw = config.to_value(false, false)?;
601    ///
602    /// // Resolved with secrets redacted (safe for logs)
603    /// let safe = config.to_value(true, true)?;
604    ///
605    /// // Resolved with secrets visible (use with caution)
606    /// let full = config.to_value(true, false)?;
607    /// ```
608    pub fn to_value(&self, resolve: bool, redact: bool) -> Result<Value> {
609        if !resolve {
610            return Ok((*self.raw).clone());
611        }
612        let mut resolution_stack = Vec::new();
613        if redact {
614            self.resolve_value_to_value_redacted(&self.raw, "", &mut resolution_stack)
615        } else {
616            self.resolve_value_to_value(&self.raw, "", &mut resolution_stack)
617        }
618    }
619
620    /// Export the configuration as YAML
621    ///
622    /// # Arguments
623    /// * `resolve` - If true, resolve interpolations (${...}). If false, show placeholders.
624    /// * `redact` - If true, replace sensitive values with "[REDACTED]". Only applies when resolve=true.
625    ///
626    /// # Examples
627    /// ```ignore
628    /// // Show raw config with placeholders
629    /// let yaml = config.to_yaml(false, false)?;
630    ///
631    /// // Resolved with secrets redacted
632    /// let yaml = config.to_yaml(true, true)?;
633    /// ```
634    pub fn to_yaml(&self, resolve: bool, redact: bool) -> Result<String> {
635        let value = self.to_value(resolve, redact)?;
636        serde_yaml::to_string(&value).map_err(|e| Error::parse(e.to_string()))
637    }
638
639    /// Export the configuration as JSON
640    ///
641    /// # Arguments
642    /// * `resolve` - If true, resolve interpolations (${...}). If false, show placeholders.
643    /// * `redact` - If true, replace sensitive values with "[REDACTED]". Only applies when resolve=true.
644    ///
645    /// # Examples
646    /// ```ignore
647    /// // Show raw config with placeholders
648    /// let json = config.to_json(false, false)?;
649    ///
650    /// // Resolved with secrets redacted
651    /// let json = config.to_json(true, true)?;
652    /// ```
653    pub fn to_json(&self, resolve: bool, redact: bool) -> Result<String> {
654        let value = self.to_value(resolve, redact)?;
655        serde_json::to_string_pretty(&value).map_err(|e| Error::parse(e.to_string()))
656    }
657
658    /// Clear the resolution cache
659    pub fn clear_cache(&self) {
660        let mut cache = self.cache.write().expect("Cache lock poisoned");
661        cache.clear();
662    }
663
664    /// Get the source file for a config path
665    ///
666    /// Returns the filename of the config file that provided this value.
667    /// For merged configs, this returns the file that "won" for this path.
668    pub fn get_source(&self, path: &str) -> Option<&str> {
669        self.source_map.get(path).map(|s| s.as_str())
670    }
671
672    /// Get all source mappings
673    ///
674    /// Returns a map of config paths to their source filenames.
675    /// Useful for debugging which file each value came from.
676    pub fn dump_sources(&self) -> &HashMap<String, String> {
677        &self.source_map
678    }
679
680    /// Register a custom resolver
681    pub fn register_resolver(&mut self, resolver: Arc<dyn crate::resolver::Resolver>) {
682        // We need to get mutable access to the registry
683        // This is safe because we're the only owner at this point
684        if let Some(registry) = Arc::get_mut(&mut self.resolvers) {
685            registry.register(resolver);
686        }
687    }
688
689    /// Validate the raw (unresolved) configuration against a schema
690    ///
691    /// This performs structural validation (Phase 1 per ADR-007):
692    /// - Required keys are present
693    /// - Object/array structure matches
694    /// - Interpolations (${...}) are allowed as placeholders
695    ///
696    /// If `schema` is None, uses the attached schema (set via `set_schema()`).
697    /// Returns an error if no schema is provided and none is attached.
698    pub fn validate_raw(&self, schema: Option<&crate::schema::Schema>) -> Result<()> {
699        let schema = self.resolve_schema(schema)?;
700        schema.validate(&self.raw)
701    }
702
703    /// Validate the resolved configuration against a schema
704    ///
705    /// This performs type/value validation (Phase 2 per ADR-007):
706    /// - Resolved values match expected types
707    /// - Constraints (min, max, pattern, enum) are checked
708    ///
709    /// If `schema` is None, uses the attached schema (set via `set_schema()`).
710    /// Returns an error if no schema is provided and none is attached.
711    pub fn validate(&self, schema: Option<&crate::schema::Schema>) -> Result<()> {
712        let schema = self.resolve_schema(schema)?;
713        let resolved = self.to_value(true, false)?;
714        schema.validate(&resolved)
715    }
716
717    /// Validate and collect all errors (instead of failing on first)
718    ///
719    /// If `schema` is None, uses the attached schema (set via `set_schema()`).
720    /// Returns a single error if no schema is provided and none is attached.
721    pub fn validate_collect(
722        &self,
723        schema: Option<&crate::schema::Schema>,
724    ) -> Vec<crate::schema::ValidationError> {
725        let schema = match self.resolve_schema(schema) {
726            Ok(s) => s,
727            Err(e) => {
728                return vec![crate::schema::ValidationError {
729                    path: String::new(),
730                    message: e.to_string(),
731                }]
732            }
733        };
734        match self.to_value(true, false) {
735            Ok(resolved) => schema.validate_collect(&resolved),
736            Err(e) => vec![crate::schema::ValidationError {
737                path: String::new(),
738                message: e.to_string(),
739            }],
740        }
741    }
742
743    /// Helper to resolve which schema to use (provided or attached)
744    fn resolve_schema<'a>(
745        &'a self,
746        schema: Option<&'a crate::schema::Schema>,
747    ) -> Result<&'a crate::schema::Schema> {
748        schema
749            .or_else(|| self.schema.as_ref().map(|s| s.as_ref()))
750            .ok_or_else(|| Error::validation("<root>", "No schema provided and none attached"))
751    }
752
753    /// Resolve a single value
754    fn resolve_value(
755        &self,
756        value: &Value,
757        path: &str,
758        resolution_stack: &mut Vec<String>,
759    ) -> Result<ResolvedValue> {
760        match value {
761            Value::String(s) => {
762                // Use needs_processing to handle both interpolations AND escape sequences
763                if interpolation::needs_processing(s) {
764                    let parsed = interpolation::parse(s)?;
765                    self.resolve_interpolation(&parsed, path, resolution_stack)
766                } else {
767                    Ok(ResolvedValue::new(value.clone()))
768                }
769            }
770            _ => Ok(ResolvedValue::new(value.clone())),
771        }
772    }
773
774    /// Resolve an interpolation expression
775    fn resolve_interpolation(
776        &self,
777        interp: &Interpolation,
778        path: &str,
779        resolution_stack: &mut Vec<String>,
780    ) -> Result<ResolvedValue> {
781        // Check recursion depth to prevent stack overflow from deeply nested defaults
782        if resolution_stack.len() >= MAX_RESOLUTION_DEPTH {
783            return Err(Error::parse(format!(
784                "Maximum interpolation depth ({}) exceeded at path '{}'. \
785                 This may indicate deeply nested defaults or a complex reference chain.",
786                MAX_RESOLUTION_DEPTH, path
787            )));
788        }
789
790        match interp {
791            Interpolation::Literal(s) => Ok(ResolvedValue::new(Value::String(s.clone()))),
792
793            Interpolation::Resolver { name, args, kwargs } => {
794                // Special handling for "ref" resolver (config path references)
795                if name == "ref" {
796                    // Get the path from first argument
797                    let ref_path = args
798                        .first()
799                        .and_then(|arg| arg.as_literal())
800                        .ok_or_else(|| Error::parse("ref resolver requires a path argument"))?;
801
802                    // Check for circular reference
803                    if resolution_stack.contains(&ref_path.to_string()) {
804                        let mut chain = resolution_stack.clone();
805                        chain.push(ref_path.to_string());
806                        return Err(Error::circular_reference(path, chain));
807                    }
808
809                    // Try to get the value from config
810                    let value_result = self.raw.get_path(ref_path);
811                    let use_default = match &value_result {
812                        Ok(val) => val.is_null(),
813                        Err(_) => true,
814                    };
815
816                    if use_default {
817                        // Path missing or null - try defaults
818
819                        // 1. User-provided default (highest priority, never errors)
820                        if let Some(default_arg) = kwargs.get("default") {
821                            let default_str =
822                                self.resolve_arg(default_arg, path, resolution_stack)?;
823                            let is_sensitive = kwargs
824                                .get("sensitive")
825                                .and_then(|arg| arg.as_literal())
826                                .map(|v| v.eq_ignore_ascii_case("true"))
827                                .unwrap_or(false);
828                            return if is_sensitive {
829                                Ok(ResolvedValue::sensitive(Value::String(default_str)))
830                            } else {
831                                Ok(ResolvedValue::new(Value::String(default_str)))
832                            };
833                        }
834
835                        // 2. Schema default (if available)
836                        if let Some(schema) = &self.schema {
837                            if let Some(default_value) = schema.get_default(ref_path) {
838                                return Ok(ResolvedValue::new(default_value));
839                            }
840                        }
841
842                        // 3. No defaults - return error
843                        return match value_result {
844                            Err(e) => Err(e),
845                            Ok(_) => Err(Error::path_not_found(ref_path)), // Was null but no default
846                        };
847                    }
848
849                    // Value exists and not null - resolve it recursively
850                    // Safety: we know value_result is Ok and not null because use_default is false
851                    let ref_value =
852                        value_result.expect("value must exist when use_default is false");
853                    resolution_stack.push(ref_path.to_string());
854                    let mut result = self.resolve_value(ref_value, ref_path, resolution_stack)?;
855                    resolution_stack.pop();
856
857                    // Apply sensitive flag if present (can only make more sensitive, not less)
858                    // Once a value is marked sensitive by a lower layer, it cannot be unmarked
859                    let is_sensitive = kwargs
860                        .get("sensitive")
861                        .and_then(|arg| arg.as_literal())
862                        .map(|v| v.eq_ignore_ascii_case("true"))
863                        .unwrap_or(false);
864
865                    if is_sensitive {
866                        result.sensitive = true;
867                    }
868                    // Note: if result.sensitive is already true from referenced value,
869                    // it remains true even if sensitive= is not specified or is false
870
871                    return Ok(result);
872                }
873
874                // Normal resolver handling (for non-ref resolvers)
875                // Create resolver context with all options
876                let mut ctx = ResolverContext::new(path);
877                ctx.config_root = Some(Arc::clone(&self.raw));
878                if let Some(base) = &self.options.base_path {
879                    ctx.base_path = Some(base.clone());
880                }
881                // File security options
882                ctx.file_roots = self.options.file_roots.iter().cloned().collect();
883                // HTTP options
884                ctx.allow_http = self.options.allow_http;
885                ctx.http_allowlist = self.options.http_allowlist.clone();
886                // TLS/Proxy options
887                ctx.http_proxy = self.options.http_proxy.clone();
888                ctx.http_proxy_from_env = self.options.http_proxy_from_env;
889                ctx.http_ca_bundle = self.options.http_ca_bundle.clone();
890                ctx.http_extra_ca_bundle = self.options.http_extra_ca_bundle.clone();
891                ctx.http_client_cert = self.options.http_client_cert.clone();
892                ctx.http_client_key = self.options.http_client_key.clone();
893                ctx.http_client_key_password = self.options.http_client_key_password.clone();
894                // http_insecure removed - use insecure=true kwarg on resolver calls
895
896                // Resolve arguments
897                let resolved_args: Vec<String> = args
898                    .iter()
899                    .map(|arg| self.resolve_arg(arg, path, resolution_stack))
900                    .collect::<Result<Vec<_>>>()?;
901
902                // Extract and defer `default` kwarg for lazy resolution
903                // Resolve all other kwargs eagerly
904                let default_arg = kwargs.get("default");
905                let resolved_kwargs: HashMap<String, String> = kwargs
906                    .iter()
907                    .filter(|(k, _)| *k != "default") // Don't resolve default yet
908                    .map(|(k, v)| Ok((k.clone(), self.resolve_arg(v, path, resolution_stack)?)))
909                    .collect::<Result<HashMap<_, _>>>()?;
910
911                // Call the resolver (without `default` in kwargs)
912                let result = self
913                    .resolvers
914                    .resolve(name, &resolved_args, &resolved_kwargs, &ctx);
915
916                // Handle NotFound errors with lazy default resolution
917                match result {
918                    Ok(value) => Ok(value),
919                    Err(e) => {
920                        // Check if this is a "not found" type error that should use default
921                        let should_use_default = matches!(
922                            &e.kind,
923                            crate::error::ErrorKind::Resolver(
924                                crate::error::ResolverErrorKind::NotFound { .. }
925                            ) | crate::error::ErrorKind::Resolver(
926                                crate::error::ResolverErrorKind::EnvNotFound { .. }
927                            ) | crate::error::ErrorKind::Resolver(
928                                crate::error::ResolverErrorKind::FileNotFound { .. }
929                            )
930                        );
931
932                        if should_use_default {
933                            if let Some(default_arg) = default_arg {
934                                // Lazily resolve the default value now
935                                let default_str =
936                                    self.resolve_arg(default_arg, path, resolution_stack)?;
937
938                                // Apply sensitivity override if present
939                                let is_sensitive = resolved_kwargs
940                                    .get("sensitive")
941                                    .map(|v| v.eq_ignore_ascii_case("true"))
942                                    .unwrap_or(false);
943
944                                return if is_sensitive {
945                                    Ok(ResolvedValue::sensitive(Value::String(default_str)))
946                                } else {
947                                    Ok(ResolvedValue::new(Value::String(default_str)))
948                                };
949                            }
950                        }
951                        Err(e)
952                    }
953                }
954            }
955
956            Interpolation::SelfRef {
957                path: ref_path,
958                relative,
959            } => {
960                let full_path = if *relative {
961                    self.resolve_relative_path(path, ref_path)
962                } else {
963                    ref_path.clone()
964                };
965
966                // Check for circular reference using the resolution stack
967                if resolution_stack.contains(&full_path) {
968                    // Build the cycle chain for the error message
969                    let mut chain = resolution_stack.clone();
970                    chain.push(full_path.clone());
971                    return Err(Error::circular_reference(path, chain));
972                }
973
974                // Get the referenced value
975                let ref_value = self
976                    .raw
977                    .get_path(&full_path)
978                    .map_err(|_| Error::ref_not_found(&full_path, Some(path.to_string())))?;
979
980                // Push onto the resolution stack before resolving
981                resolution_stack.push(full_path.clone());
982
983                // Resolve it recursively
984                let result = self.resolve_value(ref_value, &full_path, resolution_stack);
985
986                // Pop from the resolution stack after resolving
987                resolution_stack.pop();
988
989                result
990            }
991
992            Interpolation::Concat(parts) => {
993                let mut result = String::new();
994                let mut any_sensitive = false;
995
996                for part in parts {
997                    let resolved = self.resolve_interpolation(part, path, resolution_stack)?;
998                    any_sensitive = any_sensitive || resolved.sensitive;
999
1000                    match resolved.value {
1001                        Value::String(s) => result.push_str(&s),
1002                        other => result.push_str(&other.to_string()),
1003                    }
1004                }
1005
1006                if any_sensitive {
1007                    Ok(ResolvedValue::sensitive(Value::String(result)))
1008                } else {
1009                    Ok(ResolvedValue::new(Value::String(result)))
1010                }
1011            }
1012        }
1013    }
1014
1015    /// Resolve an interpolation argument
1016    fn resolve_arg(
1017        &self,
1018        arg: &InterpolationArg,
1019        path: &str,
1020        resolution_stack: &mut Vec<String>,
1021    ) -> Result<String> {
1022        match arg {
1023            InterpolationArg::Literal(s) => Ok(s.clone()),
1024            InterpolationArg::Nested(interp) => {
1025                let resolved = self.resolve_interpolation(interp, path, resolution_stack)?;
1026                // Materialize streams before converting to string
1027                let materialized = resolved.value.materialize()?;
1028                match materialized {
1029                    Value::String(s) => Ok(s),
1030                    Value::Bytes(bytes) => {
1031                        // Convert bytes to string - try UTF-8 first, fall back to base64
1032                        Ok(String::from_utf8(bytes.clone()).unwrap_or_else(|_| {
1033                            use base64::{engine::general_purpose::STANDARD, Engine as _};
1034                            STANDARD.encode(&bytes)
1035                        }))
1036                    }
1037                    other => Ok(other.to_string()),
1038                }
1039            }
1040        }
1041    }
1042
1043    /// Resolve a relative path reference
1044    fn resolve_relative_path(&self, current_path: &str, ref_path: &str) -> String {
1045        let mut ref_chars = ref_path.chars().peekable();
1046        let mut levels_up = 0;
1047
1048        // Count leading dots for parent references
1049        while ref_chars.peek() == Some(&'.') {
1050            ref_chars.next();
1051            levels_up += 1;
1052        }
1053
1054        // Get the remaining path
1055        let remaining: String = ref_chars.collect();
1056
1057        if levels_up == 0 {
1058            // No dots - shouldn't happen for relative paths
1059            return ref_path.to_string();
1060        }
1061
1062        // Split current path into segments
1063        let mut segments: Vec<&str> = current_path.split('.').collect();
1064
1065        // Remove segments based on levels up
1066        // levels_up = 1 means sibling (remove last segment)
1067        // levels_up = 2 means parent's sibling (remove last 2 segments)
1068        for _ in 0..levels_up {
1069            segments.pop();
1070        }
1071
1072        // Append the remaining path
1073        if remaining.is_empty() {
1074            segments.join(".")
1075        } else if segments.is_empty() {
1076            remaining
1077        } else {
1078            format!("{}.{}", segments.join("."), remaining)
1079        }
1080    }
1081
1082    /// Recursively resolve all values
1083    fn resolve_value_recursive(
1084        &self,
1085        value: &Value,
1086        path: &str,
1087        resolution_stack: &mut Vec<String>,
1088    ) -> Result<ResolvedValue> {
1089        match value {
1090            Value::String(s) => {
1091                if interpolation::needs_processing(s) {
1092                    let parsed = interpolation::parse(s)?;
1093                    let resolved = self.resolve_interpolation(&parsed, path, resolution_stack)?;
1094
1095                    // Materialize streams before caching
1096                    let materialized_value = resolved.value.materialize()?;
1097                    let cached_resolved = ResolvedValue {
1098                        value: materialized_value,
1099                        sensitive: resolved.sensitive,
1100                    };
1101
1102                    // Cache the result
1103                    let mut cache = self.cache.write().expect("Cache lock poisoned");
1104                    cache.insert(path.to_string(), cached_resolved.clone());
1105
1106                    Ok(cached_resolved)
1107                } else {
1108                    Ok(ResolvedValue::new(value.clone()))
1109                }
1110            }
1111            Value::Sequence(seq) => {
1112                for (i, item) in seq.iter().enumerate() {
1113                    let item_path = format!("{}[{}]", path, i);
1114                    self.resolve_value_recursive(item, &item_path, resolution_stack)?;
1115                }
1116                Ok(ResolvedValue::new(value.clone()))
1117            }
1118            Value::Mapping(map) => {
1119                for (key, val) in map {
1120                    let key_path = if path.is_empty() {
1121                        key.clone()
1122                    } else {
1123                        format!("{}.{}", path, key)
1124                    };
1125                    self.resolve_value_recursive(val, &key_path, resolution_stack)?;
1126                }
1127                Ok(ResolvedValue::new(value.clone()))
1128            }
1129            _ => Ok(ResolvedValue::new(value.clone())),
1130        }
1131    }
1132
1133    /// Resolve a value tree to a new Value
1134    fn resolve_value_to_value(
1135        &self,
1136        value: &Value,
1137        path: &str,
1138        resolution_stack: &mut Vec<String>,
1139    ) -> Result<Value> {
1140        match value {
1141            Value::String(s) => {
1142                if interpolation::needs_processing(s) {
1143                    let parsed = interpolation::parse(s)?;
1144                    let resolved = self.resolve_interpolation(&parsed, path, resolution_stack)?;
1145                    Ok(resolved.value)
1146                } else {
1147                    Ok(value.clone())
1148                }
1149            }
1150            Value::Sequence(seq) => {
1151                let mut resolved_seq = Vec::new();
1152                for (i, item) in seq.iter().enumerate() {
1153                    let item_path = format!("{}[{}]", path, i);
1154                    resolved_seq.push(self.resolve_value_to_value(
1155                        item,
1156                        &item_path,
1157                        resolution_stack,
1158                    )?);
1159                }
1160                Ok(Value::Sequence(resolved_seq))
1161            }
1162            Value::Mapping(map) => {
1163                let mut resolved = indexmap::IndexMap::new();
1164                for (key, val) in map {
1165                    let key_path = if path.is_empty() {
1166                        key.clone()
1167                    } else {
1168                        format!("{}.{}", path, key)
1169                    };
1170                    resolved.insert(
1171                        key.clone(),
1172                        self.resolve_value_to_value(val, &key_path, resolution_stack)?,
1173                    );
1174                }
1175                Ok(Value::Mapping(resolved))
1176            }
1177            _ => Ok(value.clone()),
1178        }
1179    }
1180
1181    /// Resolve a value tree to a new Value with sensitive value redaction
1182    fn resolve_value_to_value_redacted(
1183        &self,
1184        value: &Value,
1185        path: &str,
1186        resolution_stack: &mut Vec<String>,
1187    ) -> Result<Value> {
1188        const REDACTED: &str = "[REDACTED]";
1189
1190        match value {
1191            Value::String(s) => {
1192                if interpolation::needs_processing(s) {
1193                    let parsed = interpolation::parse(s)?;
1194                    let resolved = self.resolve_interpolation(&parsed, path, resolution_stack)?;
1195                    if resolved.sensitive {
1196                        Ok(Value::String(REDACTED.to_string()))
1197                    } else {
1198                        Ok(resolved.value)
1199                    }
1200                } else {
1201                    Ok(value.clone())
1202                }
1203            }
1204            Value::Sequence(seq) => {
1205                let mut resolved_seq = Vec::new();
1206                for (i, item) in seq.iter().enumerate() {
1207                    let item_path = format!("{}[{}]", path, i);
1208                    resolved_seq.push(self.resolve_value_to_value_redacted(
1209                        item,
1210                        &item_path,
1211                        resolution_stack,
1212                    )?);
1213                }
1214                Ok(Value::Sequence(resolved_seq))
1215            }
1216            Value::Mapping(map) => {
1217                let mut resolved = indexmap::IndexMap::new();
1218                for (key, val) in map {
1219                    let key_path = if path.is_empty() {
1220                        key.clone()
1221                    } else {
1222                        format!("{}.{}", path, key)
1223                    };
1224                    resolved.insert(
1225                        key.clone(),
1226                        self.resolve_value_to_value_redacted(val, &key_path, resolution_stack)?,
1227                    );
1228                }
1229                Ok(Value::Mapping(resolved))
1230            }
1231            _ => Ok(value.clone()),
1232        }
1233    }
1234}
1235
1236impl Clone for Config {
1237    fn clone(&self) -> Self {
1238        Self {
1239            raw: Arc::clone(&self.raw),
1240            cache: Arc::new(RwLock::new(HashMap::new())), // Fresh cache per ADR-010
1241            source_map: Arc::clone(&self.source_map),
1242            resolvers: Arc::clone(&self.resolvers),
1243            options: self.options.clone(),
1244            schema: self.schema.clone(),
1245        }
1246    }
1247}
1248
1249#[cfg(test)]
1250mod tests {
1251    use super::*;
1252
1253    #[test]
1254    fn test_load_yaml() {
1255        let yaml = r#"
1256database:
1257  host: localhost
1258  port: 5432
1259"#;
1260        let config = Config::from_yaml(yaml).unwrap();
1261
1262        assert_eq!(
1263            config.get("database.host").unwrap().as_str(),
1264            Some("localhost")
1265        );
1266        assert_eq!(config.get("database.port").unwrap().as_i64(), Some(5432));
1267    }
1268
1269    #[test]
1270    fn test_env_resolver() {
1271        std::env::set_var("HOLOCONF_TEST_HOST", "prod-server");
1272
1273        let yaml = r#"
1274server:
1275  host: ${env:HOLOCONF_TEST_HOST}
1276"#;
1277        let config = Config::from_yaml(yaml).unwrap();
1278
1279        assert_eq!(
1280            config.get("server.host").unwrap().as_str(),
1281            Some("prod-server")
1282        );
1283
1284        std::env::remove_var("HOLOCONF_TEST_HOST");
1285    }
1286
1287    #[test]
1288    fn test_env_resolver_with_default() {
1289        std::env::remove_var("HOLOCONF_MISSING_VAR");
1290
1291        let yaml = r#"
1292server:
1293  host: ${env:HOLOCONF_MISSING_VAR,default=default-host}
1294"#;
1295        let config = Config::from_yaml(yaml).unwrap();
1296
1297        assert_eq!(
1298            config.get("server.host").unwrap().as_str(),
1299            Some("default-host")
1300        );
1301    }
1302
1303    #[test]
1304    fn test_self_reference() {
1305        let yaml = r#"
1306defaults:
1307  host: localhost
1308database:
1309  host: ${defaults.host}
1310"#;
1311        let config = Config::from_yaml(yaml).unwrap();
1312
1313        assert_eq!(
1314            config.get("database.host").unwrap().as_str(),
1315            Some("localhost")
1316        );
1317    }
1318
1319    #[test]
1320    fn test_string_concatenation() {
1321        std::env::set_var("HOLOCONF_PREFIX", "prod");
1322
1323        let yaml = r#"
1324bucket: myapp-${env:HOLOCONF_PREFIX}-data
1325"#;
1326        let config = Config::from_yaml(yaml).unwrap();
1327
1328        assert_eq!(
1329            config.get("bucket").unwrap().as_str(),
1330            Some("myapp-prod-data")
1331        );
1332
1333        std::env::remove_var("HOLOCONF_PREFIX");
1334    }
1335
1336    #[test]
1337    fn test_escaped_interpolation() {
1338        // In YAML, we need to quote the value to preserve the backslash properly
1339        // Or the backslash escapes the $
1340        let yaml = r#"
1341literal: '\${not_resolved}'
1342"#;
1343        let config = Config::from_yaml(yaml).unwrap();
1344
1345        // After parsing, the backslash-$ sequence becomes just ${
1346        assert_eq!(
1347            config.get("literal").unwrap().as_str(),
1348            Some("${not_resolved}")
1349        );
1350    }
1351
1352    #[test]
1353    fn test_type_coercion_string_to_int() {
1354        std::env::set_var("HOLOCONF_PORT", "8080");
1355
1356        let yaml = r#"
1357port: ${env:HOLOCONF_PORT}
1358"#;
1359        let config = Config::from_yaml(yaml).unwrap();
1360
1361        // get_i64 should coerce the string to integer
1362        assert_eq!(config.get_i64("port").unwrap(), 8080);
1363
1364        std::env::remove_var("HOLOCONF_PORT");
1365    }
1366
1367    #[test]
1368    fn test_strict_boolean_coercion() {
1369        std::env::set_var("HOLOCONF_ENABLED", "true");
1370        std::env::set_var("HOLOCONF_INVALID", "1");
1371
1372        let yaml = r#"
1373enabled: ${env:HOLOCONF_ENABLED}
1374invalid: ${env:HOLOCONF_INVALID}
1375"#;
1376        let config = Config::from_yaml(yaml).unwrap();
1377
1378        // "true" should work
1379        assert!(config.get_bool("enabled").unwrap());
1380
1381        // "1" should NOT work per ADR-012
1382        assert!(config.get_bool("invalid").is_err());
1383
1384        std::env::remove_var("HOLOCONF_ENABLED");
1385        std::env::remove_var("HOLOCONF_INVALID");
1386    }
1387
1388    #[test]
1389    fn test_boolean_coercion_case_insensitive() {
1390        // Test case-insensitive boolean coercion per ADR-012
1391        let yaml = r#"
1392lower_true: "true"
1393upper_true: "TRUE"
1394mixed_true: "True"
1395lower_false: "false"
1396upper_false: "FALSE"
1397mixed_false: "False"
1398"#;
1399        let config = Config::from_yaml(yaml).unwrap();
1400
1401        // All variations of "true" should work
1402        assert!(config.get_bool("lower_true").unwrap());
1403        assert!(config.get_bool("upper_true").unwrap());
1404        assert!(config.get_bool("mixed_true").unwrap());
1405
1406        // All variations of "false" should work
1407        assert!(!config.get_bool("lower_false").unwrap());
1408        assert!(!config.get_bool("upper_false").unwrap());
1409        assert!(!config.get_bool("mixed_false").unwrap());
1410    }
1411
1412    #[test]
1413    fn test_boolean_coercion_rejects_invalid() {
1414        // Test that invalid boolean strings are rejected per ADR-012
1415        let yaml = r#"
1416yes_value: "yes"
1417no_value: "no"
1418one_value: "1"
1419zero_value: "0"
1420on_value: "on"
1421off_value: "off"
1422"#;
1423        let config = Config::from_yaml(yaml).unwrap();
1424
1425        // None of these should work
1426        assert!(config.get_bool("yes_value").is_err());
1427        assert!(config.get_bool("no_value").is_err());
1428        assert!(config.get_bool("one_value").is_err());
1429        assert!(config.get_bool("zero_value").is_err());
1430        assert!(config.get_bool("on_value").is_err());
1431        assert!(config.get_bool("off_value").is_err());
1432    }
1433
1434    #[test]
1435    fn test_caching() {
1436        std::env::set_var("HOLOCONF_CACHED", "initial");
1437
1438        let yaml = r#"
1439value: ${env:HOLOCONF_CACHED}
1440"#;
1441        let config = Config::from_yaml(yaml).unwrap();
1442
1443        // First access resolves and caches
1444        assert_eq!(config.get("value").unwrap().as_str(), Some("initial"));
1445
1446        // Change the env var
1447        std::env::set_var("HOLOCONF_CACHED", "changed");
1448
1449        // Second access returns cached value
1450        assert_eq!(config.get("value").unwrap().as_str(), Some("initial"));
1451
1452        // Clear cache
1453        config.clear_cache();
1454
1455        // Now returns new value
1456        assert_eq!(config.get("value").unwrap().as_str(), Some("changed"));
1457
1458        std::env::remove_var("HOLOCONF_CACHED");
1459    }
1460
1461    #[test]
1462    fn test_path_not_found() {
1463        let yaml = r#"
1464database:
1465  host: localhost
1466"#;
1467        let config = Config::from_yaml(yaml).unwrap();
1468
1469        let result = config.get("database.nonexistent");
1470        assert!(result.is_err());
1471    }
1472
1473    #[test]
1474    fn test_to_yaml_resolved() {
1475        std::env::set_var("HOLOCONF_EXPORT_HOST", "exported-host");
1476
1477        let yaml = r#"
1478server:
1479  host: ${env:HOLOCONF_EXPORT_HOST}
1480  port: 8080
1481"#;
1482        let config = Config::from_yaml(yaml).unwrap();
1483
1484        let exported = config.to_yaml(true, false).unwrap();
1485        assert!(exported.contains("exported-host"));
1486        assert!(exported.contains("8080"));
1487
1488        std::env::remove_var("HOLOCONF_EXPORT_HOST");
1489    }
1490
1491    #[test]
1492    fn test_relative_path_sibling() {
1493        let yaml = r#"
1494database:
1495  host: localhost
1496  url: postgres://${.host}:5432/db
1497"#;
1498        let config = Config::from_yaml(yaml).unwrap();
1499
1500        assert_eq!(
1501            config.get("database.url").unwrap().as_str(),
1502            Some("postgres://localhost:5432/db")
1503        );
1504    }
1505
1506    #[test]
1507    fn test_array_access() {
1508        let yaml = r#"
1509servers:
1510  - host: server1
1511  - host: server2
1512primary: ${servers[0].host}
1513"#;
1514        let config = Config::from_yaml(yaml).unwrap();
1515
1516        assert_eq!(config.get("primary").unwrap().as_str(), Some("server1"));
1517    }
1518
1519    #[test]
1520    fn test_nested_interpolation() {
1521        std::env::set_var("HOLOCONF_DEFAULT_HOST", "fallback-host");
1522
1523        let yaml = r#"
1524host: ${env:UNDEFINED_HOST,default=${env:HOLOCONF_DEFAULT_HOST}}
1525"#;
1526        let config = Config::from_yaml(yaml).unwrap();
1527
1528        assert_eq!(config.get("host").unwrap().as_str(), Some("fallback-host"));
1529
1530        std::env::remove_var("HOLOCONF_DEFAULT_HOST");
1531    }
1532
1533    #[test]
1534    fn test_to_yaml_unresolved() {
1535        let yaml = r#"
1536server:
1537  host: ${env:MY_HOST}
1538  port: 8080
1539"#;
1540        let config = Config::from_yaml(yaml).unwrap();
1541
1542        let raw = config.to_yaml(false, false).unwrap();
1543        // Should contain the placeholder, not a resolved value
1544        assert!(raw.contains("${env:MY_HOST}"));
1545        assert!(raw.contains("8080"));
1546    }
1547
1548    #[test]
1549    fn test_to_json_unresolved() {
1550        let yaml = r#"
1551database:
1552  url: ${env:DATABASE_URL}
1553"#;
1554        let config = Config::from_yaml(yaml).unwrap();
1555
1556        let raw = config.to_json(false, false).unwrap();
1557        // Should contain the placeholder
1558        assert!(raw.contains("${env:DATABASE_URL}"));
1559    }
1560
1561    #[test]
1562    fn test_to_value_unresolved() {
1563        let yaml = r#"
1564key: ${env:SOME_VAR}
1565"#;
1566        let config = Config::from_yaml(yaml).unwrap();
1567
1568        let raw = config.to_value(false, false).unwrap();
1569        assert_eq!(
1570            raw.get_path("key").unwrap().as_str(),
1571            Some("${env:SOME_VAR}")
1572        );
1573    }
1574
1575    #[test]
1576    fn test_to_yaml_redacted_no_sensitive() {
1577        std::env::set_var("HOLOCONF_NON_SENSITIVE", "public-value");
1578
1579        let yaml = r#"
1580value: ${env:HOLOCONF_NON_SENSITIVE}
1581"#;
1582        let config = Config::from_yaml(yaml).unwrap();
1583
1584        // With redact=true, but no sensitive values, should show real values
1585        let output = config.to_yaml(true, true).unwrap();
1586        assert!(output.contains("public-value"));
1587
1588        std::env::remove_var("HOLOCONF_NON_SENSITIVE");
1589    }
1590
1591    #[test]
1592    fn test_circular_reference_direct() {
1593        // Direct circular reference: a -> b -> a
1594        let yaml = r#"
1595a: ${b}
1596b: ${a}
1597"#;
1598        let config = Config::from_yaml(yaml).unwrap();
1599
1600        // Accessing 'a' should detect the circular reference
1601        let result = config.get("a");
1602        assert!(result.is_err());
1603        let err = result.unwrap_err();
1604        assert!(
1605            err.to_string().to_lowercase().contains("circular"),
1606            "Error should mention 'circular': {}",
1607            err
1608        );
1609    }
1610
1611    #[test]
1612    fn test_circular_reference_chain() {
1613        // Chain circular reference: first -> second -> third -> first
1614        let yaml = r#"
1615first: ${second}
1616second: ${third}
1617third: ${first}
1618"#;
1619        let config = Config::from_yaml(yaml).unwrap();
1620
1621        // Accessing 'first' should detect the circular reference chain
1622        let result = config.get("first");
1623        assert!(result.is_err());
1624        let err = result.unwrap_err();
1625        assert!(
1626            err.to_string().to_lowercase().contains("circular"),
1627            "Error should mention 'circular': {}",
1628            err
1629        );
1630    }
1631
1632    #[test]
1633    fn test_circular_reference_self() {
1634        // Self-referential: value references itself
1635        let yaml = r#"
1636value: ${value}
1637"#;
1638        let config = Config::from_yaml(yaml).unwrap();
1639
1640        let result = config.get("value");
1641        assert!(result.is_err());
1642        let err = result.unwrap_err();
1643        assert!(
1644            err.to_string().to_lowercase().contains("circular"),
1645            "Error should mention 'circular': {}",
1646            err
1647        );
1648    }
1649
1650    #[test]
1651    fn test_circular_reference_nested() {
1652        // Circular reference in nested structure
1653        let yaml = r#"
1654database:
1655  primary: ${database.secondary}
1656  secondary: ${database.primary}
1657"#;
1658        let config = Config::from_yaml(yaml).unwrap();
1659
1660        let result = config.get("database.primary");
1661        assert!(result.is_err());
1662        let err = result.unwrap_err();
1663        assert!(
1664            err.to_string().to_lowercase().contains("circular"),
1665            "Error should mention 'circular': {}",
1666            err
1667        );
1668    }
1669
1670    // Source tracking tests
1671
1672    #[test]
1673    fn test_get_source_from_yaml_string() {
1674        // Config loaded from YAML string has no source tracking
1675        let yaml = r#"
1676database:
1677  host: localhost
1678"#;
1679        let config = Config::from_yaml(yaml).unwrap();
1680
1681        // No source info for YAML string (no filename)
1682        assert!(config.get_source("database.host").is_none());
1683        assert!(config.dump_sources().is_empty());
1684    }
1685
1686    #[test]
1687    fn test_source_tracking_load_and_merge() {
1688        // Create temp files for testing
1689        let temp_dir = std::env::temp_dir().join("holoconf_test_sources");
1690        std::fs::create_dir_all(&temp_dir).unwrap();
1691
1692        let base_path = temp_dir.join("base.yaml");
1693        let override_path = temp_dir.join("override.yaml");
1694
1695        std::fs::write(
1696            &base_path,
1697            r#"
1698database:
1699  host: localhost
1700  port: 5432
1701api:
1702  url: http://localhost
1703"#,
1704        )
1705        .unwrap();
1706
1707        std::fs::write(
1708            &override_path,
1709            r#"
1710database:
1711  host: prod-db.example.com
1712api:
1713  key: secret123
1714"#,
1715        )
1716        .unwrap();
1717
1718        // Use the new API: load and merge
1719        let mut config = Config::load(&base_path).unwrap();
1720        let override_config = Config::load(&override_path).unwrap();
1721        config.merge(override_config);
1722
1723        // Verify merged values (source tracking currently doesn't persist through merge)
1724        assert_eq!(
1725            config.get("database.host").unwrap().as_str(),
1726            Some("prod-db.example.com")
1727        );
1728        assert_eq!(config.get("database.port").unwrap().as_i64(), Some(5432));
1729        assert_eq!(
1730            config.get("api.url").unwrap().as_str(),
1731            Some("http://localhost")
1732        );
1733        assert_eq!(config.get("api.key").unwrap().as_str(), Some("secret123"));
1734
1735        // Cleanup
1736        std::fs::remove_dir_all(&temp_dir).ok();
1737    }
1738
1739    #[test]
1740    fn test_source_tracking_single_file() {
1741        let temp_dir = std::env::temp_dir().join("holoconf_test_single");
1742        std::fs::create_dir_all(&temp_dir).unwrap();
1743
1744        let config_path = temp_dir.join("config.yaml");
1745        std::fs::write(
1746            &config_path,
1747            r#"
1748database:
1749  host: localhost
1750  port: 5432
1751"#,
1752        )
1753        .unwrap();
1754
1755        let config = Config::load(&config_path).unwrap();
1756
1757        // All values should come from config.yaml
1758        assert_eq!(config.get_source("database.host"), Some("config.yaml"));
1759        assert_eq!(config.get_source("database.port"), Some("config.yaml"));
1760
1761        // Cleanup
1762        std::fs::remove_dir_all(&temp_dir).ok();
1763    }
1764
1765    #[test]
1766    fn test_null_removes_values_on_merge() {
1767        let temp_dir = std::env::temp_dir().join("holoconf_test_null");
1768        std::fs::create_dir_all(&temp_dir).unwrap();
1769
1770        let base_path = temp_dir.join("base.yaml");
1771        let override_path = temp_dir.join("override.yaml");
1772
1773        std::fs::write(
1774            &base_path,
1775            r#"
1776database:
1777  host: localhost
1778  port: 5432
1779  debug: true
1780"#,
1781        )
1782        .unwrap();
1783
1784        std::fs::write(
1785            &override_path,
1786            r#"
1787database:
1788  debug: null
1789"#,
1790        )
1791        .unwrap();
1792
1793        let mut config = Config::load(&base_path).unwrap();
1794        let override_config = Config::load(&override_path).unwrap();
1795        config.merge(override_config);
1796
1797        // debug should be removed
1798        assert!(config.get("database.debug").is_err());
1799        // Others should remain
1800        assert_eq!(
1801            config.get("database.host").unwrap().as_str(),
1802            Some("localhost")
1803        );
1804        assert_eq!(config.get("database.port").unwrap().as_i64(), Some(5432));
1805
1806        // Cleanup
1807        std::fs::remove_dir_all(&temp_dir).ok();
1808    }
1809
1810    #[test]
1811    fn test_array_replacement_on_merge() {
1812        let temp_dir = std::env::temp_dir().join("holoconf_test_array");
1813        std::fs::create_dir_all(&temp_dir).unwrap();
1814
1815        let base_path = temp_dir.join("base.yaml");
1816        let override_path = temp_dir.join("override.yaml");
1817
1818        std::fs::write(
1819            &base_path,
1820            r#"
1821servers:
1822  - host: server1
1823  - host: server2
1824"#,
1825        )
1826        .unwrap();
1827
1828        std::fs::write(
1829            &override_path,
1830            r#"
1831servers:
1832  - host: prod-server
1833"#,
1834        )
1835        .unwrap();
1836
1837        let mut config = Config::load(&base_path).unwrap();
1838        let override_config = Config::load(&override_path).unwrap();
1839        config.merge(override_config);
1840
1841        // Array is replaced, so only one item
1842        assert_eq!(
1843            config.get("servers[0].host").unwrap().as_str(),
1844            Some("prod-server")
1845        );
1846        // server2 no longer exists
1847        assert!(config.get("servers[1].host").is_err());
1848
1849        // Cleanup
1850        std::fs::remove_dir_all(&temp_dir).ok();
1851    }
1852
1853    // Optional file tests
1854
1855    #[test]
1856    fn test_optional_file_missing() {
1857        let temp_dir = std::env::temp_dir().join("holoconf_test_optional_missing");
1858        std::fs::create_dir_all(&temp_dir).unwrap();
1859
1860        let base_path = temp_dir.join("base.yaml");
1861        let optional_path = temp_dir.join("optional.yaml"); // Does not exist
1862
1863        std::fs::write(
1864            &base_path,
1865            r#"
1866database:
1867  host: localhost
1868  port: 5432
1869"#,
1870        )
1871        .unwrap();
1872
1873        // Use the new API: Config::optional() returns empty config if missing
1874        let mut config = Config::load(&base_path).unwrap();
1875        let optional_config = Config::optional(&optional_path).unwrap();
1876        config.merge(optional_config);
1877
1878        // Base values should be present
1879        assert_eq!(
1880            config.get("database.host").unwrap().as_str(),
1881            Some("localhost")
1882        );
1883        assert_eq!(config.get("database.port").unwrap().as_i64(), Some(5432));
1884
1885        // Cleanup
1886        std::fs::remove_dir_all(&temp_dir).ok();
1887    }
1888
1889    #[test]
1890    fn test_optional_file_exists() {
1891        let temp_dir = std::env::temp_dir().join("holoconf_test_optional_exists");
1892        std::fs::create_dir_all(&temp_dir).unwrap();
1893
1894        let base_path = temp_dir.join("base.yaml");
1895        let optional_path = temp_dir.join("optional.yaml");
1896
1897        std::fs::write(
1898            &base_path,
1899            r#"
1900database:
1901  host: localhost
1902  port: 5432
1903"#,
1904        )
1905        .unwrap();
1906
1907        std::fs::write(
1908            &optional_path,
1909            r#"
1910database:
1911  host: prod-db
1912"#,
1913        )
1914        .unwrap();
1915
1916        // Use the new API
1917        let mut config = Config::load(&base_path).unwrap();
1918        let optional_config = Config::optional(&optional_path).unwrap();
1919        config.merge(optional_config);
1920
1921        // Optional file should override base
1922        assert_eq!(
1923            config.get("database.host").unwrap().as_str(),
1924            Some("prod-db")
1925        );
1926        assert_eq!(config.get("database.port").unwrap().as_i64(), Some(5432));
1927
1928        // Cleanup
1929        std::fs::remove_dir_all(&temp_dir).ok();
1930    }
1931
1932    #[test]
1933    fn test_required_file_missing_errors() {
1934        let temp_dir = std::env::temp_dir().join("holoconf_test_required_missing");
1935        std::fs::create_dir_all(&temp_dir).unwrap();
1936
1937        let missing_path = temp_dir.join("missing.yaml"); // Does not exist
1938
1939        // Config::load() errors on missing file
1940        let result = Config::load(&missing_path);
1941
1942        match result {
1943            Ok(_) => panic!("Expected error for missing required file"),
1944            Err(err) => {
1945                assert!(
1946                    err.to_string().contains("File not found"),
1947                    "Error should mention file not found: {}",
1948                    err
1949                );
1950            }
1951        }
1952
1953        // Config::required() is an alias and should also error
1954        let result2 = Config::required(&missing_path);
1955        assert!(result2.is_err());
1956
1957        // Cleanup
1958        std::fs::remove_dir_all(&temp_dir).ok();
1959    }
1960
1961    #[test]
1962    fn test_all_optional_files_missing() {
1963        let temp_dir = std::env::temp_dir().join("holoconf_test_all_optional_missing");
1964        std::fs::create_dir_all(&temp_dir).unwrap();
1965
1966        let optional1 = temp_dir.join("optional1.yaml");
1967        let optional2 = temp_dir.join("optional2.yaml");
1968
1969        // Both files don't exist - Config::optional() returns empty config
1970        let mut config = Config::optional(&optional1).unwrap();
1971        let config2 = Config::optional(&optional2).unwrap();
1972        config.merge(config2);
1973
1974        // Should return empty config
1975        let value = config.to_value(false, false).unwrap();
1976        assert!(value.as_mapping().unwrap().is_empty());
1977
1978        // Cleanup
1979        std::fs::remove_dir_all(&temp_dir).ok();
1980    }
1981
1982    #[test]
1983    fn test_mixed_required_and_optional() {
1984        let temp_dir = std::env::temp_dir().join("holoconf_test_mixed_req_opt");
1985        std::fs::create_dir_all(&temp_dir).unwrap();
1986
1987        let required1 = temp_dir.join("required1.yaml");
1988        let optional1 = temp_dir.join("optional1.yaml"); // Missing
1989        let required2 = temp_dir.join("required2.yaml");
1990        let optional2 = temp_dir.join("optional2.yaml");
1991
1992        std::fs::write(
1993            &required1,
1994            r#"
1995app:
1996  name: myapp
1997  debug: false
1998"#,
1999        )
2000        .unwrap();
2001
2002        std::fs::write(
2003            &required2,
2004            r#"
2005database:
2006  host: localhost
2007"#,
2008        )
2009        .unwrap();
2010
2011        std::fs::write(
2012            &optional2,
2013            r#"
2014app:
2015  debug: true
2016database:
2017  port: 5432
2018"#,
2019        )
2020        .unwrap();
2021
2022        // Use new API: load required files with load(), optional with optional(), then merge
2023        let mut config = Config::load(&required1).unwrap();
2024        let opt1 = Config::optional(&optional1).unwrap(); // Missing, returns empty
2025        config.merge(opt1);
2026        let req2 = Config::load(&required2).unwrap();
2027        config.merge(req2);
2028        let opt2 = Config::optional(&optional2).unwrap(); // Exists
2029        config.merge(opt2);
2030
2031        // Check merged values
2032        assert_eq!(config.get("app.name").unwrap().as_str(), Some("myapp"));
2033        assert_eq!(config.get("app.debug").unwrap().as_bool(), Some(true)); // From optional2
2034        assert_eq!(
2035            config.get("database.host").unwrap().as_str(),
2036            Some("localhost")
2037        );
2038        assert_eq!(config.get("database.port").unwrap().as_i64(), Some(5432)); // From optional2
2039
2040        // Cleanup
2041        std::fs::remove_dir_all(&temp_dir).ok();
2042    }
2043
2044    #[test]
2045    fn test_from_json() {
2046        let json = r#"{"database": {"host": "localhost", "port": 5432}}"#;
2047        let config = Config::from_json(json).unwrap();
2048
2049        assert_eq!(
2050            config.get("database.host").unwrap().as_str(),
2051            Some("localhost")
2052        );
2053        assert_eq!(config.get("database.port").unwrap().as_i64(), Some(5432));
2054    }
2055
2056    #[test]
2057    fn test_from_json_invalid() {
2058        let json = r#"{"unclosed": "#;
2059        let result = Config::from_json(json);
2060        assert!(result.is_err());
2061    }
2062
2063    #[test]
2064    fn test_get_raw() {
2065        let yaml = r#"
2066key: ${env:SOME_VAR,default=fallback}
2067literal: plain_value
2068"#;
2069        let config = Config::from_yaml(yaml).unwrap();
2070
2071        // get_raw returns the unresolved value
2072        let raw = config.get_raw("key").unwrap();
2073        assert!(raw.as_str().unwrap().contains("${env:"));
2074
2075        // literal values are unchanged
2076        let literal = config.get_raw("literal").unwrap();
2077        assert_eq!(literal.as_str(), Some("plain_value"));
2078    }
2079
2080    #[test]
2081    fn test_get_string() {
2082        std::env::set_var("HOLOCONF_TEST_STRING", "hello_world");
2083
2084        let yaml = r#"
2085plain: "plain_string"
2086env_var: ${env:HOLOCONF_TEST_STRING}
2087number: 42
2088"#;
2089        let config = Config::from_yaml(yaml).unwrap();
2090
2091        assert_eq!(config.get_string("plain").unwrap(), "plain_string");
2092        assert_eq!(config.get_string("env_var").unwrap(), "hello_world");
2093
2094        // Numbers get coerced to string
2095        assert_eq!(config.get_string("number").unwrap(), "42");
2096
2097        std::env::remove_var("HOLOCONF_TEST_STRING");
2098    }
2099
2100    #[test]
2101    fn test_get_f64() {
2102        let yaml = r#"
2103float: 1.23
2104int: 42
2105string_num: "4.56"
2106string_bad: "not_a_number"
2107"#;
2108        let config = Config::from_yaml(yaml).unwrap();
2109
2110        assert!((config.get_f64("float").unwrap() - 1.23).abs() < 0.001);
2111        assert!((config.get_f64("int").unwrap() - 42.0).abs() < 0.001);
2112        // Strings that look like numbers ARE coerced
2113        assert!((config.get_f64("string_num").unwrap() - 4.56).abs() < 0.001);
2114        // Strings that don't parse as numbers should error
2115        assert!(config.get_f64("string_bad").is_err());
2116    }
2117
2118    #[test]
2119    fn test_config_merge() {
2120        let yaml1 = r#"
2121database:
2122  host: localhost
2123  port: 5432
2124app:
2125  name: myapp
2126"#;
2127        let yaml2 = r#"
2128database:
2129  port: 3306
2130  user: admin
2131app:
2132  debug: true
2133"#;
2134        let mut config1 = Config::from_yaml(yaml1).unwrap();
2135        let config2 = Config::from_yaml(yaml2).unwrap();
2136
2137        config1.merge(config2);
2138
2139        // Merged values
2140        assert_eq!(
2141            config1.get("database.host").unwrap().as_str(),
2142            Some("localhost")
2143        );
2144        assert_eq!(config1.get("database.port").unwrap().as_i64(), Some(3306)); // Overwritten
2145        assert_eq!(
2146            config1.get("database.user").unwrap().as_str(),
2147            Some("admin")
2148        ); // Added
2149        assert_eq!(config1.get("app.name").unwrap().as_str(), Some("myapp"));
2150        assert_eq!(config1.get("app.debug").unwrap().as_bool(), Some(true)); // Added
2151    }
2152
2153    #[test]
2154    fn test_config_clone() {
2155        let yaml = r#"
2156key: value
2157nested:
2158  a: 1
2159  b: 2
2160"#;
2161        let config = Config::from_yaml(yaml).unwrap();
2162        let cloned = config.clone();
2163
2164        assert_eq!(cloned.get("key").unwrap().as_str(), Some("value"));
2165        assert_eq!(cloned.get("nested.a").unwrap().as_i64(), Some(1));
2166    }
2167
2168    #[test]
2169    fn test_with_options() {
2170        use indexmap::IndexMap;
2171        let mut map = IndexMap::new();
2172        map.insert("key".to_string(), crate::Value::String("value".to_string()));
2173        let value = crate::Value::Mapping(map);
2174        let options = ConfigOptions {
2175            base_path: None,
2176            allow_http: true,
2177            http_allowlist: vec![],
2178            file_roots: vec!["/custom/path".into()],
2179            ..Default::default()
2180        };
2181        let config = Config::with_options(value, options);
2182
2183        assert_eq!(config.get("key").unwrap().as_str(), Some("value"));
2184    }
2185
2186    #[test]
2187    fn test_from_yaml_with_options() {
2188        let yaml = "key: value";
2189        let options = ConfigOptions {
2190            base_path: None,
2191            allow_http: true,
2192            http_allowlist: vec![],
2193            file_roots: vec![],
2194            ..Default::default()
2195        };
2196        let config = Config::from_yaml_with_options(yaml, options).unwrap();
2197
2198        assert_eq!(config.get("key").unwrap().as_str(), Some("value"));
2199    }
2200
2201    #[test]
2202    fn test_resolve_all() {
2203        std::env::set_var("HOLOCONF_RESOLVE_ALL_TEST", "resolved");
2204
2205        let yaml = r#"
2206a: ${env:HOLOCONF_RESOLVE_ALL_TEST}
2207b: static_value
2208c:
2209  nested: ${env:HOLOCONF_RESOLVE_ALL_TEST}
2210"#;
2211        let config = Config::from_yaml(yaml).unwrap();
2212
2213        // resolve_all should resolve all values without errors
2214        config.resolve_all().unwrap();
2215
2216        // All values should be cached now
2217        assert_eq!(config.get("a").unwrap().as_str(), Some("resolved"));
2218        assert_eq!(config.get("b").unwrap().as_str(), Some("static_value"));
2219        assert_eq!(config.get("c.nested").unwrap().as_str(), Some("resolved"));
2220
2221        std::env::remove_var("HOLOCONF_RESOLVE_ALL_TEST");
2222    }
2223
2224    #[test]
2225    fn test_resolve_all_with_errors() {
2226        let yaml = r#"
2227valid: static
2228invalid: ${env:HOLOCONF_NONEXISTENT_RESOLVE_VAR}
2229"#;
2230        std::env::remove_var("HOLOCONF_NONEXISTENT_RESOLVE_VAR");
2231
2232        let config = Config::from_yaml(yaml).unwrap();
2233        let result = config.resolve_all();
2234
2235        assert!(result.is_err());
2236        assert!(result.unwrap_err().to_string().contains("not found"));
2237    }
2238
2239    #[test]
2240    fn test_self_reference_basic() {
2241        // Test basic self-references (without default kwargs - kwargs not yet implemented for self-ref)
2242        let yaml = r#"
2243settings:
2244  timeout: 30
2245app:
2246  timeout: ${settings.timeout}
2247"#;
2248        let config = Config::from_yaml(yaml).unwrap();
2249
2250        assert_eq!(config.get("app.timeout").unwrap().as_i64(), Some(30));
2251    }
2252
2253    #[test]
2254    fn test_self_reference_missing_errors() {
2255        let yaml = r#"
2256app:
2257  timeout: ${settings.missing_timeout}
2258"#;
2259        let config = Config::from_yaml(yaml).unwrap();
2260
2261        // Should error when path doesn't exist (no default support for self-refs yet)
2262        let result = config.get("app.timeout");
2263        assert!(result.is_err());
2264        assert!(result.unwrap_err().to_string().contains("not found"));
2265    }
2266
2267    #[test]
2268    fn test_self_reference_sensitivity_inheritance() {
2269        std::env::set_var("HOLOCONF_INHERITED_SECRET", "secret_value");
2270
2271        let yaml = r#"
2272secrets:
2273  api_key: ${env:HOLOCONF_INHERITED_SECRET,sensitive=true}
2274derived: ${secrets.api_key}
2275"#;
2276        let config = Config::from_yaml(yaml).unwrap();
2277
2278        // Access the values to ensure resolution works
2279        assert_eq!(
2280            config.get("secrets.api_key").unwrap().as_str(),
2281            Some("secret_value")
2282        );
2283        assert_eq!(
2284            config.get("derived").unwrap().as_str(),
2285            Some("secret_value")
2286        );
2287
2288        // Check that serialization redacts sensitive values
2289        let yaml_output = config.to_yaml(true, true).unwrap();
2290        assert!(yaml_output.contains("[REDACTED]"));
2291        assert!(!yaml_output.contains("secret_value"));
2292
2293        std::env::remove_var("HOLOCONF_INHERITED_SECRET");
2294    }
2295
2296    #[test]
2297    fn test_non_notfound_error_does_not_use_default() {
2298        // Register a resolver that returns a non-NotFound error
2299        use crate::resolver::FnResolver;
2300        use std::sync::Arc;
2301
2302        let yaml = r#"
2303value: ${failing:arg,default=should_not_be_used}
2304"#;
2305        let mut config = Config::from_yaml(yaml).unwrap();
2306
2307        // Register a resolver that fails with a custom error (not NotFound)
2308        config.register_resolver(Arc::new(FnResolver::new(
2309            "failing",
2310            |_args, _kwargs, ctx| {
2311                Err(
2312                    crate::error::Error::resolver_custom("failing", "Network timeout")
2313                        .with_path(ctx.config_path.clone()),
2314                )
2315            },
2316        )));
2317
2318        // The default should NOT be used because the error is not a NotFound type
2319        let result = config.get("value");
2320        assert!(result.is_err());
2321        assert!(result.unwrap_err().to_string().contains("Network timeout"));
2322    }
2323
2324    // Tests for schema default integration
2325
2326    #[test]
2327    fn test_get_returns_schema_default() {
2328        use crate::schema::Schema;
2329
2330        let yaml = r#"
2331database:
2332  host: localhost
2333"#;
2334        let schema_yaml = r#"
2335type: object
2336properties:
2337  database:
2338    type: object
2339    properties:
2340      host:
2341        type: string
2342      port:
2343        type: integer
2344        default: 5432
2345      pool_size:
2346        type: integer
2347        default: 10
2348"#;
2349        let mut config = Config::from_yaml(yaml).unwrap();
2350        let schema = Schema::from_yaml(schema_yaml).unwrap();
2351        config.set_schema(schema);
2352
2353        // Existing value should work
2354        assert_eq!(
2355            config.get("database.host").unwrap(),
2356            Value::String("localhost".into())
2357        );
2358
2359        // Missing value with schema default should return default
2360        assert_eq!(config.get("database.port").unwrap(), Value::Integer(5432));
2361        assert_eq!(
2362            config.get("database.pool_size").unwrap(),
2363            Value::Integer(10)
2364        );
2365    }
2366
2367    #[test]
2368    fn test_config_value_overrides_schema_default() {
2369        use crate::schema::Schema;
2370
2371        let yaml = r#"
2372port: 3000
2373"#;
2374        let schema_yaml = r#"
2375type: object
2376properties:
2377  port:
2378    type: integer
2379    default: 8080
2380"#;
2381        let mut config = Config::from_yaml(yaml).unwrap();
2382        let schema = Schema::from_yaml(schema_yaml).unwrap();
2383        config.set_schema(schema);
2384
2385        // Config value should win over schema default
2386        assert_eq!(config.get("port").unwrap(), Value::Integer(3000));
2387    }
2388
2389    #[test]
2390    fn test_no_schema_raises_path_not_found() {
2391        let yaml = r#"
2392existing: value
2393"#;
2394        let config = Config::from_yaml(yaml).unwrap();
2395
2396        // Without schema, missing path should error
2397        let result = config.get("missing");
2398        assert!(result.is_err());
2399        assert!(matches!(
2400            result.unwrap_err().kind,
2401            crate::error::ErrorKind::PathNotFound
2402        ));
2403    }
2404
2405    #[test]
2406    fn test_missing_path_no_default_raises_error() {
2407        use crate::schema::Schema;
2408
2409        let yaml = r#"
2410existing: value
2411"#;
2412        let schema_yaml = r#"
2413type: object
2414properties:
2415  existing:
2416    type: string
2417  no_default:
2418    type: string
2419"#;
2420        let mut config = Config::from_yaml(yaml).unwrap();
2421        let schema = Schema::from_yaml(schema_yaml).unwrap();
2422        config.set_schema(schema);
2423
2424        // Path exists in schema but no default, should error
2425        let result = config.get("no_default");
2426        assert!(result.is_err());
2427        assert!(matches!(
2428            result.unwrap_err().kind,
2429            crate::error::ErrorKind::PathNotFound
2430        ));
2431    }
2432
2433    #[test]
2434    fn test_validate_uses_attached_schema() {
2435        use crate::schema::Schema;
2436
2437        let yaml = r#"
2438name: test
2439port: 8080
2440"#;
2441        let schema_yaml = r#"
2442type: object
2443required:
2444  - name
2445  - port
2446properties:
2447  name:
2448    type: string
2449  port:
2450    type: integer
2451"#;
2452        let mut config = Config::from_yaml(yaml).unwrap();
2453        let schema = Schema::from_yaml(schema_yaml).unwrap();
2454        config.set_schema(schema);
2455
2456        // validate() with no arg should use attached schema
2457        assert!(config.validate(None).is_ok());
2458    }
2459
2460    #[test]
2461    fn test_validate_no_schema_errors() {
2462        let yaml = r#"
2463name: test
2464"#;
2465        let config = Config::from_yaml(yaml).unwrap();
2466
2467        // validate() with no arg and no attached schema should error
2468        let result = config.validate(None);
2469        assert!(result.is_err());
2470        let err = result.unwrap_err();
2471        assert!(err.to_string().contains("No schema"));
2472    }
2473
2474    #[test]
2475    fn test_null_value_uses_default_when_null_disallowed() {
2476        use crate::schema::Schema;
2477
2478        let yaml = r#"
2479value: null
2480"#;
2481        let schema_yaml = r#"
2482type: object
2483properties:
2484  value:
2485    type: string
2486    default: "fallback"
2487"#;
2488        let mut config = Config::from_yaml(yaml).unwrap();
2489        let schema = Schema::from_yaml(schema_yaml).unwrap();
2490        config.set_schema(schema);
2491
2492        // Null value with non-nullable schema type should use default
2493        assert_eq!(
2494            config.get("value").unwrap(),
2495            Value::String("fallback".into())
2496        );
2497    }
2498
2499    #[test]
2500    fn test_null_value_preserved_when_null_allowed() {
2501        use crate::schema::Schema;
2502
2503        let yaml = r#"
2504value: null
2505"#;
2506        let schema_yaml = r#"
2507type: object
2508properties:
2509  value:
2510    type:
2511      - string
2512      - "null"
2513    default: "fallback"
2514"#;
2515        let mut config = Config::from_yaml(yaml).unwrap();
2516        let schema = Schema::from_yaml(schema_yaml).unwrap();
2517        config.set_schema(schema);
2518
2519        // Null value with nullable schema type should preserve null
2520        assert_eq!(config.get("value").unwrap(), Value::Null);
2521    }
2522
2523    #[test]
2524    fn test_set_and_get_schema() {
2525        use crate::schema::Schema;
2526
2527        let yaml = r#"
2528name: test
2529"#;
2530        let mut config = Config::from_yaml(yaml).unwrap();
2531
2532        // No schema initially
2533        assert!(config.get_schema().is_none());
2534
2535        let schema = Schema::from_yaml(
2536            r#"
2537type: object
2538properties:
2539  name:
2540    type: string
2541"#,
2542        )
2543        .unwrap();
2544        config.set_schema(schema);
2545
2546        // Schema should now be attached
2547        assert!(config.get_schema().is_some());
2548    }
2549
2550    #[test]
2551    fn test_ref_with_default_missing_path() {
2552        let yaml = r#"
2553app:
2554  name: myapp
2555  # No 'timeout' defined
2556  effective_timeout: ${app.timeout,default=30}
2557"#;
2558        let config = Config::from_yaml(yaml).unwrap();
2559        assert_eq!(
2560            config.get("app.effective_timeout").unwrap().as_str(),
2561            Some("30")
2562        );
2563    }
2564
2565    #[test]
2566    fn test_ref_with_default_null_value() {
2567        let yaml = r#"
2568app:
2569  timeout: null
2570  effective_timeout: ${app.timeout,default=30}
2571"#;
2572        let config = Config::from_yaml(yaml).unwrap();
2573        assert_eq!(
2574            config.get("app.effective_timeout").unwrap().as_str(),
2575            Some("30")
2576        );
2577    }
2578
2579    #[test]
2580    fn test_ref_with_default_value_exists() {
2581        let yaml = r#"
2582app:
2583  timeout: 60
2584  effective_timeout: ${app.timeout,default=30}
2585"#;
2586        let config = Config::from_yaml(yaml).unwrap();
2587        // Should use actual value, not default
2588        assert_eq!(
2589            config.get("app.effective_timeout").unwrap().as_i64(),
2590            Some(60)
2591        );
2592    }
2593
2594    #[test]
2595    fn test_ref_without_default_missing_errors() {
2596        let yaml = r#"
2597app:
2598  name: myapp
2599  timeout: ${app.missing}
2600"#;
2601        let config = Config::from_yaml(yaml).unwrap();
2602        let result = config.get("app.timeout");
2603        assert!(result.is_err());
2604    }
2605
2606    #[test]
2607    fn test_ref_nested_defaults() {
2608        let yaml = r#"
2609defaults:
2610  timeout: 30
2611app:
2612  timeout: ${custom.timeout,default=${defaults.timeout}}
2613"#;
2614        let config = Config::from_yaml(yaml).unwrap();
2615        assert_eq!(config.get("app.timeout").unwrap().as_str(), Some("30"));
2616    }
2617
2618    #[test]
2619    fn test_ref_explicit_syntax() {
2620        let yaml = r#"
2621database:
2622  host: localhost
2623app:
2624  db_host: ${ref:database.host}
2625"#;
2626        let config = Config::from_yaml(yaml).unwrap();
2627        assert_eq!(
2628            config.get("app.db_host").unwrap().as_str(),
2629            Some("localhost")
2630        );
2631    }
2632
2633    #[test]
2634    fn test_ref_explicit_syntax_with_default() {
2635        let yaml = r#"
2636app:
2637  timeout: ${ref:custom.timeout,default=30}
2638"#;
2639        let config = Config::from_yaml(yaml).unwrap();
2640        assert_eq!(config.get("app.timeout").unwrap().as_str(), Some("30"));
2641    }
2642
2643    #[test]
2644    fn test_ref_sensitive_flag_on_default() {
2645        let yaml = r#"
2646app:
2647  password: ${custom.password,default=dev-password,sensitive=true}
2648"#;
2649        let config = Config::from_yaml(yaml).unwrap();
2650        let dumped_yaml = config.to_yaml(true, true).unwrap();
2651        // Should be redacted in the output
2652        assert!(dumped_yaml.contains("[REDACTED]"));
2653        assert!(!dumped_yaml.contains("dev-password"));
2654    }
2655
2656    #[test]
2657    fn test_ref_sensitive_flag_inheritance() {
2658        use crate::resolver::{FnResolver, ResolverRegistry};
2659        use std::sync::Arc;
2660
2661        let mut registry = ResolverRegistry::new();
2662        registry.register(Arc::new(FnResolver::new(
2663            "sensitive_test",
2664            |_args, _kwargs, _ctx| {
2665                Ok(ResolvedValue::sensitive(Value::String(
2666                    "secret-value".to_string(),
2667                )))
2668            },
2669        )));
2670
2671        let yaml = r#"
2672secret:
2673  password: ${sensitive_test:foo}
2674app:
2675  # Reference to sensitive value should inherit sensitivity
2676  db_password: ${secret.password}
2677"#;
2678        let value: Value = serde_yaml::from_str(yaml).unwrap();
2679        let config = Config::with_resolvers(value, registry);
2680        let dumped_yaml = config.to_yaml(true, true).unwrap();
2681        // Should be redacted in the output
2682        assert!(dumped_yaml.contains("[REDACTED]"));
2683        assert!(!dumped_yaml.contains("secret-value"));
2684    }
2685
2686    #[test]
2687    fn test_ref_sensitive_cannot_be_unmarked() {
2688        use crate::resolver::{FnResolver, ResolverRegistry};
2689        use std::sync::Arc;
2690
2691        let mut registry = ResolverRegistry::new();
2692        registry.register(Arc::new(FnResolver::new(
2693            "sensitive_test",
2694            |_args, _kwargs, _ctx| {
2695                Ok(ResolvedValue::sensitive(Value::String(
2696                    "secret-value".to_string(),
2697                )))
2698            },
2699        )));
2700
2701        let yaml = r#"
2702secret:
2703  password: ${sensitive_test:foo}
2704app:
2705  # Even with sensitive=false, should stay sensitive
2706  db_password: ${secret.password,sensitive=false}
2707"#;
2708        let value: Value = serde_yaml::from_str(yaml).unwrap();
2709        let config = Config::with_resolvers(value, registry);
2710        let dumped_yaml = config.to_yaml(true, true).unwrap();
2711        // Should still be redacted even though sensitive=false
2712        assert!(dumped_yaml.contains("[REDACTED]"));
2713        assert!(!dumped_yaml.contains("secret-value"));
2714    }
2715
2716    #[test]
2717    fn test_max_resolution_depth() {
2718        // Create a config with nested references approaching the limit
2719        // Build a chain: a -> b -> c -> ... (each references the next)
2720        let mut yaml_parts = vec!["root: ${level1}".to_string()];
2721        for i in 1..95 {
2722            yaml_parts.push(format!("level{}: ${{level{}}}", i, i + 1));
2723        }
2724        yaml_parts.push("level95: final_value".to_string());
2725
2726        let yaml = yaml_parts.join("\n");
2727        let config = Config::from_yaml(&yaml).unwrap();
2728
2729        // Should succeed with depth under MAX_RESOLUTION_DEPTH (100)
2730        assert_eq!(config.get("root").unwrap().as_str(), Some("final_value"));
2731    }
2732}