1use 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
15const MAX_RESOLUTION_DEPTH: usize = 100;
17
18fn is_glob_pattern(path: &str) -> bool {
20 path.contains('*') || path.contains('?') || path.contains('[')
21}
22
23fn expand_glob(pattern: &str) -> Result<Vec<PathBuf>> {
25 let mut paths: Vec<PathBuf> = glob::glob(pattern)
26 .map_err(|e| Error::parse(format!("Invalid glob pattern '{}': {}", pattern, e)))?
27 .filter_map(|r| r.ok())
28 .collect();
29 paths.sort();
30 Ok(paths)
31}
32
33#[derive(Debug, Clone, Default)]
35pub struct ConfigOptions {
36 pub base_path: Option<PathBuf>,
38 pub allow_http: bool,
40 pub http_allowlist: Vec<String>,
42 pub file_roots: Vec<PathBuf>,
44
45 pub http_proxy: Option<String>,
48 pub http_proxy_from_env: bool,
50 pub http_ca_bundle: Option<crate::resolver::CertInput>,
52 pub http_extra_ca_bundle: Option<crate::resolver::CertInput>,
54 pub http_client_cert: Option<crate::resolver::CertInput>,
56 pub http_client_key: Option<crate::resolver::CertInput>,
58 pub http_client_key_password: Option<String>,
60 }
64
65pub struct Config {
70 raw: Arc<Value>,
72 cache: Arc<RwLock<HashMap<String, ResolvedValue>>>,
74 source_map: Arc<HashMap<String, String>>,
76 resolvers: Arc<ResolverRegistry>,
78 options: ConfigOptions,
80 schema: Option<Arc<crate::schema::Schema>>,
82}
83
84fn clone_global_registry() -> Arc<ResolverRegistry> {
86 let global = global_registry()
87 .read()
88 .expect("Global registry lock poisoned");
89 Arc::new(global.clone())
90}
91
92impl Config {
93 pub fn new(value: Value) -> Self {
97 Self {
98 raw: Arc::new(value),
99 cache: Arc::new(RwLock::new(HashMap::new())),
100 source_map: Arc::new(HashMap::new()),
101 resolvers: clone_global_registry(),
102 options: ConfigOptions::default(),
103 schema: None,
104 }
105 }
106
107 pub fn with_options(value: Value, options: ConfigOptions) -> Self {
111 Self {
112 raw: Arc::new(value),
113 cache: Arc::new(RwLock::new(HashMap::new())),
114 source_map: Arc::new(HashMap::new()),
115 resolvers: clone_global_registry(),
116 options,
117 schema: None,
118 }
119 }
120
121 fn with_options_and_sources(
123 value: Value,
124 options: ConfigOptions,
125 source_map: HashMap<String, String>,
126 ) -> Self {
127 Self {
128 raw: Arc::new(value),
129 cache: Arc::new(RwLock::new(HashMap::new())),
130 source_map: Arc::new(source_map),
131 resolvers: clone_global_registry(),
132 options,
133 schema: None,
134 }
135 }
136
137 pub fn with_resolvers(value: Value, resolvers: ResolverRegistry) -> Self {
139 Self {
140 raw: Arc::new(value),
141 cache: Arc::new(RwLock::new(HashMap::new())),
142 source_map: Arc::new(HashMap::new()),
143 resolvers: Arc::new(resolvers),
144 options: ConfigOptions::default(),
145 schema: None,
146 }
147 }
148
149 pub fn from_yaml(yaml: &str) -> Result<Self> {
151 let value: Value = serde_yaml::from_str(yaml).map_err(|e| Error::parse(e.to_string()))?;
152 Ok(Self::new(value))
153 }
154
155 pub fn from_yaml_with_options(yaml: &str, options: ConfigOptions) -> Result<Self> {
157 let value: Value = serde_yaml::from_str(yaml).map_err(|e| Error::parse(e.to_string()))?;
158 Ok(Self::with_options(value, options))
159 }
160
161 pub fn load(path: impl AsRef<Path>) -> Result<Self> {
177 let path_str = path.as_ref().to_string_lossy();
178
179 if is_glob_pattern(&path_str) {
180 let paths = expand_glob(&path_str)?;
181 if paths.is_empty() {
182 return Err(Error::file_not_found(
183 format!("No files matched glob pattern '{}'", path_str),
184 None,
185 ));
186 }
187 let mut config = Self::from_yaml_file(&paths[0])?;
189 for p in &paths[1..] {
190 let other = Self::from_yaml_file(p)?;
191 config.merge(other);
192 }
193 Ok(config)
194 } else {
195 Self::from_yaml_file(path)
196 }
197 }
198
199 pub fn load_with_options(path: impl AsRef<Path>, options: ConfigOptions) -> Result<Self> {
215 let path_str = path.as_ref().to_string_lossy();
216
217 if is_glob_pattern(&path_str) {
218 let paths = expand_glob(&path_str)?;
219 if paths.is_empty() {
220 return Err(Error::file_not_found(
221 format!("No files matched glob pattern '{}'", path_str),
222 None,
223 ));
224 }
225 let mut config = Self::from_yaml_file_with_options(&paths[0], options.clone())?;
227 for p in &paths[1..] {
228 let other = Self::from_yaml_file_with_options(p, options.clone())?;
229 config.merge(other);
230 }
231 Ok(config)
232 } else {
233 Self::from_yaml_file_with_options(path, options)
234 }
235 }
236
237 pub fn required(path: impl AsRef<Path>) -> Result<Self> {
241 Self::load(path)
242 }
243
244 pub fn required_with_options(path: impl AsRef<Path>, options: ConfigOptions) -> Result<Self> {
246 Self::load_with_options(path, options)
247 }
248
249 pub fn optional(path: impl AsRef<Path>) -> Result<Self> {
268 let path_str = path.as_ref().to_string_lossy();
269
270 if is_glob_pattern(&path_str) {
271 let paths = expand_glob(&path_str)?;
272 if paths.is_empty() {
273 return Ok(Self::new(Value::Mapping(indexmap::IndexMap::new())));
275 }
276 let mut config = Self::from_yaml_file(&paths[0])?;
278 for p in &paths[1..] {
279 let other = Self::from_yaml_file(p)?;
280 config.merge(other);
281 }
282 Ok(config)
283 } else {
284 Self::optional_single_file(path)
285 }
286 }
287
288 fn optional_single_file(path: impl AsRef<Path>) -> Result<Self> {
290 let path = path.as_ref();
291 match std::fs::read_to_string(path) {
292 Ok(content) => {
293 let value: Value =
294 serde_yaml::from_str(&content).map_err(|e| Error::parse(e.to_string()))?;
295
296 let filename = path
297 .file_name()
298 .and_then(|n| n.to_str())
299 .unwrap_or_default()
300 .to_string();
301 let mut source_map = HashMap::new();
302 value.collect_leaf_paths("", &filename, &mut source_map);
303
304 let mut options = ConfigOptions::default();
305 options.base_path = path.parent().map(|p| p.to_path_buf());
306
307 Ok(Self::with_options_and_sources(value, options, source_map))
308 }
309 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
310 Ok(Self::new(Value::Mapping(indexmap::IndexMap::new())))
312 }
313 Err(e) => Err(Error::parse(format!(
314 "Failed to read file '{}': {}",
315 path.display(),
316 e
317 ))),
318 }
319 }
320
321 fn from_yaml_file(path: impl AsRef<Path>) -> Result<Self> {
323 Self::from_yaml_file_with_options(path, ConfigOptions::default())
324 }
325
326 fn from_yaml_file_with_options(
328 path: impl AsRef<Path>,
329 mut options: ConfigOptions,
330 ) -> Result<Self> {
331 let path = path.as_ref();
332 let content = std::fs::read_to_string(path).map_err(|e| {
333 if e.kind() == std::io::ErrorKind::NotFound {
334 Error::file_not_found(path.display().to_string(), None)
335 } else {
336 Error::parse(format!("Failed to read file '{}': {}", path.display(), e))
337 }
338 })?;
339
340 let value: Value =
341 serde_yaml::from_str(&content).map_err(|e| Error::parse(e.to_string()))?;
342
343 let filename = path
345 .file_name()
346 .and_then(|n| n.to_str())
347 .unwrap_or_default()
348 .to_string();
349 let mut source_map = HashMap::new();
350 value.collect_leaf_paths("", &filename, &mut source_map);
351
352 if options.base_path.is_none() {
354 options.base_path = path.parent().map(|p| p.to_path_buf());
355 }
356
357 if let Some(parent) = path.parent() {
359 options.file_roots.push(parent.to_path_buf());
360 }
361
362 Ok(Self::with_options_and_sources(value, options, source_map))
363 }
364
365 pub fn merge(&mut self, other: Config) {
370 if let Some(raw) = Arc::get_mut(&mut self.raw) {
372 raw.merge((*other.raw).clone());
373 } else {
374 let mut new_raw = (*self.raw).clone();
376 new_raw.merge((*other.raw).clone());
377 self.raw = Arc::new(new_raw);
378 }
379
380 for root in &other.options.file_roots {
382 if !self.options.file_roots.contains(root) {
383 self.options.file_roots.push(root.clone());
384 }
385 }
386
387 self.clear_cache();
389 }
390
391 pub fn from_json(json: &str) -> Result<Self> {
393 let value: Value = serde_json::from_str(json).map_err(|e| Error::parse(e.to_string()))?;
394 Ok(Self::new(value))
395 }
396
397 pub fn set_schema(&mut self, schema: crate::schema::Schema) {
405 self.schema = Some(Arc::new(schema));
406 self.clear_cache();
407 }
408
409 pub fn get_schema(&self) -> Option<&crate::schema::Schema> {
411 self.schema.as_ref().map(|s| s.as_ref())
412 }
413
414 pub fn get_raw(&self, path: &str) -> Result<&Value> {
416 self.raw.get_path(path)
417 }
418
419 pub fn get(&self, path: &str) -> Result<Value> {
427 {
429 let cache = self.cache.read().expect("Cache lock poisoned");
430 if let Some(cached) = cache.get(path) {
431 return Ok(cached.value.clone());
432 }
433 }
434
435 let raw_result = self.raw.get_path(path);
437
438 if let Ok(raw_value) = raw_result {
440 let mut resolution_stack = Vec::new();
442 let resolved = self.resolve_value(raw_value, path, &mut resolution_stack)?;
443
444 let materialized_value = resolved.value.materialize()?;
446
447 if materialized_value.is_null() {
449 if let Some(ref schema) = self.schema {
450 if !schema.allows_null(path) {
452 if let Some(default) = schema.get_default(path) {
453 return Ok(default);
454 }
455 }
456 }
457 }
458
459 let cached_value = crate::resolver::ResolvedValue {
461 value: materialized_value.clone(),
462 sensitive: resolved.sensitive,
463 };
464
465 {
467 let mut cache = self.cache.write().expect("Cache lock poisoned");
468 cache.insert(path.to_string(), cached_value);
469 }
470
471 return Ok(materialized_value);
472 }
473
474 if !path.is_empty() {
478 let mut candidate_splits = Vec::new();
480
481 for (i, ch) in path.char_indices().rev() {
483 if ch == '.' || ch == '[' {
484 let parent = &path[..i];
485 let child = if ch == '.' {
486 &path[i + 1..]
487 } else {
488 &path[i..]
489 };
490 if !parent.is_empty() && !child.is_empty() {
491 candidate_splits.push((parent.to_string(), child.to_string()));
492 }
493 }
494 }
495
496 for (parent_path, child_path) in candidate_splits {
498 if let Ok(parent_value) = self.get(&parent_path) {
500 if let Ok(child_value) = parent_value.get_path(&child_path) {
502 return Ok(child_value.clone());
503 }
504 }
505 }
506 }
507
508 if let Some(schema) = &self.schema {
510 if let Some(default_value) = schema.get_default(path) {
511 let resolved_default = ResolvedValue::new(default_value.clone());
512 {
514 let mut cache = self.cache.write().expect("Cache lock poisoned");
515 cache.insert(path.to_string(), resolved_default);
516 }
517 return Ok(default_value);
518 }
519 }
520
521 Err(Error::path_not_found(path))
523 }
524
525 pub fn get_string(&self, path: &str) -> Result<String> {
527 let value = self.get(path)?;
528 match value {
529 Value::String(s) => Ok(s),
530 Value::Integer(i) => Ok(i.to_string()),
531 Value::Float(f) => Ok(f.to_string()),
532 Value::Bool(b) => Ok(b.to_string()),
533 Value::Null => Ok("null".to_string()),
534 _ => Err(Error::type_coercion(path, "string", value.type_name())),
535 }
536 }
537
538 pub fn get_i64(&self, path: &str) -> Result<i64> {
540 let value = self.get(path)?;
541 match value {
542 Value::Integer(i) => Ok(i),
543 Value::String(s) => s
544 .parse()
545 .map_err(|_| Error::type_coercion(path, "integer", format!("string (\"{}\")", s))),
546 _ => Err(Error::type_coercion(path, "integer", value.type_name())),
547 }
548 }
549
550 pub fn get_f64(&self, path: &str) -> Result<f64> {
552 let value = self.get(path)?;
553 match value {
554 Value::Float(f) => Ok(f),
555 Value::Integer(i) => Ok(i as f64),
556 Value::String(s) => s
557 .parse()
558 .map_err(|_| Error::type_coercion(path, "float", format!("string (\"{}\")", s))),
559 _ => Err(Error::type_coercion(path, "float", value.type_name())),
560 }
561 }
562
563 pub fn get_bool(&self, path: &str) -> Result<bool> {
565 let value = self.get(path)?;
566 match value {
567 Value::Bool(b) => Ok(b),
568 Value::String(s) => {
569 match s.to_lowercase().as_str() {
571 "true" => Ok(true),
572 "false" => Ok(false),
573 _ => Err(Error::type_coercion(
574 path,
575 "boolean",
576 format!("string (\"{}\") - only \"true\" or \"false\" allowed", s),
577 )),
578 }
579 }
580 _ => Err(Error::type_coercion(path, "boolean", value.type_name())),
581 }
582 }
583
584 pub fn resolve_all(&self) -> Result<()> {
586 let mut resolution_stack = Vec::new();
587 self.resolve_value_recursive(&self.raw, "", &mut resolution_stack)?;
588 Ok(())
589 }
590
591 pub fn to_value(&self, resolve: bool, redact: bool) -> Result<Value> {
609 if !resolve {
610 return Ok((*self.raw).clone());
611 }
612 let mut resolution_stack = Vec::new();
613 if redact {
614 self.resolve_value_to_value_redacted(&self.raw, "", &mut resolution_stack)
615 } else {
616 self.resolve_value_to_value(&self.raw, "", &mut resolution_stack)
617 }
618 }
619
620 pub fn to_yaml(&self, resolve: bool, redact: bool) -> Result<String> {
635 let value = self.to_value(resolve, redact)?;
636 serde_yaml::to_string(&value).map_err(|e| Error::parse(e.to_string()))
637 }
638
639 pub fn to_json(&self, resolve: bool, redact: bool) -> Result<String> {
654 let value = self.to_value(resolve, redact)?;
655 serde_json::to_string_pretty(&value).map_err(|e| Error::parse(e.to_string()))
656 }
657
658 pub fn clear_cache(&self) {
660 let mut cache = self.cache.write().expect("Cache lock poisoned");
661 cache.clear();
662 }
663
664 pub fn get_source(&self, path: &str) -> Option<&str> {
669 self.source_map.get(path).map(|s| s.as_str())
670 }
671
672 pub fn dump_sources(&self) -> &HashMap<String, String> {
677 &self.source_map
678 }
679
680 pub fn register_resolver(&mut self, resolver: Arc<dyn crate::resolver::Resolver>) {
682 if let Some(registry) = Arc::get_mut(&mut self.resolvers) {
685 registry.register(resolver);
686 }
687 }
688
689 pub fn validate_raw(&self, schema: Option<&crate::schema::Schema>) -> Result<()> {
699 let schema = self.resolve_schema(schema)?;
700 schema.validate(&self.raw)
701 }
702
703 pub fn validate(&self, schema: Option<&crate::schema::Schema>) -> Result<()> {
712 let schema = self.resolve_schema(schema)?;
713 let resolved = self.to_value(true, false)?;
714 schema.validate(&resolved)
715 }
716
717 pub fn validate_collect(
722 &self,
723 schema: Option<&crate::schema::Schema>,
724 ) -> Vec<crate::schema::ValidationError> {
725 let schema = match self.resolve_schema(schema) {
726 Ok(s) => s,
727 Err(e) => {
728 return vec![crate::schema::ValidationError {
729 path: String::new(),
730 message: e.to_string(),
731 }]
732 }
733 };
734 match self.to_value(true, false) {
735 Ok(resolved) => schema.validate_collect(&resolved),
736 Err(e) => vec![crate::schema::ValidationError {
737 path: String::new(),
738 message: e.to_string(),
739 }],
740 }
741 }
742
743 fn resolve_schema<'a>(
745 &'a self,
746 schema: Option<&'a crate::schema::Schema>,
747 ) -> Result<&'a crate::schema::Schema> {
748 schema
749 .or_else(|| self.schema.as_ref().map(|s| s.as_ref()))
750 .ok_or_else(|| Error::validation("<root>", "No schema provided and none attached"))
751 }
752
753 fn resolve_value(
755 &self,
756 value: &Value,
757 path: &str,
758 resolution_stack: &mut Vec<String>,
759 ) -> Result<ResolvedValue> {
760 match value {
761 Value::String(s) => {
762 if interpolation::needs_processing(s) {
764 let parsed = interpolation::parse(s)?;
765 self.resolve_interpolation(&parsed, path, resolution_stack)
766 } else {
767 Ok(ResolvedValue::new(value.clone()))
768 }
769 }
770 _ => Ok(ResolvedValue::new(value.clone())),
771 }
772 }
773
774 fn resolve_interpolation(
776 &self,
777 interp: &Interpolation,
778 path: &str,
779 resolution_stack: &mut Vec<String>,
780 ) -> Result<ResolvedValue> {
781 if resolution_stack.len() >= MAX_RESOLUTION_DEPTH {
783 return Err(Error::parse(format!(
784 "Maximum interpolation depth ({}) exceeded at path '{}'. \
785 This may indicate deeply nested defaults or a complex reference chain.",
786 MAX_RESOLUTION_DEPTH, path
787 )));
788 }
789
790 match interp {
791 Interpolation::Literal(s) => Ok(ResolvedValue::new(Value::String(s.clone()))),
792
793 Interpolation::Resolver { name, args, kwargs } => {
794 if name == "ref" {
796 let ref_path = args
798 .first()
799 .and_then(|arg| arg.as_literal())
800 .ok_or_else(|| Error::parse("ref resolver requires a path argument"))?;
801
802 if resolution_stack.contains(&ref_path.to_string()) {
804 let mut chain = resolution_stack.clone();
805 chain.push(ref_path.to_string());
806 return Err(Error::circular_reference(path, chain));
807 }
808
809 let value_result = self.raw.get_path(ref_path);
811 let use_default = match &value_result {
812 Ok(val) => val.is_null(),
813 Err(_) => true,
814 };
815
816 if use_default {
817 if let Some(default_arg) = kwargs.get("default") {
821 let default_str =
822 self.resolve_arg(default_arg, path, resolution_stack)?;
823 let is_sensitive = kwargs
824 .get("sensitive")
825 .and_then(|arg| arg.as_literal())
826 .map(|v| v.eq_ignore_ascii_case("true"))
827 .unwrap_or(false);
828 return if is_sensitive {
829 Ok(ResolvedValue::sensitive(Value::String(default_str)))
830 } else {
831 Ok(ResolvedValue::new(Value::String(default_str)))
832 };
833 }
834
835 if let Some(schema) = &self.schema {
837 if let Some(default_value) = schema.get_default(ref_path) {
838 return Ok(ResolvedValue::new(default_value));
839 }
840 }
841
842 return match value_result {
844 Err(e) => Err(e),
845 Ok(_) => Err(Error::path_not_found(ref_path)), };
847 }
848
849 let ref_value =
852 value_result.expect("value must exist when use_default is false");
853 resolution_stack.push(ref_path.to_string());
854 let mut result = self.resolve_value(ref_value, ref_path, resolution_stack)?;
855 resolution_stack.pop();
856
857 let is_sensitive = kwargs
860 .get("sensitive")
861 .and_then(|arg| arg.as_literal())
862 .map(|v| v.eq_ignore_ascii_case("true"))
863 .unwrap_or(false);
864
865 if is_sensitive {
866 result.sensitive = true;
867 }
868 return Ok(result);
872 }
873
874 let mut ctx = ResolverContext::new(path);
877 ctx.config_root = Some(Arc::clone(&self.raw));
878 if let Some(base) = &self.options.base_path {
879 ctx.base_path = Some(base.clone());
880 }
881 ctx.file_roots = self.options.file_roots.iter().cloned().collect();
883 ctx.allow_http = self.options.allow_http;
885 ctx.http_allowlist = self.options.http_allowlist.clone();
886 ctx.http_proxy = self.options.http_proxy.clone();
888 ctx.http_proxy_from_env = self.options.http_proxy_from_env;
889 ctx.http_ca_bundle = self.options.http_ca_bundle.clone();
890 ctx.http_extra_ca_bundle = self.options.http_extra_ca_bundle.clone();
891 ctx.http_client_cert = self.options.http_client_cert.clone();
892 ctx.http_client_key = self.options.http_client_key.clone();
893 ctx.http_client_key_password = self.options.http_client_key_password.clone();
894 let resolved_args: Vec<String> = args
898 .iter()
899 .map(|arg| self.resolve_arg(arg, path, resolution_stack))
900 .collect::<Result<Vec<_>>>()?;
901
902 let default_arg = kwargs.get("default");
905 let resolved_kwargs: HashMap<String, String> = kwargs
906 .iter()
907 .filter(|(k, _)| *k != "default") .map(|(k, v)| Ok((k.clone(), self.resolve_arg(v, path, resolution_stack)?)))
909 .collect::<Result<HashMap<_, _>>>()?;
910
911 let result = self
913 .resolvers
914 .resolve(name, &resolved_args, &resolved_kwargs, &ctx);
915
916 match result {
918 Ok(value) => Ok(value),
919 Err(e) => {
920 let should_use_default = matches!(
922 &e.kind,
923 crate::error::ErrorKind::Resolver(
924 crate::error::ResolverErrorKind::NotFound { .. }
925 ) | crate::error::ErrorKind::Resolver(
926 crate::error::ResolverErrorKind::EnvNotFound { .. }
927 ) | crate::error::ErrorKind::Resolver(
928 crate::error::ResolverErrorKind::FileNotFound { .. }
929 )
930 );
931
932 if should_use_default {
933 if let Some(default_arg) = default_arg {
934 let default_str =
936 self.resolve_arg(default_arg, path, resolution_stack)?;
937
938 let is_sensitive = resolved_kwargs
940 .get("sensitive")
941 .map(|v| v.eq_ignore_ascii_case("true"))
942 .unwrap_or(false);
943
944 return if is_sensitive {
945 Ok(ResolvedValue::sensitive(Value::String(default_str)))
946 } else {
947 Ok(ResolvedValue::new(Value::String(default_str)))
948 };
949 }
950 }
951 Err(e)
952 }
953 }
954 }
955
956 Interpolation::SelfRef {
957 path: ref_path,
958 relative,
959 } => {
960 let full_path = if *relative {
961 self.resolve_relative_path(path, ref_path)
962 } else {
963 ref_path.clone()
964 };
965
966 if resolution_stack.contains(&full_path) {
968 let mut chain = resolution_stack.clone();
970 chain.push(full_path.clone());
971 return Err(Error::circular_reference(path, chain));
972 }
973
974 let ref_value = self
976 .raw
977 .get_path(&full_path)
978 .map_err(|_| Error::ref_not_found(&full_path, Some(path.to_string())))?;
979
980 resolution_stack.push(full_path.clone());
982
983 let result = self.resolve_value(ref_value, &full_path, resolution_stack);
985
986 resolution_stack.pop();
988
989 result
990 }
991
992 Interpolation::Concat(parts) => {
993 let mut result = String::new();
994 let mut any_sensitive = false;
995
996 for part in parts {
997 let resolved = self.resolve_interpolation(part, path, resolution_stack)?;
998 any_sensitive = any_sensitive || resolved.sensitive;
999
1000 match resolved.value {
1001 Value::String(s) => result.push_str(&s),
1002 other => result.push_str(&other.to_string()),
1003 }
1004 }
1005
1006 if any_sensitive {
1007 Ok(ResolvedValue::sensitive(Value::String(result)))
1008 } else {
1009 Ok(ResolvedValue::new(Value::String(result)))
1010 }
1011 }
1012 }
1013 }
1014
1015 fn resolve_arg(
1017 &self,
1018 arg: &InterpolationArg,
1019 path: &str,
1020 resolution_stack: &mut Vec<String>,
1021 ) -> Result<String> {
1022 match arg {
1023 InterpolationArg::Literal(s) => Ok(s.clone()),
1024 InterpolationArg::Nested(interp) => {
1025 let resolved = self.resolve_interpolation(interp, path, resolution_stack)?;
1026 let materialized = resolved.value.materialize()?;
1028 match materialized {
1029 Value::String(s) => Ok(s),
1030 Value::Bytes(bytes) => {
1031 Ok(String::from_utf8(bytes.clone()).unwrap_or_else(|_| {
1033 use base64::{engine::general_purpose::STANDARD, Engine as _};
1034 STANDARD.encode(&bytes)
1035 }))
1036 }
1037 other => Ok(other.to_string()),
1038 }
1039 }
1040 }
1041 }
1042
1043 fn resolve_relative_path(&self, current_path: &str, ref_path: &str) -> String {
1045 let mut ref_chars = ref_path.chars().peekable();
1046 let mut levels_up = 0;
1047
1048 while ref_chars.peek() == Some(&'.') {
1050 ref_chars.next();
1051 levels_up += 1;
1052 }
1053
1054 let remaining: String = ref_chars.collect();
1056
1057 if levels_up == 0 {
1058 return ref_path.to_string();
1060 }
1061
1062 let mut segments: Vec<&str> = current_path.split('.').collect();
1064
1065 for _ in 0..levels_up {
1069 segments.pop();
1070 }
1071
1072 if remaining.is_empty() {
1074 segments.join(".")
1075 } else if segments.is_empty() {
1076 remaining
1077 } else {
1078 format!("{}.{}", segments.join("."), remaining)
1079 }
1080 }
1081
1082 fn resolve_value_recursive(
1084 &self,
1085 value: &Value,
1086 path: &str,
1087 resolution_stack: &mut Vec<String>,
1088 ) -> Result<ResolvedValue> {
1089 match value {
1090 Value::String(s) => {
1091 if interpolation::needs_processing(s) {
1092 let parsed = interpolation::parse(s)?;
1093 let resolved = self.resolve_interpolation(&parsed, path, resolution_stack)?;
1094
1095 let materialized_value = resolved.value.materialize()?;
1097 let cached_resolved = ResolvedValue {
1098 value: materialized_value,
1099 sensitive: resolved.sensitive,
1100 };
1101
1102 let mut cache = self.cache.write().expect("Cache lock poisoned");
1104 cache.insert(path.to_string(), cached_resolved.clone());
1105
1106 Ok(cached_resolved)
1107 } else {
1108 Ok(ResolvedValue::new(value.clone()))
1109 }
1110 }
1111 Value::Sequence(seq) => {
1112 for (i, item) in seq.iter().enumerate() {
1113 let item_path = format!("{}[{}]", path, i);
1114 self.resolve_value_recursive(item, &item_path, resolution_stack)?;
1115 }
1116 Ok(ResolvedValue::new(value.clone()))
1117 }
1118 Value::Mapping(map) => {
1119 for (key, val) in map {
1120 let key_path = if path.is_empty() {
1121 key.clone()
1122 } else {
1123 format!("{}.{}", path, key)
1124 };
1125 self.resolve_value_recursive(val, &key_path, resolution_stack)?;
1126 }
1127 Ok(ResolvedValue::new(value.clone()))
1128 }
1129 _ => Ok(ResolvedValue::new(value.clone())),
1130 }
1131 }
1132
1133 fn resolve_value_to_value(
1135 &self,
1136 value: &Value,
1137 path: &str,
1138 resolution_stack: &mut Vec<String>,
1139 ) -> Result<Value> {
1140 match value {
1141 Value::String(s) => {
1142 if interpolation::needs_processing(s) {
1143 let parsed = interpolation::parse(s)?;
1144 let resolved = self.resolve_interpolation(&parsed, path, resolution_stack)?;
1145 Ok(resolved.value)
1146 } else {
1147 Ok(value.clone())
1148 }
1149 }
1150 Value::Sequence(seq) => {
1151 let mut resolved_seq = Vec::new();
1152 for (i, item) in seq.iter().enumerate() {
1153 let item_path = format!("{}[{}]", path, i);
1154 resolved_seq.push(self.resolve_value_to_value(
1155 item,
1156 &item_path,
1157 resolution_stack,
1158 )?);
1159 }
1160 Ok(Value::Sequence(resolved_seq))
1161 }
1162 Value::Mapping(map) => {
1163 let mut resolved = indexmap::IndexMap::new();
1164 for (key, val) in map {
1165 let key_path = if path.is_empty() {
1166 key.clone()
1167 } else {
1168 format!("{}.{}", path, key)
1169 };
1170 resolved.insert(
1171 key.clone(),
1172 self.resolve_value_to_value(val, &key_path, resolution_stack)?,
1173 );
1174 }
1175 Ok(Value::Mapping(resolved))
1176 }
1177 _ => Ok(value.clone()),
1178 }
1179 }
1180
1181 fn resolve_value_to_value_redacted(
1183 &self,
1184 value: &Value,
1185 path: &str,
1186 resolution_stack: &mut Vec<String>,
1187 ) -> Result<Value> {
1188 const REDACTED: &str = "[REDACTED]";
1189
1190 match value {
1191 Value::String(s) => {
1192 if interpolation::needs_processing(s) {
1193 let parsed = interpolation::parse(s)?;
1194 let resolved = self.resolve_interpolation(&parsed, path, resolution_stack)?;
1195 if resolved.sensitive {
1196 Ok(Value::String(REDACTED.to_string()))
1197 } else {
1198 Ok(resolved.value)
1199 }
1200 } else {
1201 Ok(value.clone())
1202 }
1203 }
1204 Value::Sequence(seq) => {
1205 let mut resolved_seq = Vec::new();
1206 for (i, item) in seq.iter().enumerate() {
1207 let item_path = format!("{}[{}]", path, i);
1208 resolved_seq.push(self.resolve_value_to_value_redacted(
1209 item,
1210 &item_path,
1211 resolution_stack,
1212 )?);
1213 }
1214 Ok(Value::Sequence(resolved_seq))
1215 }
1216 Value::Mapping(map) => {
1217 let mut resolved = indexmap::IndexMap::new();
1218 for (key, val) in map {
1219 let key_path = if path.is_empty() {
1220 key.clone()
1221 } else {
1222 format!("{}.{}", path, key)
1223 };
1224 resolved.insert(
1225 key.clone(),
1226 self.resolve_value_to_value_redacted(val, &key_path, resolution_stack)?,
1227 );
1228 }
1229 Ok(Value::Mapping(resolved))
1230 }
1231 _ => Ok(value.clone()),
1232 }
1233 }
1234}
1235
1236impl Clone for Config {
1237 fn clone(&self) -> Self {
1238 Self {
1239 raw: Arc::clone(&self.raw),
1240 cache: Arc::new(RwLock::new(HashMap::new())), source_map: Arc::clone(&self.source_map),
1242 resolvers: Arc::clone(&self.resolvers),
1243 options: self.options.clone(),
1244 schema: self.schema.clone(),
1245 }
1246 }
1247}
1248
1249#[cfg(test)]
1250mod tests {
1251 use super::*;
1252
1253 #[test]
1254 fn test_load_yaml() {
1255 let yaml = r#"
1256database:
1257 host: localhost
1258 port: 5432
1259"#;
1260 let config = Config::from_yaml(yaml).unwrap();
1261
1262 assert_eq!(
1263 config.get("database.host").unwrap().as_str(),
1264 Some("localhost")
1265 );
1266 assert_eq!(config.get("database.port").unwrap().as_i64(), Some(5432));
1267 }
1268
1269 #[test]
1270 fn test_env_resolver() {
1271 std::env::set_var("HOLOCONF_TEST_HOST", "prod-server");
1272
1273 let yaml = r#"
1274server:
1275 host: ${env:HOLOCONF_TEST_HOST}
1276"#;
1277 let config = Config::from_yaml(yaml).unwrap();
1278
1279 assert_eq!(
1280 config.get("server.host").unwrap().as_str(),
1281 Some("prod-server")
1282 );
1283
1284 std::env::remove_var("HOLOCONF_TEST_HOST");
1285 }
1286
1287 #[test]
1288 fn test_env_resolver_with_default() {
1289 std::env::remove_var("HOLOCONF_MISSING_VAR");
1290
1291 let yaml = r#"
1292server:
1293 host: ${env:HOLOCONF_MISSING_VAR,default=default-host}
1294"#;
1295 let config = Config::from_yaml(yaml).unwrap();
1296
1297 assert_eq!(
1298 config.get("server.host").unwrap().as_str(),
1299 Some("default-host")
1300 );
1301 }
1302
1303 #[test]
1304 fn test_self_reference() {
1305 let yaml = r#"
1306defaults:
1307 host: localhost
1308database:
1309 host: ${defaults.host}
1310"#;
1311 let config = Config::from_yaml(yaml).unwrap();
1312
1313 assert_eq!(
1314 config.get("database.host").unwrap().as_str(),
1315 Some("localhost")
1316 );
1317 }
1318
1319 #[test]
1320 fn test_string_concatenation() {
1321 std::env::set_var("HOLOCONF_PREFIX", "prod");
1322
1323 let yaml = r#"
1324bucket: myapp-${env:HOLOCONF_PREFIX}-data
1325"#;
1326 let config = Config::from_yaml(yaml).unwrap();
1327
1328 assert_eq!(
1329 config.get("bucket").unwrap().as_str(),
1330 Some("myapp-prod-data")
1331 );
1332
1333 std::env::remove_var("HOLOCONF_PREFIX");
1334 }
1335
1336 #[test]
1337 fn test_escaped_interpolation() {
1338 let yaml = r#"
1341literal: '\${not_resolved}'
1342"#;
1343 let config = Config::from_yaml(yaml).unwrap();
1344
1345 assert_eq!(
1347 config.get("literal").unwrap().as_str(),
1348 Some("${not_resolved}")
1349 );
1350 }
1351
1352 #[test]
1353 fn test_type_coercion_string_to_int() {
1354 std::env::set_var("HOLOCONF_PORT", "8080");
1355
1356 let yaml = r#"
1357port: ${env:HOLOCONF_PORT}
1358"#;
1359 let config = Config::from_yaml(yaml).unwrap();
1360
1361 assert_eq!(config.get_i64("port").unwrap(), 8080);
1363
1364 std::env::remove_var("HOLOCONF_PORT");
1365 }
1366
1367 #[test]
1368 fn test_strict_boolean_coercion() {
1369 std::env::set_var("HOLOCONF_ENABLED", "true");
1370 std::env::set_var("HOLOCONF_INVALID", "1");
1371
1372 let yaml = r#"
1373enabled: ${env:HOLOCONF_ENABLED}
1374invalid: ${env:HOLOCONF_INVALID}
1375"#;
1376 let config = Config::from_yaml(yaml).unwrap();
1377
1378 assert!(config.get_bool("enabled").unwrap());
1380
1381 assert!(config.get_bool("invalid").is_err());
1383
1384 std::env::remove_var("HOLOCONF_ENABLED");
1385 std::env::remove_var("HOLOCONF_INVALID");
1386 }
1387
1388 #[test]
1389 fn test_boolean_coercion_case_insensitive() {
1390 let yaml = r#"
1392lower_true: "true"
1393upper_true: "TRUE"
1394mixed_true: "True"
1395lower_false: "false"
1396upper_false: "FALSE"
1397mixed_false: "False"
1398"#;
1399 let config = Config::from_yaml(yaml).unwrap();
1400
1401 assert!(config.get_bool("lower_true").unwrap());
1403 assert!(config.get_bool("upper_true").unwrap());
1404 assert!(config.get_bool("mixed_true").unwrap());
1405
1406 assert!(!config.get_bool("lower_false").unwrap());
1408 assert!(!config.get_bool("upper_false").unwrap());
1409 assert!(!config.get_bool("mixed_false").unwrap());
1410 }
1411
1412 #[test]
1413 fn test_boolean_coercion_rejects_invalid() {
1414 let yaml = r#"
1416yes_value: "yes"
1417no_value: "no"
1418one_value: "1"
1419zero_value: "0"
1420on_value: "on"
1421off_value: "off"
1422"#;
1423 let config = Config::from_yaml(yaml).unwrap();
1424
1425 assert!(config.get_bool("yes_value").is_err());
1427 assert!(config.get_bool("no_value").is_err());
1428 assert!(config.get_bool("one_value").is_err());
1429 assert!(config.get_bool("zero_value").is_err());
1430 assert!(config.get_bool("on_value").is_err());
1431 assert!(config.get_bool("off_value").is_err());
1432 }
1433
1434 #[test]
1435 fn test_caching() {
1436 std::env::set_var("HOLOCONF_CACHED", "initial");
1437
1438 let yaml = r#"
1439value: ${env:HOLOCONF_CACHED}
1440"#;
1441 let config = Config::from_yaml(yaml).unwrap();
1442
1443 assert_eq!(config.get("value").unwrap().as_str(), Some("initial"));
1445
1446 std::env::set_var("HOLOCONF_CACHED", "changed");
1448
1449 assert_eq!(config.get("value").unwrap().as_str(), Some("initial"));
1451
1452 config.clear_cache();
1454
1455 assert_eq!(config.get("value").unwrap().as_str(), Some("changed"));
1457
1458 std::env::remove_var("HOLOCONF_CACHED");
1459 }
1460
1461 #[test]
1462 fn test_path_not_found() {
1463 let yaml = r#"
1464database:
1465 host: localhost
1466"#;
1467 let config = Config::from_yaml(yaml).unwrap();
1468
1469 let result = config.get("database.nonexistent");
1470 assert!(result.is_err());
1471 }
1472
1473 #[test]
1474 fn test_to_yaml_resolved() {
1475 std::env::set_var("HOLOCONF_EXPORT_HOST", "exported-host");
1476
1477 let yaml = r#"
1478server:
1479 host: ${env:HOLOCONF_EXPORT_HOST}
1480 port: 8080
1481"#;
1482 let config = Config::from_yaml(yaml).unwrap();
1483
1484 let exported = config.to_yaml(true, false).unwrap();
1485 assert!(exported.contains("exported-host"));
1486 assert!(exported.contains("8080"));
1487
1488 std::env::remove_var("HOLOCONF_EXPORT_HOST");
1489 }
1490
1491 #[test]
1492 fn test_relative_path_sibling() {
1493 let yaml = r#"
1494database:
1495 host: localhost
1496 url: postgres://${.host}:5432/db
1497"#;
1498 let config = Config::from_yaml(yaml).unwrap();
1499
1500 assert_eq!(
1501 config.get("database.url").unwrap().as_str(),
1502 Some("postgres://localhost:5432/db")
1503 );
1504 }
1505
1506 #[test]
1507 fn test_array_access() {
1508 let yaml = r#"
1509servers:
1510 - host: server1
1511 - host: server2
1512primary: ${servers[0].host}
1513"#;
1514 let config = Config::from_yaml(yaml).unwrap();
1515
1516 assert_eq!(config.get("primary").unwrap().as_str(), Some("server1"));
1517 }
1518
1519 #[test]
1520 fn test_nested_interpolation() {
1521 std::env::set_var("HOLOCONF_DEFAULT_HOST", "fallback-host");
1522
1523 let yaml = r#"
1524host: ${env:UNDEFINED_HOST,default=${env:HOLOCONF_DEFAULT_HOST}}
1525"#;
1526 let config = Config::from_yaml(yaml).unwrap();
1527
1528 assert_eq!(config.get("host").unwrap().as_str(), Some("fallback-host"));
1529
1530 std::env::remove_var("HOLOCONF_DEFAULT_HOST");
1531 }
1532
1533 #[test]
1534 fn test_to_yaml_unresolved() {
1535 let yaml = r#"
1536server:
1537 host: ${env:MY_HOST}
1538 port: 8080
1539"#;
1540 let config = Config::from_yaml(yaml).unwrap();
1541
1542 let raw = config.to_yaml(false, false).unwrap();
1543 assert!(raw.contains("${env:MY_HOST}"));
1545 assert!(raw.contains("8080"));
1546 }
1547
1548 #[test]
1549 fn test_to_json_unresolved() {
1550 let yaml = r#"
1551database:
1552 url: ${env:DATABASE_URL}
1553"#;
1554 let config = Config::from_yaml(yaml).unwrap();
1555
1556 let raw = config.to_json(false, false).unwrap();
1557 assert!(raw.contains("${env:DATABASE_URL}"));
1559 }
1560
1561 #[test]
1562 fn test_to_value_unresolved() {
1563 let yaml = r#"
1564key: ${env:SOME_VAR}
1565"#;
1566 let config = Config::from_yaml(yaml).unwrap();
1567
1568 let raw = config.to_value(false, false).unwrap();
1569 assert_eq!(
1570 raw.get_path("key").unwrap().as_str(),
1571 Some("${env:SOME_VAR}")
1572 );
1573 }
1574
1575 #[test]
1576 fn test_to_yaml_redacted_no_sensitive() {
1577 std::env::set_var("HOLOCONF_NON_SENSITIVE", "public-value");
1578
1579 let yaml = r#"
1580value: ${env:HOLOCONF_NON_SENSITIVE}
1581"#;
1582 let config = Config::from_yaml(yaml).unwrap();
1583
1584 let output = config.to_yaml(true, true).unwrap();
1586 assert!(output.contains("public-value"));
1587
1588 std::env::remove_var("HOLOCONF_NON_SENSITIVE");
1589 }
1590
1591 #[test]
1592 fn test_circular_reference_direct() {
1593 let yaml = r#"
1595a: ${b}
1596b: ${a}
1597"#;
1598 let config = Config::from_yaml(yaml).unwrap();
1599
1600 let result = config.get("a");
1602 assert!(result.is_err());
1603 let err = result.unwrap_err();
1604 assert!(
1605 err.to_string().to_lowercase().contains("circular"),
1606 "Error should mention 'circular': {}",
1607 err
1608 );
1609 }
1610
1611 #[test]
1612 fn test_circular_reference_chain() {
1613 let yaml = r#"
1615first: ${second}
1616second: ${third}
1617third: ${first}
1618"#;
1619 let config = Config::from_yaml(yaml).unwrap();
1620
1621 let result = config.get("first");
1623 assert!(result.is_err());
1624 let err = result.unwrap_err();
1625 assert!(
1626 err.to_string().to_lowercase().contains("circular"),
1627 "Error should mention 'circular': {}",
1628 err
1629 );
1630 }
1631
1632 #[test]
1633 fn test_circular_reference_self() {
1634 let yaml = r#"
1636value: ${value}
1637"#;
1638 let config = Config::from_yaml(yaml).unwrap();
1639
1640 let result = config.get("value");
1641 assert!(result.is_err());
1642 let err = result.unwrap_err();
1643 assert!(
1644 err.to_string().to_lowercase().contains("circular"),
1645 "Error should mention 'circular': {}",
1646 err
1647 );
1648 }
1649
1650 #[test]
1651 fn test_circular_reference_nested() {
1652 let yaml = r#"
1654database:
1655 primary: ${database.secondary}
1656 secondary: ${database.primary}
1657"#;
1658 let config = Config::from_yaml(yaml).unwrap();
1659
1660 let result = config.get("database.primary");
1661 assert!(result.is_err());
1662 let err = result.unwrap_err();
1663 assert!(
1664 err.to_string().to_lowercase().contains("circular"),
1665 "Error should mention 'circular': {}",
1666 err
1667 );
1668 }
1669
1670 #[test]
1673 fn test_get_source_from_yaml_string() {
1674 let yaml = r#"
1676database:
1677 host: localhost
1678"#;
1679 let config = Config::from_yaml(yaml).unwrap();
1680
1681 assert!(config.get_source("database.host").is_none());
1683 assert!(config.dump_sources().is_empty());
1684 }
1685
1686 #[test]
1687 fn test_source_tracking_load_and_merge() {
1688 let temp_dir = std::env::temp_dir().join("holoconf_test_sources");
1690 std::fs::create_dir_all(&temp_dir).unwrap();
1691
1692 let base_path = temp_dir.join("base.yaml");
1693 let override_path = temp_dir.join("override.yaml");
1694
1695 std::fs::write(
1696 &base_path,
1697 r#"
1698database:
1699 host: localhost
1700 port: 5432
1701api:
1702 url: http://localhost
1703"#,
1704 )
1705 .unwrap();
1706
1707 std::fs::write(
1708 &override_path,
1709 r#"
1710database:
1711 host: prod-db.example.com
1712api:
1713 key: secret123
1714"#,
1715 )
1716 .unwrap();
1717
1718 let mut config = Config::load(&base_path).unwrap();
1720 let override_config = Config::load(&override_path).unwrap();
1721 config.merge(override_config);
1722
1723 assert_eq!(
1725 config.get("database.host").unwrap().as_str(),
1726 Some("prod-db.example.com")
1727 );
1728 assert_eq!(config.get("database.port").unwrap().as_i64(), Some(5432));
1729 assert_eq!(
1730 config.get("api.url").unwrap().as_str(),
1731 Some("http://localhost")
1732 );
1733 assert_eq!(config.get("api.key").unwrap().as_str(), Some("secret123"));
1734
1735 std::fs::remove_dir_all(&temp_dir).ok();
1737 }
1738
1739 #[test]
1740 fn test_source_tracking_single_file() {
1741 let temp_dir = std::env::temp_dir().join("holoconf_test_single");
1742 std::fs::create_dir_all(&temp_dir).unwrap();
1743
1744 let config_path = temp_dir.join("config.yaml");
1745 std::fs::write(
1746 &config_path,
1747 r#"
1748database:
1749 host: localhost
1750 port: 5432
1751"#,
1752 )
1753 .unwrap();
1754
1755 let config = Config::load(&config_path).unwrap();
1756
1757 assert_eq!(config.get_source("database.host"), Some("config.yaml"));
1759 assert_eq!(config.get_source("database.port"), Some("config.yaml"));
1760
1761 std::fs::remove_dir_all(&temp_dir).ok();
1763 }
1764
1765 #[test]
1766 fn test_null_removes_values_on_merge() {
1767 let temp_dir = std::env::temp_dir().join("holoconf_test_null");
1768 std::fs::create_dir_all(&temp_dir).unwrap();
1769
1770 let base_path = temp_dir.join("base.yaml");
1771 let override_path = temp_dir.join("override.yaml");
1772
1773 std::fs::write(
1774 &base_path,
1775 r#"
1776database:
1777 host: localhost
1778 port: 5432
1779 debug: true
1780"#,
1781 )
1782 .unwrap();
1783
1784 std::fs::write(
1785 &override_path,
1786 r#"
1787database:
1788 debug: null
1789"#,
1790 )
1791 .unwrap();
1792
1793 let mut config = Config::load(&base_path).unwrap();
1794 let override_config = Config::load(&override_path).unwrap();
1795 config.merge(override_config);
1796
1797 assert!(config.get("database.debug").is_err());
1799 assert_eq!(
1801 config.get("database.host").unwrap().as_str(),
1802 Some("localhost")
1803 );
1804 assert_eq!(config.get("database.port").unwrap().as_i64(), Some(5432));
1805
1806 std::fs::remove_dir_all(&temp_dir).ok();
1808 }
1809
1810 #[test]
1811 fn test_array_replacement_on_merge() {
1812 let temp_dir = std::env::temp_dir().join("holoconf_test_array");
1813 std::fs::create_dir_all(&temp_dir).unwrap();
1814
1815 let base_path = temp_dir.join("base.yaml");
1816 let override_path = temp_dir.join("override.yaml");
1817
1818 std::fs::write(
1819 &base_path,
1820 r#"
1821servers:
1822 - host: server1
1823 - host: server2
1824"#,
1825 )
1826 .unwrap();
1827
1828 std::fs::write(
1829 &override_path,
1830 r#"
1831servers:
1832 - host: prod-server
1833"#,
1834 )
1835 .unwrap();
1836
1837 let mut config = Config::load(&base_path).unwrap();
1838 let override_config = Config::load(&override_path).unwrap();
1839 config.merge(override_config);
1840
1841 assert_eq!(
1843 config.get("servers[0].host").unwrap().as_str(),
1844 Some("prod-server")
1845 );
1846 assert!(config.get("servers[1].host").is_err());
1848
1849 std::fs::remove_dir_all(&temp_dir).ok();
1851 }
1852
1853 #[test]
1856 fn test_optional_file_missing() {
1857 let temp_dir = std::env::temp_dir().join("holoconf_test_optional_missing");
1858 std::fs::create_dir_all(&temp_dir).unwrap();
1859
1860 let base_path = temp_dir.join("base.yaml");
1861 let optional_path = temp_dir.join("optional.yaml"); std::fs::write(
1864 &base_path,
1865 r#"
1866database:
1867 host: localhost
1868 port: 5432
1869"#,
1870 )
1871 .unwrap();
1872
1873 let mut config = Config::load(&base_path).unwrap();
1875 let optional_config = Config::optional(&optional_path).unwrap();
1876 config.merge(optional_config);
1877
1878 assert_eq!(
1880 config.get("database.host").unwrap().as_str(),
1881 Some("localhost")
1882 );
1883 assert_eq!(config.get("database.port").unwrap().as_i64(), Some(5432));
1884
1885 std::fs::remove_dir_all(&temp_dir).ok();
1887 }
1888
1889 #[test]
1890 fn test_optional_file_exists() {
1891 let temp_dir = std::env::temp_dir().join("holoconf_test_optional_exists");
1892 std::fs::create_dir_all(&temp_dir).unwrap();
1893
1894 let base_path = temp_dir.join("base.yaml");
1895 let optional_path = temp_dir.join("optional.yaml");
1896
1897 std::fs::write(
1898 &base_path,
1899 r#"
1900database:
1901 host: localhost
1902 port: 5432
1903"#,
1904 )
1905 .unwrap();
1906
1907 std::fs::write(
1908 &optional_path,
1909 r#"
1910database:
1911 host: prod-db
1912"#,
1913 )
1914 .unwrap();
1915
1916 let mut config = Config::load(&base_path).unwrap();
1918 let optional_config = Config::optional(&optional_path).unwrap();
1919 config.merge(optional_config);
1920
1921 assert_eq!(
1923 config.get("database.host").unwrap().as_str(),
1924 Some("prod-db")
1925 );
1926 assert_eq!(config.get("database.port").unwrap().as_i64(), Some(5432));
1927
1928 std::fs::remove_dir_all(&temp_dir).ok();
1930 }
1931
1932 #[test]
1933 fn test_required_file_missing_errors() {
1934 let temp_dir = std::env::temp_dir().join("holoconf_test_required_missing");
1935 std::fs::create_dir_all(&temp_dir).unwrap();
1936
1937 let missing_path = temp_dir.join("missing.yaml"); let result = Config::load(&missing_path);
1941
1942 match result {
1943 Ok(_) => panic!("Expected error for missing required file"),
1944 Err(err) => {
1945 assert!(
1946 err.to_string().contains("File not found"),
1947 "Error should mention file not found: {}",
1948 err
1949 );
1950 }
1951 }
1952
1953 let result2 = Config::required(&missing_path);
1955 assert!(result2.is_err());
1956
1957 std::fs::remove_dir_all(&temp_dir).ok();
1959 }
1960
1961 #[test]
1962 fn test_all_optional_files_missing() {
1963 let temp_dir = std::env::temp_dir().join("holoconf_test_all_optional_missing");
1964 std::fs::create_dir_all(&temp_dir).unwrap();
1965
1966 let optional1 = temp_dir.join("optional1.yaml");
1967 let optional2 = temp_dir.join("optional2.yaml");
1968
1969 let mut config = Config::optional(&optional1).unwrap();
1971 let config2 = Config::optional(&optional2).unwrap();
1972 config.merge(config2);
1973
1974 let value = config.to_value(false, false).unwrap();
1976 assert!(value.as_mapping().unwrap().is_empty());
1977
1978 std::fs::remove_dir_all(&temp_dir).ok();
1980 }
1981
1982 #[test]
1983 fn test_mixed_required_and_optional() {
1984 let temp_dir = std::env::temp_dir().join("holoconf_test_mixed_req_opt");
1985 std::fs::create_dir_all(&temp_dir).unwrap();
1986
1987 let required1 = temp_dir.join("required1.yaml");
1988 let optional1 = temp_dir.join("optional1.yaml"); let required2 = temp_dir.join("required2.yaml");
1990 let optional2 = temp_dir.join("optional2.yaml");
1991
1992 std::fs::write(
1993 &required1,
1994 r#"
1995app:
1996 name: myapp
1997 debug: false
1998"#,
1999 )
2000 .unwrap();
2001
2002 std::fs::write(
2003 &required2,
2004 r#"
2005database:
2006 host: localhost
2007"#,
2008 )
2009 .unwrap();
2010
2011 std::fs::write(
2012 &optional2,
2013 r#"
2014app:
2015 debug: true
2016database:
2017 port: 5432
2018"#,
2019 )
2020 .unwrap();
2021
2022 let mut config = Config::load(&required1).unwrap();
2024 let opt1 = Config::optional(&optional1).unwrap(); config.merge(opt1);
2026 let req2 = Config::load(&required2).unwrap();
2027 config.merge(req2);
2028 let opt2 = Config::optional(&optional2).unwrap(); config.merge(opt2);
2030
2031 assert_eq!(config.get("app.name").unwrap().as_str(), Some("myapp"));
2033 assert_eq!(config.get("app.debug").unwrap().as_bool(), Some(true)); assert_eq!(
2035 config.get("database.host").unwrap().as_str(),
2036 Some("localhost")
2037 );
2038 assert_eq!(config.get("database.port").unwrap().as_i64(), Some(5432)); std::fs::remove_dir_all(&temp_dir).ok();
2042 }
2043
2044 #[test]
2045 fn test_from_json() {
2046 let json = r#"{"database": {"host": "localhost", "port": 5432}}"#;
2047 let config = Config::from_json(json).unwrap();
2048
2049 assert_eq!(
2050 config.get("database.host").unwrap().as_str(),
2051 Some("localhost")
2052 );
2053 assert_eq!(config.get("database.port").unwrap().as_i64(), Some(5432));
2054 }
2055
2056 #[test]
2057 fn test_from_json_invalid() {
2058 let json = r#"{"unclosed": "#;
2059 let result = Config::from_json(json);
2060 assert!(result.is_err());
2061 }
2062
2063 #[test]
2064 fn test_get_raw() {
2065 let yaml = r#"
2066key: ${env:SOME_VAR,default=fallback}
2067literal: plain_value
2068"#;
2069 let config = Config::from_yaml(yaml).unwrap();
2070
2071 let raw = config.get_raw("key").unwrap();
2073 assert!(raw.as_str().unwrap().contains("${env:"));
2074
2075 let literal = config.get_raw("literal").unwrap();
2077 assert_eq!(literal.as_str(), Some("plain_value"));
2078 }
2079
2080 #[test]
2081 fn test_get_string() {
2082 std::env::set_var("HOLOCONF_TEST_STRING", "hello_world");
2083
2084 let yaml = r#"
2085plain: "plain_string"
2086env_var: ${env:HOLOCONF_TEST_STRING}
2087number: 42
2088"#;
2089 let config = Config::from_yaml(yaml).unwrap();
2090
2091 assert_eq!(config.get_string("plain").unwrap(), "plain_string");
2092 assert_eq!(config.get_string("env_var").unwrap(), "hello_world");
2093
2094 assert_eq!(config.get_string("number").unwrap(), "42");
2096
2097 std::env::remove_var("HOLOCONF_TEST_STRING");
2098 }
2099
2100 #[test]
2101 fn test_get_f64() {
2102 let yaml = r#"
2103float: 1.23
2104int: 42
2105string_num: "4.56"
2106string_bad: "not_a_number"
2107"#;
2108 let config = Config::from_yaml(yaml).unwrap();
2109
2110 assert!((config.get_f64("float").unwrap() - 1.23).abs() < 0.001);
2111 assert!((config.get_f64("int").unwrap() - 42.0).abs() < 0.001);
2112 assert!((config.get_f64("string_num").unwrap() - 4.56).abs() < 0.001);
2114 assert!(config.get_f64("string_bad").is_err());
2116 }
2117
2118 #[test]
2119 fn test_config_merge() {
2120 let yaml1 = r#"
2121database:
2122 host: localhost
2123 port: 5432
2124app:
2125 name: myapp
2126"#;
2127 let yaml2 = r#"
2128database:
2129 port: 3306
2130 user: admin
2131app:
2132 debug: true
2133"#;
2134 let mut config1 = Config::from_yaml(yaml1).unwrap();
2135 let config2 = Config::from_yaml(yaml2).unwrap();
2136
2137 config1.merge(config2);
2138
2139 assert_eq!(
2141 config1.get("database.host").unwrap().as_str(),
2142 Some("localhost")
2143 );
2144 assert_eq!(config1.get("database.port").unwrap().as_i64(), Some(3306)); assert_eq!(
2146 config1.get("database.user").unwrap().as_str(),
2147 Some("admin")
2148 ); assert_eq!(config1.get("app.name").unwrap().as_str(), Some("myapp"));
2150 assert_eq!(config1.get("app.debug").unwrap().as_bool(), Some(true)); }
2152
2153 #[test]
2154 fn test_config_clone() {
2155 let yaml = r#"
2156key: value
2157nested:
2158 a: 1
2159 b: 2
2160"#;
2161 let config = Config::from_yaml(yaml).unwrap();
2162 let cloned = config.clone();
2163
2164 assert_eq!(cloned.get("key").unwrap().as_str(), Some("value"));
2165 assert_eq!(cloned.get("nested.a").unwrap().as_i64(), Some(1));
2166 }
2167
2168 #[test]
2169 fn test_with_options() {
2170 use indexmap::IndexMap;
2171 let mut map = IndexMap::new();
2172 map.insert("key".to_string(), crate::Value::String("value".to_string()));
2173 let value = crate::Value::Mapping(map);
2174 let options = ConfigOptions {
2175 base_path: None,
2176 allow_http: true,
2177 http_allowlist: vec![],
2178 file_roots: vec!["/custom/path".into()],
2179 ..Default::default()
2180 };
2181 let config = Config::with_options(value, options);
2182
2183 assert_eq!(config.get("key").unwrap().as_str(), Some("value"));
2184 }
2185
2186 #[test]
2187 fn test_from_yaml_with_options() {
2188 let yaml = "key: value";
2189 let options = ConfigOptions {
2190 base_path: None,
2191 allow_http: true,
2192 http_allowlist: vec![],
2193 file_roots: vec![],
2194 ..Default::default()
2195 };
2196 let config = Config::from_yaml_with_options(yaml, options).unwrap();
2197
2198 assert_eq!(config.get("key").unwrap().as_str(), Some("value"));
2199 }
2200
2201 #[test]
2202 fn test_resolve_all() {
2203 std::env::set_var("HOLOCONF_RESOLVE_ALL_TEST", "resolved");
2204
2205 let yaml = r#"
2206a: ${env:HOLOCONF_RESOLVE_ALL_TEST}
2207b: static_value
2208c:
2209 nested: ${env:HOLOCONF_RESOLVE_ALL_TEST}
2210"#;
2211 let config = Config::from_yaml(yaml).unwrap();
2212
2213 config.resolve_all().unwrap();
2215
2216 assert_eq!(config.get("a").unwrap().as_str(), Some("resolved"));
2218 assert_eq!(config.get("b").unwrap().as_str(), Some("static_value"));
2219 assert_eq!(config.get("c.nested").unwrap().as_str(), Some("resolved"));
2220
2221 std::env::remove_var("HOLOCONF_RESOLVE_ALL_TEST");
2222 }
2223
2224 #[test]
2225 fn test_resolve_all_with_errors() {
2226 let yaml = r#"
2227valid: static
2228invalid: ${env:HOLOCONF_NONEXISTENT_RESOLVE_VAR}
2229"#;
2230 std::env::remove_var("HOLOCONF_NONEXISTENT_RESOLVE_VAR");
2231
2232 let config = Config::from_yaml(yaml).unwrap();
2233 let result = config.resolve_all();
2234
2235 assert!(result.is_err());
2236 assert!(result.unwrap_err().to_string().contains("not found"));
2237 }
2238
2239 #[test]
2240 fn test_self_reference_basic() {
2241 let yaml = r#"
2243settings:
2244 timeout: 30
2245app:
2246 timeout: ${settings.timeout}
2247"#;
2248 let config = Config::from_yaml(yaml).unwrap();
2249
2250 assert_eq!(config.get("app.timeout").unwrap().as_i64(), Some(30));
2251 }
2252
2253 #[test]
2254 fn test_self_reference_missing_errors() {
2255 let yaml = r#"
2256app:
2257 timeout: ${settings.missing_timeout}
2258"#;
2259 let config = Config::from_yaml(yaml).unwrap();
2260
2261 let result = config.get("app.timeout");
2263 assert!(result.is_err());
2264 assert!(result.unwrap_err().to_string().contains("not found"));
2265 }
2266
2267 #[test]
2268 fn test_self_reference_sensitivity_inheritance() {
2269 std::env::set_var("HOLOCONF_INHERITED_SECRET", "secret_value");
2270
2271 let yaml = r#"
2272secrets:
2273 api_key: ${env:HOLOCONF_INHERITED_SECRET,sensitive=true}
2274derived: ${secrets.api_key}
2275"#;
2276 let config = Config::from_yaml(yaml).unwrap();
2277
2278 assert_eq!(
2280 config.get("secrets.api_key").unwrap().as_str(),
2281 Some("secret_value")
2282 );
2283 assert_eq!(
2284 config.get("derived").unwrap().as_str(),
2285 Some("secret_value")
2286 );
2287
2288 let yaml_output = config.to_yaml(true, true).unwrap();
2290 assert!(yaml_output.contains("[REDACTED]"));
2291 assert!(!yaml_output.contains("secret_value"));
2292
2293 std::env::remove_var("HOLOCONF_INHERITED_SECRET");
2294 }
2295
2296 #[test]
2297 fn test_non_notfound_error_does_not_use_default() {
2298 use crate::resolver::FnResolver;
2300 use std::sync::Arc;
2301
2302 let yaml = r#"
2303value: ${failing:arg,default=should_not_be_used}
2304"#;
2305 let mut config = Config::from_yaml(yaml).unwrap();
2306
2307 config.register_resolver(Arc::new(FnResolver::new(
2309 "failing",
2310 |_args, _kwargs, ctx| {
2311 Err(
2312 crate::error::Error::resolver_custom("failing", "Network timeout")
2313 .with_path(ctx.config_path.clone()),
2314 )
2315 },
2316 )));
2317
2318 let result = config.get("value");
2320 assert!(result.is_err());
2321 assert!(result.unwrap_err().to_string().contains("Network timeout"));
2322 }
2323
2324 #[test]
2327 fn test_get_returns_schema_default() {
2328 use crate::schema::Schema;
2329
2330 let yaml = r#"
2331database:
2332 host: localhost
2333"#;
2334 let schema_yaml = r#"
2335type: object
2336properties:
2337 database:
2338 type: object
2339 properties:
2340 host:
2341 type: string
2342 port:
2343 type: integer
2344 default: 5432
2345 pool_size:
2346 type: integer
2347 default: 10
2348"#;
2349 let mut config = Config::from_yaml(yaml).unwrap();
2350 let schema = Schema::from_yaml(schema_yaml).unwrap();
2351 config.set_schema(schema);
2352
2353 assert_eq!(
2355 config.get("database.host").unwrap(),
2356 Value::String("localhost".into())
2357 );
2358
2359 assert_eq!(config.get("database.port").unwrap(), Value::Integer(5432));
2361 assert_eq!(
2362 config.get("database.pool_size").unwrap(),
2363 Value::Integer(10)
2364 );
2365 }
2366
2367 #[test]
2368 fn test_config_value_overrides_schema_default() {
2369 use crate::schema::Schema;
2370
2371 let yaml = r#"
2372port: 3000
2373"#;
2374 let schema_yaml = r#"
2375type: object
2376properties:
2377 port:
2378 type: integer
2379 default: 8080
2380"#;
2381 let mut config = Config::from_yaml(yaml).unwrap();
2382 let schema = Schema::from_yaml(schema_yaml).unwrap();
2383 config.set_schema(schema);
2384
2385 assert_eq!(config.get("port").unwrap(), Value::Integer(3000));
2387 }
2388
2389 #[test]
2390 fn test_no_schema_raises_path_not_found() {
2391 let yaml = r#"
2392existing: value
2393"#;
2394 let config = Config::from_yaml(yaml).unwrap();
2395
2396 let result = config.get("missing");
2398 assert!(result.is_err());
2399 assert!(matches!(
2400 result.unwrap_err().kind,
2401 crate::error::ErrorKind::PathNotFound
2402 ));
2403 }
2404
2405 #[test]
2406 fn test_missing_path_no_default_raises_error() {
2407 use crate::schema::Schema;
2408
2409 let yaml = r#"
2410existing: value
2411"#;
2412 let schema_yaml = r#"
2413type: object
2414properties:
2415 existing:
2416 type: string
2417 no_default:
2418 type: string
2419"#;
2420 let mut config = Config::from_yaml(yaml).unwrap();
2421 let schema = Schema::from_yaml(schema_yaml).unwrap();
2422 config.set_schema(schema);
2423
2424 let result = config.get("no_default");
2426 assert!(result.is_err());
2427 assert!(matches!(
2428 result.unwrap_err().kind,
2429 crate::error::ErrorKind::PathNotFound
2430 ));
2431 }
2432
2433 #[test]
2434 fn test_validate_uses_attached_schema() {
2435 use crate::schema::Schema;
2436
2437 let yaml = r#"
2438name: test
2439port: 8080
2440"#;
2441 let schema_yaml = r#"
2442type: object
2443required:
2444 - name
2445 - port
2446properties:
2447 name:
2448 type: string
2449 port:
2450 type: integer
2451"#;
2452 let mut config = Config::from_yaml(yaml).unwrap();
2453 let schema = Schema::from_yaml(schema_yaml).unwrap();
2454 config.set_schema(schema);
2455
2456 assert!(config.validate(None).is_ok());
2458 }
2459
2460 #[test]
2461 fn test_validate_no_schema_errors() {
2462 let yaml = r#"
2463name: test
2464"#;
2465 let config = Config::from_yaml(yaml).unwrap();
2466
2467 let result = config.validate(None);
2469 assert!(result.is_err());
2470 let err = result.unwrap_err();
2471 assert!(err.to_string().contains("No schema"));
2472 }
2473
2474 #[test]
2475 fn test_null_value_uses_default_when_null_disallowed() {
2476 use crate::schema::Schema;
2477
2478 let yaml = r#"
2479value: null
2480"#;
2481 let schema_yaml = r#"
2482type: object
2483properties:
2484 value:
2485 type: string
2486 default: "fallback"
2487"#;
2488 let mut config = Config::from_yaml(yaml).unwrap();
2489 let schema = Schema::from_yaml(schema_yaml).unwrap();
2490 config.set_schema(schema);
2491
2492 assert_eq!(
2494 config.get("value").unwrap(),
2495 Value::String("fallback".into())
2496 );
2497 }
2498
2499 #[test]
2500 fn test_null_value_preserved_when_null_allowed() {
2501 use crate::schema::Schema;
2502
2503 let yaml = r#"
2504value: null
2505"#;
2506 let schema_yaml = r#"
2507type: object
2508properties:
2509 value:
2510 type:
2511 - string
2512 - "null"
2513 default: "fallback"
2514"#;
2515 let mut config = Config::from_yaml(yaml).unwrap();
2516 let schema = Schema::from_yaml(schema_yaml).unwrap();
2517 config.set_schema(schema);
2518
2519 assert_eq!(config.get("value").unwrap(), Value::Null);
2521 }
2522
2523 #[test]
2524 fn test_set_and_get_schema() {
2525 use crate::schema::Schema;
2526
2527 let yaml = r#"
2528name: test
2529"#;
2530 let mut config = Config::from_yaml(yaml).unwrap();
2531
2532 assert!(config.get_schema().is_none());
2534
2535 let schema = Schema::from_yaml(
2536 r#"
2537type: object
2538properties:
2539 name:
2540 type: string
2541"#,
2542 )
2543 .unwrap();
2544 config.set_schema(schema);
2545
2546 assert!(config.get_schema().is_some());
2548 }
2549
2550 #[test]
2551 fn test_ref_with_default_missing_path() {
2552 let yaml = r#"
2553app:
2554 name: myapp
2555 # No 'timeout' defined
2556 effective_timeout: ${app.timeout,default=30}
2557"#;
2558 let config = Config::from_yaml(yaml).unwrap();
2559 assert_eq!(
2560 config.get("app.effective_timeout").unwrap().as_str(),
2561 Some("30")
2562 );
2563 }
2564
2565 #[test]
2566 fn test_ref_with_default_null_value() {
2567 let yaml = r#"
2568app:
2569 timeout: null
2570 effective_timeout: ${app.timeout,default=30}
2571"#;
2572 let config = Config::from_yaml(yaml).unwrap();
2573 assert_eq!(
2574 config.get("app.effective_timeout").unwrap().as_str(),
2575 Some("30")
2576 );
2577 }
2578
2579 #[test]
2580 fn test_ref_with_default_value_exists() {
2581 let yaml = r#"
2582app:
2583 timeout: 60
2584 effective_timeout: ${app.timeout,default=30}
2585"#;
2586 let config = Config::from_yaml(yaml).unwrap();
2587 assert_eq!(
2589 config.get("app.effective_timeout").unwrap().as_i64(),
2590 Some(60)
2591 );
2592 }
2593
2594 #[test]
2595 fn test_ref_without_default_missing_errors() {
2596 let yaml = r#"
2597app:
2598 name: myapp
2599 timeout: ${app.missing}
2600"#;
2601 let config = Config::from_yaml(yaml).unwrap();
2602 let result = config.get("app.timeout");
2603 assert!(result.is_err());
2604 }
2605
2606 #[test]
2607 fn test_ref_nested_defaults() {
2608 let yaml = r#"
2609defaults:
2610 timeout: 30
2611app:
2612 timeout: ${custom.timeout,default=${defaults.timeout}}
2613"#;
2614 let config = Config::from_yaml(yaml).unwrap();
2615 assert_eq!(config.get("app.timeout").unwrap().as_str(), Some("30"));
2616 }
2617
2618 #[test]
2619 fn test_ref_explicit_syntax() {
2620 let yaml = r#"
2621database:
2622 host: localhost
2623app:
2624 db_host: ${ref:database.host}
2625"#;
2626 let config = Config::from_yaml(yaml).unwrap();
2627 assert_eq!(
2628 config.get("app.db_host").unwrap().as_str(),
2629 Some("localhost")
2630 );
2631 }
2632
2633 #[test]
2634 fn test_ref_explicit_syntax_with_default() {
2635 let yaml = r#"
2636app:
2637 timeout: ${ref:custom.timeout,default=30}
2638"#;
2639 let config = Config::from_yaml(yaml).unwrap();
2640 assert_eq!(config.get("app.timeout").unwrap().as_str(), Some("30"));
2641 }
2642
2643 #[test]
2644 fn test_ref_sensitive_flag_on_default() {
2645 let yaml = r#"
2646app:
2647 password: ${custom.password,default=dev-password,sensitive=true}
2648"#;
2649 let config = Config::from_yaml(yaml).unwrap();
2650 let dumped_yaml = config.to_yaml(true, true).unwrap();
2651 assert!(dumped_yaml.contains("[REDACTED]"));
2653 assert!(!dumped_yaml.contains("dev-password"));
2654 }
2655
2656 #[test]
2657 fn test_ref_sensitive_flag_inheritance() {
2658 use crate::resolver::{FnResolver, ResolverRegistry};
2659 use std::sync::Arc;
2660
2661 let mut registry = ResolverRegistry::new();
2662 registry.register(Arc::new(FnResolver::new(
2663 "sensitive_test",
2664 |_args, _kwargs, _ctx| {
2665 Ok(ResolvedValue::sensitive(Value::String(
2666 "secret-value".to_string(),
2667 )))
2668 },
2669 )));
2670
2671 let yaml = r#"
2672secret:
2673 password: ${sensitive_test:foo}
2674app:
2675 # Reference to sensitive value should inherit sensitivity
2676 db_password: ${secret.password}
2677"#;
2678 let value: Value = serde_yaml::from_str(yaml).unwrap();
2679 let config = Config::with_resolvers(value, registry);
2680 let dumped_yaml = config.to_yaml(true, true).unwrap();
2681 assert!(dumped_yaml.contains("[REDACTED]"));
2683 assert!(!dumped_yaml.contains("secret-value"));
2684 }
2685
2686 #[test]
2687 fn test_ref_sensitive_cannot_be_unmarked() {
2688 use crate::resolver::{FnResolver, ResolverRegistry};
2689 use std::sync::Arc;
2690
2691 let mut registry = ResolverRegistry::new();
2692 registry.register(Arc::new(FnResolver::new(
2693 "sensitive_test",
2694 |_args, _kwargs, _ctx| {
2695 Ok(ResolvedValue::sensitive(Value::String(
2696 "secret-value".to_string(),
2697 )))
2698 },
2699 )));
2700
2701 let yaml = r#"
2702secret:
2703 password: ${sensitive_test:foo}
2704app:
2705 # Even with sensitive=false, should stay sensitive
2706 db_password: ${secret.password,sensitive=false}
2707"#;
2708 let value: Value = serde_yaml::from_str(yaml).unwrap();
2709 let config = Config::with_resolvers(value, registry);
2710 let dumped_yaml = config.to_yaml(true, true).unwrap();
2711 assert!(dumped_yaml.contains("[REDACTED]"));
2713 assert!(!dumped_yaml.contains("secret-value"));
2714 }
2715
2716 #[test]
2717 fn test_max_resolution_depth() {
2718 let mut yaml_parts = vec!["root: ${level1}".to_string()];
2721 for i in 1..95 {
2722 yaml_parts.push(format!("level{}: ${{level{}}}", i, i + 1));
2723 }
2724 yaml_parts.push("level95: final_value".to_string());
2725
2726 let yaml = yaml_parts.join("\n");
2727 let config = Config::from_yaml(&yaml).unwrap();
2728
2729 assert_eq!(config.get("root").unwrap().as_str(), Some("final_value"));
2731 }
2732}