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