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