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
15fn is_glob_pattern(path: &str) -> bool {
17 path.contains('*') || path.contains('?') || path.contains('[')
18}
19
20fn 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#[derive(Debug, Clone, Default)]
32pub struct ConfigOptions {
33 pub base_path: Option<PathBuf>,
35 pub allow_http: bool,
37 pub http_allowlist: Vec<String>,
39 pub file_roots: Vec<PathBuf>,
41
42 pub http_proxy: Option<String>,
45 pub http_proxy_from_env: bool,
47 pub http_ca_bundle: Option<PathBuf>,
49 pub http_extra_ca_bundle: Option<PathBuf>,
51 pub http_client_cert: Option<PathBuf>,
53 pub http_client_key: Option<PathBuf>,
55 pub http_client_key_password: Option<String>,
57 pub http_insecure: bool,
59}
60
61pub struct Config {
66 raw: Arc<Value>,
68 cache: Arc<RwLock<HashMap<String, ResolvedValue>>>,
70 source_map: Arc<HashMap<String, String>>,
72 resolvers: Arc<ResolverRegistry>,
74 options: ConfigOptions,
76 schema: Option<Arc<crate::schema::Schema>>,
78}
79
80fn clone_global_registry() -> Arc<ResolverRegistry> {
82 let global = global_registry()
83 .read()
84 .expect("Global registry lock poisoned");
85 Arc::new(global.clone())
86}
87
88impl Config {
89 pub fn new(value: Value) -> Self {
93 Self {
94 raw: Arc::new(value),
95 cache: Arc::new(RwLock::new(HashMap::new())),
96 source_map: Arc::new(HashMap::new()),
97 resolvers: clone_global_registry(),
98 options: ConfigOptions::default(),
99 schema: None,
100 }
101 }
102
103 pub fn with_options(value: Value, options: ConfigOptions) -> Self {
107 Self {
108 raw: Arc::new(value),
109 cache: Arc::new(RwLock::new(HashMap::new())),
110 source_map: Arc::new(HashMap::new()),
111 resolvers: clone_global_registry(),
112 options,
113 schema: None,
114 }
115 }
116
117 fn with_options_and_sources(
119 value: Value,
120 options: ConfigOptions,
121 source_map: HashMap<String, String>,
122 ) -> Self {
123 Self {
124 raw: Arc::new(value),
125 cache: Arc::new(RwLock::new(HashMap::new())),
126 source_map: Arc::new(source_map),
127 resolvers: clone_global_registry(),
128 options,
129 schema: None,
130 }
131 }
132
133 pub fn with_resolvers(value: Value, resolvers: ResolverRegistry) -> Self {
135 Self {
136 raw: Arc::new(value),
137 cache: Arc::new(RwLock::new(HashMap::new())),
138 source_map: Arc::new(HashMap::new()),
139 resolvers: Arc::new(resolvers),
140 options: ConfigOptions::default(),
141 schema: None,
142 }
143 }
144
145 pub fn from_yaml(yaml: &str) -> Result<Self> {
147 let value: Value = serde_yaml::from_str(yaml).map_err(|e| Error::parse(e.to_string()))?;
148 Ok(Self::new(value))
149 }
150
151 pub fn from_yaml_with_options(yaml: &str, options: ConfigOptions) -> Result<Self> {
153 let value: Value = serde_yaml::from_str(yaml).map_err(|e| Error::parse(e.to_string()))?;
154 Ok(Self::with_options(value, options))
155 }
156
157 pub fn load(path: impl AsRef<Path>) -> Result<Self> {
173 let path_str = path.as_ref().to_string_lossy();
174
175 if is_glob_pattern(&path_str) {
176 let paths = expand_glob(&path_str)?;
177 if paths.is_empty() {
178 return Err(Error::file_not_found(
179 format!("No files matched glob pattern '{}'", path_str),
180 None,
181 ));
182 }
183 let mut config = Self::from_yaml_file(&paths[0])?;
185 for p in &paths[1..] {
186 let other = Self::from_yaml_file(p)?;
187 config.merge(other);
188 }
189 Ok(config)
190 } else {
191 Self::from_yaml_file(path)
192 }
193 }
194
195 pub fn load_with_options(path: impl AsRef<Path>, options: ConfigOptions) -> Result<Self> {
211 let path_str = path.as_ref().to_string_lossy();
212
213 if is_glob_pattern(&path_str) {
214 let paths = expand_glob(&path_str)?;
215 if paths.is_empty() {
216 return Err(Error::file_not_found(
217 format!("No files matched glob pattern '{}'", path_str),
218 None,
219 ));
220 }
221 let mut config = Self::from_yaml_file_with_options(&paths[0], options.clone())?;
223 for p in &paths[1..] {
224 let other = Self::from_yaml_file_with_options(p, options.clone())?;
225 config.merge(other);
226 }
227 Ok(config)
228 } else {
229 Self::from_yaml_file_with_options(path, options)
230 }
231 }
232
233 pub fn required(path: impl AsRef<Path>) -> Result<Self> {
237 Self::load(path)
238 }
239
240 pub fn required_with_options(path: impl AsRef<Path>, options: ConfigOptions) -> Result<Self> {
242 Self::load_with_options(path, options)
243 }
244
245 pub fn optional(path: impl AsRef<Path>) -> Result<Self> {
264 let path_str = path.as_ref().to_string_lossy();
265
266 if is_glob_pattern(&path_str) {
267 let paths = expand_glob(&path_str)?;
268 if paths.is_empty() {
269 return Ok(Self::new(Value::Mapping(indexmap::IndexMap::new())));
271 }
272 let mut config = Self::from_yaml_file(&paths[0])?;
274 for p in &paths[1..] {
275 let other = Self::from_yaml_file(p)?;
276 config.merge(other);
277 }
278 Ok(config)
279 } else {
280 Self::optional_single_file(path)
281 }
282 }
283
284 fn optional_single_file(path: impl AsRef<Path>) -> Result<Self> {
286 let path = path.as_ref();
287 match std::fs::read_to_string(path) {
288 Ok(content) => {
289 let value: Value =
290 serde_yaml::from_str(&content).map_err(|e| Error::parse(e.to_string()))?;
291
292 let filename = path
293 .file_name()
294 .and_then(|n| n.to_str())
295 .unwrap_or_default()
296 .to_string();
297 let mut source_map = HashMap::new();
298 value.collect_leaf_paths("", &filename, &mut source_map);
299
300 let mut options = ConfigOptions::default();
301 options.base_path = path.parent().map(|p| p.to_path_buf());
302
303 Ok(Self::with_options_and_sources(value, options, source_map))
304 }
305 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
306 Ok(Self::new(Value::Mapping(indexmap::IndexMap::new())))
308 }
309 Err(e) => Err(Error::parse(format!(
310 "Failed to read file '{}': {}",
311 path.display(),
312 e
313 ))),
314 }
315 }
316
317 fn from_yaml_file(path: impl AsRef<Path>) -> Result<Self> {
319 Self::from_yaml_file_with_options(path, ConfigOptions::default())
320 }
321
322 fn from_yaml_file_with_options(
324 path: impl AsRef<Path>,
325 mut options: ConfigOptions,
326 ) -> Result<Self> {
327 let path = path.as_ref();
328 let content = std::fs::read_to_string(path).map_err(|e| {
329 if e.kind() == std::io::ErrorKind::NotFound {
330 Error::file_not_found(path.display().to_string(), None)
331 } else {
332 Error::parse(format!("Failed to read file '{}': {}", path.display(), e))
333 }
334 })?;
335
336 let value: Value =
337 serde_yaml::from_str(&content).map_err(|e| Error::parse(e.to_string()))?;
338
339 let filename = path
341 .file_name()
342 .and_then(|n| n.to_str())
343 .unwrap_or_default()
344 .to_string();
345 let mut source_map = HashMap::new();
346 value.collect_leaf_paths("", &filename, &mut source_map);
347
348 if options.base_path.is_none() {
350 options.base_path = path.parent().map(|p| p.to_path_buf());
351 }
352
353 Ok(Self::with_options_and_sources(value, options, source_map))
354 }
355
356 pub fn merge(&mut self, other: Config) {
360 if let Some(raw) = Arc::get_mut(&mut self.raw) {
362 raw.merge((*other.raw).clone());
363 } else {
364 let mut new_raw = (*self.raw).clone();
366 new_raw.merge((*other.raw).clone());
367 self.raw = Arc::new(new_raw);
368 }
369 self.clear_cache();
371 }
372
373 pub fn from_json(json: &str) -> Result<Self> {
375 let value: Value = serde_json::from_str(json).map_err(|e| Error::parse(e.to_string()))?;
376 Ok(Self::new(value))
377 }
378
379 pub fn set_schema(&mut self, schema: crate::schema::Schema) {
387 self.schema = Some(Arc::new(schema));
388 self.clear_cache();
389 }
390
391 pub fn get_schema(&self) -> Option<&crate::schema::Schema> {
393 self.schema.as_ref().map(|s| s.as_ref())
394 }
395
396 pub fn get_raw(&self, path: &str) -> Result<&Value> {
398 self.raw.get_path(path)
399 }
400
401 pub fn get(&self, path: &str) -> Result<Value> {
409 {
411 let cache = self.cache.read().unwrap();
412 if let Some(cached) = cache.get(path) {
413 return Ok(cached.value.clone());
414 }
415 }
416
417 let raw_result = self.raw.get_path(path);
419
420 match raw_result {
421 Ok(raw_value) => {
422 let mut resolution_stack = Vec::new();
424 let resolved = self.resolve_value(raw_value, path, &mut resolution_stack)?;
425
426 if resolved.value.is_null() {
428 if let Some(schema) = &self.schema {
429 if !schema.allows_null(path) {
431 if let Some(default_value) = schema.get_default(path) {
432 let resolved_default = ResolvedValue::new(default_value.clone());
433 {
435 let mut cache = self.cache.write().unwrap();
436 cache.insert(path.to_string(), resolved_default);
437 }
438 return Ok(default_value);
439 }
440 }
441 }
442 }
443
444 {
446 let mut cache = self.cache.write().unwrap();
447 cache.insert(path.to_string(), resolved.clone());
448 }
449
450 Ok(resolved.value)
451 }
452 Err(e) if matches!(e.kind, crate::error::ErrorKind::PathNotFound) => {
453 if let Some(schema) = &self.schema {
455 if let Some(default_value) = schema.get_default(path) {
456 let resolved_default = ResolvedValue::new(default_value.clone());
457 {
459 let mut cache = self.cache.write().unwrap();
460 cache.insert(path.to_string(), resolved_default);
461 }
462 return Ok(default_value);
463 }
464 }
465 Err(e)
467 }
468 Err(e) => Err(e),
469 }
470 }
471
472 pub fn get_string(&self, path: &str) -> Result<String> {
474 let value = self.get(path)?;
475 match value {
476 Value::String(s) => Ok(s),
477 Value::Integer(i) => Ok(i.to_string()),
478 Value::Float(f) => Ok(f.to_string()),
479 Value::Bool(b) => Ok(b.to_string()),
480 Value::Null => Ok("null".to_string()),
481 _ => Err(Error::type_coercion(path, "string", value.type_name())),
482 }
483 }
484
485 pub fn get_i64(&self, path: &str) -> Result<i64> {
487 let value = self.get(path)?;
488 match value {
489 Value::Integer(i) => Ok(i),
490 Value::String(s) => s
491 .parse()
492 .map_err(|_| Error::type_coercion(path, "integer", format!("string (\"{}\")", s))),
493 _ => Err(Error::type_coercion(path, "integer", value.type_name())),
494 }
495 }
496
497 pub fn get_f64(&self, path: &str) -> Result<f64> {
499 let value = self.get(path)?;
500 match value {
501 Value::Float(f) => Ok(f),
502 Value::Integer(i) => Ok(i as f64),
503 Value::String(s) => s
504 .parse()
505 .map_err(|_| Error::type_coercion(path, "float", format!("string (\"{}\")", s))),
506 _ => Err(Error::type_coercion(path, "float", value.type_name())),
507 }
508 }
509
510 pub fn get_bool(&self, path: &str) -> Result<bool> {
512 let value = self.get(path)?;
513 match value {
514 Value::Bool(b) => Ok(b),
515 Value::String(s) => {
516 match s.to_lowercase().as_str() {
518 "true" => Ok(true),
519 "false" => Ok(false),
520 _ => Err(Error::type_coercion(
521 path,
522 "boolean",
523 format!("string (\"{}\") - only \"true\" or \"false\" allowed", s),
524 )),
525 }
526 }
527 _ => Err(Error::type_coercion(path, "boolean", value.type_name())),
528 }
529 }
530
531 pub fn resolve_all(&self) -> Result<()> {
533 let mut resolution_stack = Vec::new();
534 self.resolve_value_recursive(&self.raw, "", &mut resolution_stack)?;
535 Ok(())
536 }
537
538 pub fn to_value(&self, resolve: bool, redact: bool) -> Result<Value> {
556 if !resolve {
557 return Ok((*self.raw).clone());
558 }
559 let mut resolution_stack = Vec::new();
560 if redact {
561 self.resolve_value_to_value_redacted(&self.raw, "", &mut resolution_stack)
562 } else {
563 self.resolve_value_to_value(&self.raw, "", &mut resolution_stack)
564 }
565 }
566
567 pub fn to_yaml(&self, resolve: bool, redact: bool) -> Result<String> {
582 let value = self.to_value(resolve, redact)?;
583 serde_yaml::to_string(&value).map_err(|e| Error::parse(e.to_string()))
584 }
585
586 pub fn to_json(&self, resolve: bool, redact: bool) -> Result<String> {
601 let value = self.to_value(resolve, redact)?;
602 serde_json::to_string_pretty(&value).map_err(|e| Error::parse(e.to_string()))
603 }
604
605 pub fn clear_cache(&self) {
607 let mut cache = self.cache.write().unwrap();
608 cache.clear();
609 }
610
611 pub fn get_source(&self, path: &str) -> Option<&str> {
616 self.source_map.get(path).map(|s| s.as_str())
617 }
618
619 pub fn dump_sources(&self) -> &HashMap<String, String> {
624 &self.source_map
625 }
626
627 pub fn register_resolver(&mut self, resolver: Arc<dyn crate::resolver::Resolver>) {
629 if let Some(registry) = Arc::get_mut(&mut self.resolvers) {
632 registry.register(resolver);
633 }
634 }
635
636 pub fn validate_raw(&self, schema: Option<&crate::schema::Schema>) -> Result<()> {
646 let schema = self.resolve_schema(schema)?;
647 schema.validate(&self.raw)
648 }
649
650 pub fn validate(&self, schema: Option<&crate::schema::Schema>) -> Result<()> {
659 let schema = self.resolve_schema(schema)?;
660 let resolved = self.to_value(true, false)?;
661 schema.validate(&resolved)
662 }
663
664 pub fn validate_collect(
669 &self,
670 schema: Option<&crate::schema::Schema>,
671 ) -> Vec<crate::schema::ValidationError> {
672 let schema = match self.resolve_schema(schema) {
673 Ok(s) => s,
674 Err(e) => {
675 return vec![crate::schema::ValidationError {
676 path: String::new(),
677 message: e.to_string(),
678 }]
679 }
680 };
681 match self.to_value(true, false) {
682 Ok(resolved) => schema.validate_collect(&resolved),
683 Err(e) => vec![crate::schema::ValidationError {
684 path: String::new(),
685 message: e.to_string(),
686 }],
687 }
688 }
689
690 fn resolve_schema<'a>(
692 &'a self,
693 schema: Option<&'a crate::schema::Schema>,
694 ) -> Result<&'a crate::schema::Schema> {
695 schema
696 .or_else(|| self.schema.as_ref().map(|s| s.as_ref()))
697 .ok_or_else(|| Error::validation("<root>", "No schema provided and none attached"))
698 }
699
700 fn resolve_value(
702 &self,
703 value: &Value,
704 path: &str,
705 resolution_stack: &mut Vec<String>,
706 ) -> Result<ResolvedValue> {
707 match value {
708 Value::String(s) => {
709 if interpolation::needs_processing(s) {
711 let parsed = interpolation::parse(s)?;
712 self.resolve_interpolation(&parsed, path, resolution_stack)
713 } else {
714 Ok(ResolvedValue::new(value.clone()))
715 }
716 }
717 _ => Ok(ResolvedValue::new(value.clone())),
718 }
719 }
720
721 fn resolve_interpolation(
723 &self,
724 interp: &Interpolation,
725 path: &str,
726 resolution_stack: &mut Vec<String>,
727 ) -> Result<ResolvedValue> {
728 match interp {
729 Interpolation::Literal(s) => Ok(ResolvedValue::new(Value::String(s.clone()))),
730
731 Interpolation::Resolver { name, args, kwargs } => {
732 let mut ctx = ResolverContext::new(path);
734 ctx.config_root = Some(Arc::clone(&self.raw));
735 if let Some(base) = &self.options.base_path {
736 ctx.base_path = Some(base.clone());
737 }
738 ctx.allow_http = self.options.allow_http;
740 ctx.http_allowlist = self.options.http_allowlist.clone();
741 ctx.http_proxy = self.options.http_proxy.clone();
743 ctx.http_proxy_from_env = self.options.http_proxy_from_env;
744 ctx.http_ca_bundle = self.options.http_ca_bundle.clone();
745 ctx.http_extra_ca_bundle = self.options.http_extra_ca_bundle.clone();
746 ctx.http_client_cert = self.options.http_client_cert.clone();
747 ctx.http_client_key = self.options.http_client_key.clone();
748 ctx.http_client_key_password = self.options.http_client_key_password.clone();
749 ctx.http_insecure = self.options.http_insecure;
750
751 let resolved_args: Vec<String> = args
753 .iter()
754 .map(|arg| self.resolve_arg(arg, path, resolution_stack))
755 .collect::<Result<Vec<_>>>()?;
756
757 let default_arg = kwargs.get("default");
760 let resolved_kwargs: HashMap<String, String> = kwargs
761 .iter()
762 .filter(|(k, _)| *k != "default") .map(|(k, v)| Ok((k.clone(), self.resolve_arg(v, path, resolution_stack)?)))
764 .collect::<Result<HashMap<_, _>>>()?;
765
766 let result = self
768 .resolvers
769 .resolve(name, &resolved_args, &resolved_kwargs, &ctx);
770
771 match result {
773 Ok(value) => Ok(value),
774 Err(e) => {
775 let should_use_default = matches!(
777 &e.kind,
778 crate::error::ErrorKind::Resolver(
779 crate::error::ResolverErrorKind::NotFound { .. }
780 ) | crate::error::ErrorKind::Resolver(
781 crate::error::ResolverErrorKind::EnvNotFound { .. }
782 ) | crate::error::ErrorKind::Resolver(
783 crate::error::ResolverErrorKind::FileNotFound { .. }
784 )
785 );
786
787 if should_use_default {
788 if let Some(default_arg) = default_arg {
789 let default_str =
791 self.resolve_arg(default_arg, path, resolution_stack)?;
792
793 let is_sensitive = resolved_kwargs
795 .get("sensitive")
796 .map(|v| v.eq_ignore_ascii_case("true"))
797 .unwrap_or(false);
798
799 return if is_sensitive {
800 Ok(ResolvedValue::sensitive(Value::String(default_str)))
801 } else {
802 Ok(ResolvedValue::new(Value::String(default_str)))
803 };
804 }
805 }
806 Err(e)
807 }
808 }
809 }
810
811 Interpolation::SelfRef {
812 path: ref_path,
813 relative,
814 } => {
815 let full_path = if *relative {
816 self.resolve_relative_path(path, ref_path)
817 } else {
818 ref_path.clone()
819 };
820
821 if resolution_stack.contains(&full_path) {
823 let mut chain = resolution_stack.clone();
825 chain.push(full_path.clone());
826 return Err(Error::circular_reference(path, chain));
827 }
828
829 let ref_value = self
831 .raw
832 .get_path(&full_path)
833 .map_err(|_| Error::ref_not_found(&full_path, Some(path.to_string())))?;
834
835 resolution_stack.push(full_path.clone());
837
838 let result = self.resolve_value(ref_value, &full_path, resolution_stack);
840
841 resolution_stack.pop();
843
844 result
845 }
846
847 Interpolation::Concat(parts) => {
848 let mut result = String::new();
849 let mut any_sensitive = false;
850
851 for part in parts {
852 let resolved = self.resolve_interpolation(part, path, resolution_stack)?;
853 any_sensitive = any_sensitive || resolved.sensitive;
854
855 match resolved.value {
856 Value::String(s) => result.push_str(&s),
857 other => result.push_str(&other.to_string()),
858 }
859 }
860
861 if any_sensitive {
862 Ok(ResolvedValue::sensitive(Value::String(result)))
863 } else {
864 Ok(ResolvedValue::new(Value::String(result)))
865 }
866 }
867 }
868 }
869
870 fn resolve_arg(
872 &self,
873 arg: &InterpolationArg,
874 path: &str,
875 resolution_stack: &mut Vec<String>,
876 ) -> Result<String> {
877 match arg {
878 InterpolationArg::Literal(s) => Ok(s.clone()),
879 InterpolationArg::Nested(interp) => {
880 let resolved = self.resolve_interpolation(interp, path, resolution_stack)?;
881 match resolved.value {
882 Value::String(s) => Ok(s),
883 other => Ok(other.to_string()),
884 }
885 }
886 }
887 }
888
889 fn resolve_relative_path(&self, current_path: &str, ref_path: &str) -> String {
891 let mut ref_chars = ref_path.chars().peekable();
892 let mut levels_up = 0;
893
894 while ref_chars.peek() == Some(&'.') {
896 ref_chars.next();
897 levels_up += 1;
898 }
899
900 let remaining: String = ref_chars.collect();
902
903 if levels_up == 0 {
904 return ref_path.to_string();
906 }
907
908 let mut segments: Vec<&str> = current_path.split('.').collect();
910
911 for _ in 0..levels_up {
915 segments.pop();
916 }
917
918 if remaining.is_empty() {
920 segments.join(".")
921 } else if segments.is_empty() {
922 remaining
923 } else {
924 format!("{}.{}", segments.join("."), remaining)
925 }
926 }
927
928 fn resolve_value_recursive(
930 &self,
931 value: &Value,
932 path: &str,
933 resolution_stack: &mut Vec<String>,
934 ) -> Result<ResolvedValue> {
935 match value {
936 Value::String(s) => {
937 if interpolation::needs_processing(s) {
938 let parsed = interpolation::parse(s)?;
939 let resolved = self.resolve_interpolation(&parsed, path, resolution_stack)?;
940
941 let mut cache = self.cache.write().unwrap();
943 cache.insert(path.to_string(), resolved.clone());
944
945 Ok(resolved)
946 } else {
947 Ok(ResolvedValue::new(value.clone()))
948 }
949 }
950 Value::Sequence(seq) => {
951 for (i, item) in seq.iter().enumerate() {
952 let item_path = format!("{}[{}]", path, i);
953 self.resolve_value_recursive(item, &item_path, resolution_stack)?;
954 }
955 Ok(ResolvedValue::new(value.clone()))
956 }
957 Value::Mapping(map) => {
958 for (key, val) in map {
959 let key_path = if path.is_empty() {
960 key.clone()
961 } else {
962 format!("{}.{}", path, key)
963 };
964 self.resolve_value_recursive(val, &key_path, resolution_stack)?;
965 }
966 Ok(ResolvedValue::new(value.clone()))
967 }
968 _ => Ok(ResolvedValue::new(value.clone())),
969 }
970 }
971
972 fn resolve_value_to_value(
974 &self,
975 value: &Value,
976 path: &str,
977 resolution_stack: &mut Vec<String>,
978 ) -> Result<Value> {
979 match value {
980 Value::String(s) => {
981 if interpolation::needs_processing(s) {
982 let parsed = interpolation::parse(s)?;
983 let resolved = self.resolve_interpolation(&parsed, path, resolution_stack)?;
984 Ok(resolved.value)
985 } else {
986 Ok(value.clone())
987 }
988 }
989 Value::Sequence(seq) => {
990 let mut resolved_seq = Vec::new();
991 for (i, item) in seq.iter().enumerate() {
992 let item_path = format!("{}[{}]", path, i);
993 resolved_seq.push(self.resolve_value_to_value(
994 item,
995 &item_path,
996 resolution_stack,
997 )?);
998 }
999 Ok(Value::Sequence(resolved_seq))
1000 }
1001 Value::Mapping(map) => {
1002 let mut resolved = indexmap::IndexMap::new();
1003 for (key, val) in map {
1004 let key_path = if path.is_empty() {
1005 key.clone()
1006 } else {
1007 format!("{}.{}", path, key)
1008 };
1009 resolved.insert(
1010 key.clone(),
1011 self.resolve_value_to_value(val, &key_path, resolution_stack)?,
1012 );
1013 }
1014 Ok(Value::Mapping(resolved))
1015 }
1016 _ => Ok(value.clone()),
1017 }
1018 }
1019
1020 fn resolve_value_to_value_redacted(
1022 &self,
1023 value: &Value,
1024 path: &str,
1025 resolution_stack: &mut Vec<String>,
1026 ) -> Result<Value> {
1027 const REDACTED: &str = "[REDACTED]";
1028
1029 match value {
1030 Value::String(s) => {
1031 if interpolation::needs_processing(s) {
1032 let parsed = interpolation::parse(s)?;
1033 let resolved = self.resolve_interpolation(&parsed, path, resolution_stack)?;
1034 if resolved.sensitive {
1035 Ok(Value::String(REDACTED.to_string()))
1036 } else {
1037 Ok(resolved.value)
1038 }
1039 } else {
1040 Ok(value.clone())
1041 }
1042 }
1043 Value::Sequence(seq) => {
1044 let mut resolved_seq = Vec::new();
1045 for (i, item) in seq.iter().enumerate() {
1046 let item_path = format!("{}[{}]", path, i);
1047 resolved_seq.push(self.resolve_value_to_value_redacted(
1048 item,
1049 &item_path,
1050 resolution_stack,
1051 )?);
1052 }
1053 Ok(Value::Sequence(resolved_seq))
1054 }
1055 Value::Mapping(map) => {
1056 let mut resolved = indexmap::IndexMap::new();
1057 for (key, val) in map {
1058 let key_path = if path.is_empty() {
1059 key.clone()
1060 } else {
1061 format!("{}.{}", path, key)
1062 };
1063 resolved.insert(
1064 key.clone(),
1065 self.resolve_value_to_value_redacted(val, &key_path, resolution_stack)?,
1066 );
1067 }
1068 Ok(Value::Mapping(resolved))
1069 }
1070 _ => Ok(value.clone()),
1071 }
1072 }
1073}
1074
1075impl Clone for Config {
1076 fn clone(&self) -> Self {
1077 Self {
1078 raw: Arc::clone(&self.raw),
1079 cache: Arc::new(RwLock::new(HashMap::new())), source_map: Arc::clone(&self.source_map),
1081 resolvers: Arc::clone(&self.resolvers),
1082 options: self.options.clone(),
1083 schema: self.schema.clone(),
1084 }
1085 }
1086}
1087
1088#[cfg(test)]
1089mod tests {
1090 use super::*;
1091
1092 #[test]
1093 fn test_load_yaml() {
1094 let yaml = r#"
1095database:
1096 host: localhost
1097 port: 5432
1098"#;
1099 let config = Config::from_yaml(yaml).unwrap();
1100
1101 assert_eq!(
1102 config.get("database.host").unwrap().as_str(),
1103 Some("localhost")
1104 );
1105 assert_eq!(config.get("database.port").unwrap().as_i64(), Some(5432));
1106 }
1107
1108 #[test]
1109 fn test_env_resolver() {
1110 std::env::set_var("HOLOCONF_TEST_HOST", "prod-server");
1111
1112 let yaml = r#"
1113server:
1114 host: ${env:HOLOCONF_TEST_HOST}
1115"#;
1116 let config = Config::from_yaml(yaml).unwrap();
1117
1118 assert_eq!(
1119 config.get("server.host").unwrap().as_str(),
1120 Some("prod-server")
1121 );
1122
1123 std::env::remove_var("HOLOCONF_TEST_HOST");
1124 }
1125
1126 #[test]
1127 fn test_env_resolver_with_default() {
1128 std::env::remove_var("HOLOCONF_MISSING_VAR");
1129
1130 let yaml = r#"
1131server:
1132 host: ${env:HOLOCONF_MISSING_VAR,default=default-host}
1133"#;
1134 let config = Config::from_yaml(yaml).unwrap();
1135
1136 assert_eq!(
1137 config.get("server.host").unwrap().as_str(),
1138 Some("default-host")
1139 );
1140 }
1141
1142 #[test]
1143 fn test_self_reference() {
1144 let yaml = r#"
1145defaults:
1146 host: localhost
1147database:
1148 host: ${defaults.host}
1149"#;
1150 let config = Config::from_yaml(yaml).unwrap();
1151
1152 assert_eq!(
1153 config.get("database.host").unwrap().as_str(),
1154 Some("localhost")
1155 );
1156 }
1157
1158 #[test]
1159 fn test_string_concatenation() {
1160 std::env::set_var("HOLOCONF_PREFIX", "prod");
1161
1162 let yaml = r#"
1163bucket: myapp-${env:HOLOCONF_PREFIX}-data
1164"#;
1165 let config = Config::from_yaml(yaml).unwrap();
1166
1167 assert_eq!(
1168 config.get("bucket").unwrap().as_str(),
1169 Some("myapp-prod-data")
1170 );
1171
1172 std::env::remove_var("HOLOCONF_PREFIX");
1173 }
1174
1175 #[test]
1176 fn test_escaped_interpolation() {
1177 let yaml = r#"
1180literal: '\${not_resolved}'
1181"#;
1182 let config = Config::from_yaml(yaml).unwrap();
1183
1184 assert_eq!(
1186 config.get("literal").unwrap().as_str(),
1187 Some("${not_resolved}")
1188 );
1189 }
1190
1191 #[test]
1192 fn test_type_coercion_string_to_int() {
1193 std::env::set_var("HOLOCONF_PORT", "8080");
1194
1195 let yaml = r#"
1196port: ${env:HOLOCONF_PORT}
1197"#;
1198 let config = Config::from_yaml(yaml).unwrap();
1199
1200 assert_eq!(config.get_i64("port").unwrap(), 8080);
1202
1203 std::env::remove_var("HOLOCONF_PORT");
1204 }
1205
1206 #[test]
1207 fn test_strict_boolean_coercion() {
1208 std::env::set_var("HOLOCONF_ENABLED", "true");
1209 std::env::set_var("HOLOCONF_INVALID", "1");
1210
1211 let yaml = r#"
1212enabled: ${env:HOLOCONF_ENABLED}
1213invalid: ${env:HOLOCONF_INVALID}
1214"#;
1215 let config = Config::from_yaml(yaml).unwrap();
1216
1217 assert!(config.get_bool("enabled").unwrap());
1219
1220 assert!(config.get_bool("invalid").is_err());
1222
1223 std::env::remove_var("HOLOCONF_ENABLED");
1224 std::env::remove_var("HOLOCONF_INVALID");
1225 }
1226
1227 #[test]
1228 fn test_boolean_coercion_case_insensitive() {
1229 let yaml = r#"
1231lower_true: "true"
1232upper_true: "TRUE"
1233mixed_true: "True"
1234lower_false: "false"
1235upper_false: "FALSE"
1236mixed_false: "False"
1237"#;
1238 let config = Config::from_yaml(yaml).unwrap();
1239
1240 assert!(config.get_bool("lower_true").unwrap());
1242 assert!(config.get_bool("upper_true").unwrap());
1243 assert!(config.get_bool("mixed_true").unwrap());
1244
1245 assert!(!config.get_bool("lower_false").unwrap());
1247 assert!(!config.get_bool("upper_false").unwrap());
1248 assert!(!config.get_bool("mixed_false").unwrap());
1249 }
1250
1251 #[test]
1252 fn test_boolean_coercion_rejects_invalid() {
1253 let yaml = r#"
1255yes_value: "yes"
1256no_value: "no"
1257one_value: "1"
1258zero_value: "0"
1259on_value: "on"
1260off_value: "off"
1261"#;
1262 let config = Config::from_yaml(yaml).unwrap();
1263
1264 assert!(config.get_bool("yes_value").is_err());
1266 assert!(config.get_bool("no_value").is_err());
1267 assert!(config.get_bool("one_value").is_err());
1268 assert!(config.get_bool("zero_value").is_err());
1269 assert!(config.get_bool("on_value").is_err());
1270 assert!(config.get_bool("off_value").is_err());
1271 }
1272
1273 #[test]
1274 fn test_caching() {
1275 std::env::set_var("HOLOCONF_CACHED", "initial");
1276
1277 let yaml = r#"
1278value: ${env:HOLOCONF_CACHED}
1279"#;
1280 let config = Config::from_yaml(yaml).unwrap();
1281
1282 assert_eq!(config.get("value").unwrap().as_str(), Some("initial"));
1284
1285 std::env::set_var("HOLOCONF_CACHED", "changed");
1287
1288 assert_eq!(config.get("value").unwrap().as_str(), Some("initial"));
1290
1291 config.clear_cache();
1293
1294 assert_eq!(config.get("value").unwrap().as_str(), Some("changed"));
1296
1297 std::env::remove_var("HOLOCONF_CACHED");
1298 }
1299
1300 #[test]
1301 fn test_path_not_found() {
1302 let yaml = r#"
1303database:
1304 host: localhost
1305"#;
1306 let config = Config::from_yaml(yaml).unwrap();
1307
1308 let result = config.get("database.nonexistent");
1309 assert!(result.is_err());
1310 }
1311
1312 #[test]
1313 fn test_to_yaml_resolved() {
1314 std::env::set_var("HOLOCONF_EXPORT_HOST", "exported-host");
1315
1316 let yaml = r#"
1317server:
1318 host: ${env:HOLOCONF_EXPORT_HOST}
1319 port: 8080
1320"#;
1321 let config = Config::from_yaml(yaml).unwrap();
1322
1323 let exported = config.to_yaml(true, false).unwrap();
1324 assert!(exported.contains("exported-host"));
1325 assert!(exported.contains("8080"));
1326
1327 std::env::remove_var("HOLOCONF_EXPORT_HOST");
1328 }
1329
1330 #[test]
1331 fn test_relative_path_sibling() {
1332 let yaml = r#"
1333database:
1334 host: localhost
1335 url: postgres://${.host}:5432/db
1336"#;
1337 let config = Config::from_yaml(yaml).unwrap();
1338
1339 assert_eq!(
1340 config.get("database.url").unwrap().as_str(),
1341 Some("postgres://localhost:5432/db")
1342 );
1343 }
1344
1345 #[test]
1346 fn test_array_access() {
1347 let yaml = r#"
1348servers:
1349 - host: server1
1350 - host: server2
1351primary: ${servers[0].host}
1352"#;
1353 let config = Config::from_yaml(yaml).unwrap();
1354
1355 assert_eq!(config.get("primary").unwrap().as_str(), Some("server1"));
1356 }
1357
1358 #[test]
1359 fn test_nested_interpolation() {
1360 std::env::set_var("HOLOCONF_DEFAULT_HOST", "fallback-host");
1361
1362 let yaml = r#"
1363host: ${env:UNDEFINED_HOST,default=${env:HOLOCONF_DEFAULT_HOST}}
1364"#;
1365 let config = Config::from_yaml(yaml).unwrap();
1366
1367 assert_eq!(config.get("host").unwrap().as_str(), Some("fallback-host"));
1368
1369 std::env::remove_var("HOLOCONF_DEFAULT_HOST");
1370 }
1371
1372 #[test]
1373 fn test_to_yaml_unresolved() {
1374 let yaml = r#"
1375server:
1376 host: ${env:MY_HOST}
1377 port: 8080
1378"#;
1379 let config = Config::from_yaml(yaml).unwrap();
1380
1381 let raw = config.to_yaml(false, false).unwrap();
1382 assert!(raw.contains("${env:MY_HOST}"));
1384 assert!(raw.contains("8080"));
1385 }
1386
1387 #[test]
1388 fn test_to_json_unresolved() {
1389 let yaml = r#"
1390database:
1391 url: ${env:DATABASE_URL}
1392"#;
1393 let config = Config::from_yaml(yaml).unwrap();
1394
1395 let raw = config.to_json(false, false).unwrap();
1396 assert!(raw.contains("${env:DATABASE_URL}"));
1398 }
1399
1400 #[test]
1401 fn test_to_value_unresolved() {
1402 let yaml = r#"
1403key: ${env:SOME_VAR}
1404"#;
1405 let config = Config::from_yaml(yaml).unwrap();
1406
1407 let raw = config.to_value(false, false).unwrap();
1408 assert_eq!(
1409 raw.get_path("key").unwrap().as_str(),
1410 Some("${env:SOME_VAR}")
1411 );
1412 }
1413
1414 #[test]
1415 fn test_to_yaml_redacted_no_sensitive() {
1416 std::env::set_var("HOLOCONF_NON_SENSITIVE", "public-value");
1417
1418 let yaml = r#"
1419value: ${env:HOLOCONF_NON_SENSITIVE}
1420"#;
1421 let config = Config::from_yaml(yaml).unwrap();
1422
1423 let output = config.to_yaml(true, true).unwrap();
1425 assert!(output.contains("public-value"));
1426
1427 std::env::remove_var("HOLOCONF_NON_SENSITIVE");
1428 }
1429
1430 #[test]
1431 fn test_circular_reference_direct() {
1432 let yaml = r#"
1434a: ${b}
1435b: ${a}
1436"#;
1437 let config = Config::from_yaml(yaml).unwrap();
1438
1439 let result = config.get("a");
1441 assert!(result.is_err());
1442 let err = result.unwrap_err();
1443 assert!(
1444 err.to_string().to_lowercase().contains("circular"),
1445 "Error should mention 'circular': {}",
1446 err
1447 );
1448 }
1449
1450 #[test]
1451 fn test_circular_reference_chain() {
1452 let yaml = r#"
1454first: ${second}
1455second: ${third}
1456third: ${first}
1457"#;
1458 let config = Config::from_yaml(yaml).unwrap();
1459
1460 let result = config.get("first");
1462 assert!(result.is_err());
1463 let err = result.unwrap_err();
1464 assert!(
1465 err.to_string().to_lowercase().contains("circular"),
1466 "Error should mention 'circular': {}",
1467 err
1468 );
1469 }
1470
1471 #[test]
1472 fn test_circular_reference_self() {
1473 let yaml = r#"
1475value: ${value}
1476"#;
1477 let config = Config::from_yaml(yaml).unwrap();
1478
1479 let result = config.get("value");
1480 assert!(result.is_err());
1481 let err = result.unwrap_err();
1482 assert!(
1483 err.to_string().to_lowercase().contains("circular"),
1484 "Error should mention 'circular': {}",
1485 err
1486 );
1487 }
1488
1489 #[test]
1490 fn test_circular_reference_nested() {
1491 let yaml = r#"
1493database:
1494 primary: ${database.secondary}
1495 secondary: ${database.primary}
1496"#;
1497 let config = Config::from_yaml(yaml).unwrap();
1498
1499 let result = config.get("database.primary");
1500 assert!(result.is_err());
1501 let err = result.unwrap_err();
1502 assert!(
1503 err.to_string().to_lowercase().contains("circular"),
1504 "Error should mention 'circular': {}",
1505 err
1506 );
1507 }
1508
1509 #[test]
1512 fn test_get_source_from_yaml_string() {
1513 let yaml = r#"
1515database:
1516 host: localhost
1517"#;
1518 let config = Config::from_yaml(yaml).unwrap();
1519
1520 assert!(config.get_source("database.host").is_none());
1522 assert!(config.dump_sources().is_empty());
1523 }
1524
1525 #[test]
1526 fn test_source_tracking_load_and_merge() {
1527 let temp_dir = std::env::temp_dir().join("holoconf_test_sources");
1529 std::fs::create_dir_all(&temp_dir).unwrap();
1530
1531 let base_path = temp_dir.join("base.yaml");
1532 let override_path = temp_dir.join("override.yaml");
1533
1534 std::fs::write(
1535 &base_path,
1536 r#"
1537database:
1538 host: localhost
1539 port: 5432
1540api:
1541 url: http://localhost
1542"#,
1543 )
1544 .unwrap();
1545
1546 std::fs::write(
1547 &override_path,
1548 r#"
1549database:
1550 host: prod-db.example.com
1551api:
1552 key: secret123
1553"#,
1554 )
1555 .unwrap();
1556
1557 let mut config = Config::load(&base_path).unwrap();
1559 let override_config = Config::load(&override_path).unwrap();
1560 config.merge(override_config);
1561
1562 assert_eq!(
1564 config.get("database.host").unwrap().as_str(),
1565 Some("prod-db.example.com")
1566 );
1567 assert_eq!(config.get("database.port").unwrap().as_i64(), Some(5432));
1568 assert_eq!(
1569 config.get("api.url").unwrap().as_str(),
1570 Some("http://localhost")
1571 );
1572 assert_eq!(config.get("api.key").unwrap().as_str(), Some("secret123"));
1573
1574 std::fs::remove_dir_all(&temp_dir).ok();
1576 }
1577
1578 #[test]
1579 fn test_source_tracking_single_file() {
1580 let temp_dir = std::env::temp_dir().join("holoconf_test_single");
1581 std::fs::create_dir_all(&temp_dir).unwrap();
1582
1583 let config_path = temp_dir.join("config.yaml");
1584 std::fs::write(
1585 &config_path,
1586 r#"
1587database:
1588 host: localhost
1589 port: 5432
1590"#,
1591 )
1592 .unwrap();
1593
1594 let config = Config::load(&config_path).unwrap();
1595
1596 assert_eq!(config.get_source("database.host"), Some("config.yaml"));
1598 assert_eq!(config.get_source("database.port"), Some("config.yaml"));
1599
1600 std::fs::remove_dir_all(&temp_dir).ok();
1602 }
1603
1604 #[test]
1605 fn test_null_removes_values_on_merge() {
1606 let temp_dir = std::env::temp_dir().join("holoconf_test_null");
1607 std::fs::create_dir_all(&temp_dir).unwrap();
1608
1609 let base_path = temp_dir.join("base.yaml");
1610 let override_path = temp_dir.join("override.yaml");
1611
1612 std::fs::write(
1613 &base_path,
1614 r#"
1615database:
1616 host: localhost
1617 port: 5432
1618 debug: true
1619"#,
1620 )
1621 .unwrap();
1622
1623 std::fs::write(
1624 &override_path,
1625 r#"
1626database:
1627 debug: null
1628"#,
1629 )
1630 .unwrap();
1631
1632 let mut config = Config::load(&base_path).unwrap();
1633 let override_config = Config::load(&override_path).unwrap();
1634 config.merge(override_config);
1635
1636 assert!(config.get("database.debug").is_err());
1638 assert_eq!(
1640 config.get("database.host").unwrap().as_str(),
1641 Some("localhost")
1642 );
1643 assert_eq!(config.get("database.port").unwrap().as_i64(), Some(5432));
1644
1645 std::fs::remove_dir_all(&temp_dir).ok();
1647 }
1648
1649 #[test]
1650 fn test_array_replacement_on_merge() {
1651 let temp_dir = std::env::temp_dir().join("holoconf_test_array");
1652 std::fs::create_dir_all(&temp_dir).unwrap();
1653
1654 let base_path = temp_dir.join("base.yaml");
1655 let override_path = temp_dir.join("override.yaml");
1656
1657 std::fs::write(
1658 &base_path,
1659 r#"
1660servers:
1661 - host: server1
1662 - host: server2
1663"#,
1664 )
1665 .unwrap();
1666
1667 std::fs::write(
1668 &override_path,
1669 r#"
1670servers:
1671 - host: prod-server
1672"#,
1673 )
1674 .unwrap();
1675
1676 let mut config = Config::load(&base_path).unwrap();
1677 let override_config = Config::load(&override_path).unwrap();
1678 config.merge(override_config);
1679
1680 assert_eq!(
1682 config.get("servers[0].host").unwrap().as_str(),
1683 Some("prod-server")
1684 );
1685 assert!(config.get("servers[1].host").is_err());
1687
1688 std::fs::remove_dir_all(&temp_dir).ok();
1690 }
1691
1692 #[test]
1695 fn test_optional_file_missing() {
1696 let temp_dir = std::env::temp_dir().join("holoconf_test_optional_missing");
1697 std::fs::create_dir_all(&temp_dir).unwrap();
1698
1699 let base_path = temp_dir.join("base.yaml");
1700 let optional_path = temp_dir.join("optional.yaml"); std::fs::write(
1703 &base_path,
1704 r#"
1705database:
1706 host: localhost
1707 port: 5432
1708"#,
1709 )
1710 .unwrap();
1711
1712 let mut config = Config::load(&base_path).unwrap();
1714 let optional_config = Config::optional(&optional_path).unwrap();
1715 config.merge(optional_config);
1716
1717 assert_eq!(
1719 config.get("database.host").unwrap().as_str(),
1720 Some("localhost")
1721 );
1722 assert_eq!(config.get("database.port").unwrap().as_i64(), Some(5432));
1723
1724 std::fs::remove_dir_all(&temp_dir).ok();
1726 }
1727
1728 #[test]
1729 fn test_optional_file_exists() {
1730 let temp_dir = std::env::temp_dir().join("holoconf_test_optional_exists");
1731 std::fs::create_dir_all(&temp_dir).unwrap();
1732
1733 let base_path = temp_dir.join("base.yaml");
1734 let optional_path = temp_dir.join("optional.yaml");
1735
1736 std::fs::write(
1737 &base_path,
1738 r#"
1739database:
1740 host: localhost
1741 port: 5432
1742"#,
1743 )
1744 .unwrap();
1745
1746 std::fs::write(
1747 &optional_path,
1748 r#"
1749database:
1750 host: prod-db
1751"#,
1752 )
1753 .unwrap();
1754
1755 let mut config = Config::load(&base_path).unwrap();
1757 let optional_config = Config::optional(&optional_path).unwrap();
1758 config.merge(optional_config);
1759
1760 assert_eq!(
1762 config.get("database.host").unwrap().as_str(),
1763 Some("prod-db")
1764 );
1765 assert_eq!(config.get("database.port").unwrap().as_i64(), Some(5432));
1766
1767 std::fs::remove_dir_all(&temp_dir).ok();
1769 }
1770
1771 #[test]
1772 fn test_required_file_missing_errors() {
1773 let temp_dir = std::env::temp_dir().join("holoconf_test_required_missing");
1774 std::fs::create_dir_all(&temp_dir).unwrap();
1775
1776 let missing_path = temp_dir.join("missing.yaml"); let result = Config::load(&missing_path);
1780
1781 match result {
1782 Ok(_) => panic!("Expected error for missing required file"),
1783 Err(err) => {
1784 assert!(
1785 err.to_string().contains("File not found"),
1786 "Error should mention file not found: {}",
1787 err
1788 );
1789 }
1790 }
1791
1792 let result2 = Config::required(&missing_path);
1794 assert!(result2.is_err());
1795
1796 std::fs::remove_dir_all(&temp_dir).ok();
1798 }
1799
1800 #[test]
1801 fn test_all_optional_files_missing() {
1802 let temp_dir = std::env::temp_dir().join("holoconf_test_all_optional_missing");
1803 std::fs::create_dir_all(&temp_dir).unwrap();
1804
1805 let optional1 = temp_dir.join("optional1.yaml");
1806 let optional2 = temp_dir.join("optional2.yaml");
1807
1808 let mut config = Config::optional(&optional1).unwrap();
1810 let config2 = Config::optional(&optional2).unwrap();
1811 config.merge(config2);
1812
1813 let value = config.to_value(false, false).unwrap();
1815 assert!(value.as_mapping().unwrap().is_empty());
1816
1817 std::fs::remove_dir_all(&temp_dir).ok();
1819 }
1820
1821 #[test]
1822 fn test_mixed_required_and_optional() {
1823 let temp_dir = std::env::temp_dir().join("holoconf_test_mixed_req_opt");
1824 std::fs::create_dir_all(&temp_dir).unwrap();
1825
1826 let required1 = temp_dir.join("required1.yaml");
1827 let optional1 = temp_dir.join("optional1.yaml"); let required2 = temp_dir.join("required2.yaml");
1829 let optional2 = temp_dir.join("optional2.yaml");
1830
1831 std::fs::write(
1832 &required1,
1833 r#"
1834app:
1835 name: myapp
1836 debug: false
1837"#,
1838 )
1839 .unwrap();
1840
1841 std::fs::write(
1842 &required2,
1843 r#"
1844database:
1845 host: localhost
1846"#,
1847 )
1848 .unwrap();
1849
1850 std::fs::write(
1851 &optional2,
1852 r#"
1853app:
1854 debug: true
1855database:
1856 port: 5432
1857"#,
1858 )
1859 .unwrap();
1860
1861 let mut config = Config::load(&required1).unwrap();
1863 let opt1 = Config::optional(&optional1).unwrap(); config.merge(opt1);
1865 let req2 = Config::load(&required2).unwrap();
1866 config.merge(req2);
1867 let opt2 = Config::optional(&optional2).unwrap(); config.merge(opt2);
1869
1870 assert_eq!(config.get("app.name").unwrap().as_str(), Some("myapp"));
1872 assert_eq!(config.get("app.debug").unwrap().as_bool(), Some(true)); assert_eq!(
1874 config.get("database.host").unwrap().as_str(),
1875 Some("localhost")
1876 );
1877 assert_eq!(config.get("database.port").unwrap().as_i64(), Some(5432)); std::fs::remove_dir_all(&temp_dir).ok();
1881 }
1882
1883 #[test]
1884 fn test_from_json() {
1885 let json = r#"{"database": {"host": "localhost", "port": 5432}}"#;
1886 let config = Config::from_json(json).unwrap();
1887
1888 assert_eq!(
1889 config.get("database.host").unwrap().as_str(),
1890 Some("localhost")
1891 );
1892 assert_eq!(config.get("database.port").unwrap().as_i64(), Some(5432));
1893 }
1894
1895 #[test]
1896 fn test_from_json_invalid() {
1897 let json = r#"{"unclosed": "#;
1898 let result = Config::from_json(json);
1899 assert!(result.is_err());
1900 }
1901
1902 #[test]
1903 fn test_get_raw() {
1904 let yaml = r#"
1905key: ${env:SOME_VAR,default=fallback}
1906literal: plain_value
1907"#;
1908 let config = Config::from_yaml(yaml).unwrap();
1909
1910 let raw = config.get_raw("key").unwrap();
1912 assert!(raw.as_str().unwrap().contains("${env:"));
1913
1914 let literal = config.get_raw("literal").unwrap();
1916 assert_eq!(literal.as_str(), Some("plain_value"));
1917 }
1918
1919 #[test]
1920 fn test_get_string() {
1921 std::env::set_var("HOLOCONF_TEST_STRING", "hello_world");
1922
1923 let yaml = r#"
1924plain: "plain_string"
1925env_var: ${env:HOLOCONF_TEST_STRING}
1926number: 42
1927"#;
1928 let config = Config::from_yaml(yaml).unwrap();
1929
1930 assert_eq!(config.get_string("plain").unwrap(), "plain_string");
1931 assert_eq!(config.get_string("env_var").unwrap(), "hello_world");
1932
1933 assert_eq!(config.get_string("number").unwrap(), "42");
1935
1936 std::env::remove_var("HOLOCONF_TEST_STRING");
1937 }
1938
1939 #[test]
1940 fn test_get_f64() {
1941 let yaml = r#"
1942float: 1.23
1943int: 42
1944string_num: "4.56"
1945string_bad: "not_a_number"
1946"#;
1947 let config = Config::from_yaml(yaml).unwrap();
1948
1949 assert!((config.get_f64("float").unwrap() - 1.23).abs() < 0.001);
1950 assert!((config.get_f64("int").unwrap() - 42.0).abs() < 0.001);
1951 assert!((config.get_f64("string_num").unwrap() - 4.56).abs() < 0.001);
1953 assert!(config.get_f64("string_bad").is_err());
1955 }
1956
1957 #[test]
1958 fn test_config_merge() {
1959 let yaml1 = r#"
1960database:
1961 host: localhost
1962 port: 5432
1963app:
1964 name: myapp
1965"#;
1966 let yaml2 = r#"
1967database:
1968 port: 3306
1969 user: admin
1970app:
1971 debug: true
1972"#;
1973 let mut config1 = Config::from_yaml(yaml1).unwrap();
1974 let config2 = Config::from_yaml(yaml2).unwrap();
1975
1976 config1.merge(config2);
1977
1978 assert_eq!(
1980 config1.get("database.host").unwrap().as_str(),
1981 Some("localhost")
1982 );
1983 assert_eq!(config1.get("database.port").unwrap().as_i64(), Some(3306)); assert_eq!(
1985 config1.get("database.user").unwrap().as_str(),
1986 Some("admin")
1987 ); assert_eq!(config1.get("app.name").unwrap().as_str(), Some("myapp"));
1989 assert_eq!(config1.get("app.debug").unwrap().as_bool(), Some(true)); }
1991
1992 #[test]
1993 fn test_config_clone() {
1994 let yaml = r#"
1995key: value
1996nested:
1997 a: 1
1998 b: 2
1999"#;
2000 let config = Config::from_yaml(yaml).unwrap();
2001 let cloned = config.clone();
2002
2003 assert_eq!(cloned.get("key").unwrap().as_str(), Some("value"));
2004 assert_eq!(cloned.get("nested.a").unwrap().as_i64(), Some(1));
2005 }
2006
2007 #[test]
2008 fn test_with_options() {
2009 use indexmap::IndexMap;
2010 let mut map = IndexMap::new();
2011 map.insert("key".to_string(), crate::Value::String("value".to_string()));
2012 let value = crate::Value::Mapping(map);
2013 let options = ConfigOptions {
2014 base_path: None,
2015 allow_http: true,
2016 http_allowlist: vec![],
2017 file_roots: vec!["/custom/path".into()],
2018 ..Default::default()
2019 };
2020 let config = Config::with_options(value, options);
2021
2022 assert_eq!(config.get("key").unwrap().as_str(), Some("value"));
2023 }
2024
2025 #[test]
2026 fn test_from_yaml_with_options() {
2027 let yaml = "key: value";
2028 let options = ConfigOptions {
2029 base_path: None,
2030 allow_http: true,
2031 http_allowlist: vec![],
2032 file_roots: vec![],
2033 ..Default::default()
2034 };
2035 let config = Config::from_yaml_with_options(yaml, options).unwrap();
2036
2037 assert_eq!(config.get("key").unwrap().as_str(), Some("value"));
2038 }
2039
2040 #[test]
2041 fn test_resolve_all() {
2042 std::env::set_var("HOLOCONF_RESOLVE_ALL_TEST", "resolved");
2043
2044 let yaml = r#"
2045a: ${env:HOLOCONF_RESOLVE_ALL_TEST}
2046b: static_value
2047c:
2048 nested: ${env:HOLOCONF_RESOLVE_ALL_TEST}
2049"#;
2050 let config = Config::from_yaml(yaml).unwrap();
2051
2052 config.resolve_all().unwrap();
2054
2055 assert_eq!(config.get("a").unwrap().as_str(), Some("resolved"));
2057 assert_eq!(config.get("b").unwrap().as_str(), Some("static_value"));
2058 assert_eq!(config.get("c.nested").unwrap().as_str(), Some("resolved"));
2059
2060 std::env::remove_var("HOLOCONF_RESOLVE_ALL_TEST");
2061 }
2062
2063 #[test]
2064 fn test_resolve_all_with_errors() {
2065 let yaml = r#"
2066valid: static
2067invalid: ${env:HOLOCONF_NONEXISTENT_RESOLVE_VAR}
2068"#;
2069 std::env::remove_var("HOLOCONF_NONEXISTENT_RESOLVE_VAR");
2070
2071 let config = Config::from_yaml(yaml).unwrap();
2072 let result = config.resolve_all();
2073
2074 assert!(result.is_err());
2075 assert!(result.unwrap_err().to_string().contains("not found"));
2076 }
2077
2078 #[test]
2079 fn test_self_reference_basic() {
2080 let yaml = r#"
2082settings:
2083 timeout: 30
2084app:
2085 timeout: ${settings.timeout}
2086"#;
2087 let config = Config::from_yaml(yaml).unwrap();
2088
2089 assert_eq!(config.get("app.timeout").unwrap().as_i64(), Some(30));
2090 }
2091
2092 #[test]
2093 fn test_self_reference_missing_errors() {
2094 let yaml = r#"
2095app:
2096 timeout: ${settings.missing_timeout}
2097"#;
2098 let config = Config::from_yaml(yaml).unwrap();
2099
2100 let result = config.get("app.timeout");
2102 assert!(result.is_err());
2103 assert!(result.unwrap_err().to_string().contains("not found"));
2104 }
2105
2106 #[test]
2107 fn test_self_reference_sensitivity_inheritance() {
2108 std::env::set_var("HOLOCONF_INHERITED_SECRET", "secret_value");
2109
2110 let yaml = r#"
2111secrets:
2112 api_key: ${env:HOLOCONF_INHERITED_SECRET,sensitive=true}
2113derived: ${secrets.api_key}
2114"#;
2115 let config = Config::from_yaml(yaml).unwrap();
2116
2117 assert_eq!(
2119 config.get("secrets.api_key").unwrap().as_str(),
2120 Some("secret_value")
2121 );
2122 assert_eq!(
2123 config.get("derived").unwrap().as_str(),
2124 Some("secret_value")
2125 );
2126
2127 let yaml_output = config.to_yaml(true, true).unwrap();
2129 assert!(yaml_output.contains("[REDACTED]"));
2130 assert!(!yaml_output.contains("secret_value"));
2131
2132 std::env::remove_var("HOLOCONF_INHERITED_SECRET");
2133 }
2134
2135 #[test]
2136 fn test_non_notfound_error_does_not_use_default() {
2137 use crate::resolver::FnResolver;
2139 use std::sync::Arc;
2140
2141 let yaml = r#"
2142value: ${failing:arg,default=should_not_be_used}
2143"#;
2144 let mut config = Config::from_yaml(yaml).unwrap();
2145
2146 config.register_resolver(Arc::new(FnResolver::new(
2148 "failing",
2149 |_args, _kwargs, ctx| {
2150 Err(
2151 crate::error::Error::resolver_custom("failing", "Network timeout")
2152 .with_path(ctx.config_path.clone()),
2153 )
2154 },
2155 )));
2156
2157 let result = config.get("value");
2159 assert!(result.is_err());
2160 assert!(result.unwrap_err().to_string().contains("Network timeout"));
2161 }
2162
2163 #[test]
2166 fn test_get_returns_schema_default() {
2167 use crate::schema::Schema;
2168
2169 let yaml = r#"
2170database:
2171 host: localhost
2172"#;
2173 let schema_yaml = r#"
2174type: object
2175properties:
2176 database:
2177 type: object
2178 properties:
2179 host:
2180 type: string
2181 port:
2182 type: integer
2183 default: 5432
2184 pool_size:
2185 type: integer
2186 default: 10
2187"#;
2188 let mut config = Config::from_yaml(yaml).unwrap();
2189 let schema = Schema::from_yaml(schema_yaml).unwrap();
2190 config.set_schema(schema);
2191
2192 assert_eq!(
2194 config.get("database.host").unwrap(),
2195 Value::String("localhost".into())
2196 );
2197
2198 assert_eq!(config.get("database.port").unwrap(), Value::Integer(5432));
2200 assert_eq!(
2201 config.get("database.pool_size").unwrap(),
2202 Value::Integer(10)
2203 );
2204 }
2205
2206 #[test]
2207 fn test_config_value_overrides_schema_default() {
2208 use crate::schema::Schema;
2209
2210 let yaml = r#"
2211port: 3000
2212"#;
2213 let schema_yaml = r#"
2214type: object
2215properties:
2216 port:
2217 type: integer
2218 default: 8080
2219"#;
2220 let mut config = Config::from_yaml(yaml).unwrap();
2221 let schema = Schema::from_yaml(schema_yaml).unwrap();
2222 config.set_schema(schema);
2223
2224 assert_eq!(config.get("port").unwrap(), Value::Integer(3000));
2226 }
2227
2228 #[test]
2229 fn test_no_schema_raises_path_not_found() {
2230 let yaml = r#"
2231existing: value
2232"#;
2233 let config = Config::from_yaml(yaml).unwrap();
2234
2235 let result = config.get("missing");
2237 assert!(result.is_err());
2238 assert!(matches!(
2239 result.unwrap_err().kind,
2240 crate::error::ErrorKind::PathNotFound
2241 ));
2242 }
2243
2244 #[test]
2245 fn test_missing_path_no_default_raises_error() {
2246 use crate::schema::Schema;
2247
2248 let yaml = r#"
2249existing: value
2250"#;
2251 let schema_yaml = r#"
2252type: object
2253properties:
2254 existing:
2255 type: string
2256 no_default:
2257 type: string
2258"#;
2259 let mut config = Config::from_yaml(yaml).unwrap();
2260 let schema = Schema::from_yaml(schema_yaml).unwrap();
2261 config.set_schema(schema);
2262
2263 let result = config.get("no_default");
2265 assert!(result.is_err());
2266 assert!(matches!(
2267 result.unwrap_err().kind,
2268 crate::error::ErrorKind::PathNotFound
2269 ));
2270 }
2271
2272 #[test]
2273 fn test_validate_uses_attached_schema() {
2274 use crate::schema::Schema;
2275
2276 let yaml = r#"
2277name: test
2278port: 8080
2279"#;
2280 let schema_yaml = r#"
2281type: object
2282required:
2283 - name
2284 - port
2285properties:
2286 name:
2287 type: string
2288 port:
2289 type: integer
2290"#;
2291 let mut config = Config::from_yaml(yaml).unwrap();
2292 let schema = Schema::from_yaml(schema_yaml).unwrap();
2293 config.set_schema(schema);
2294
2295 assert!(config.validate(None).is_ok());
2297 }
2298
2299 #[test]
2300 fn test_validate_no_schema_errors() {
2301 let yaml = r#"
2302name: test
2303"#;
2304 let config = Config::from_yaml(yaml).unwrap();
2305
2306 let result = config.validate(None);
2308 assert!(result.is_err());
2309 let err = result.unwrap_err();
2310 assert!(err.to_string().contains("No schema"));
2311 }
2312
2313 #[test]
2314 fn test_null_value_uses_default_when_null_disallowed() {
2315 use crate::schema::Schema;
2316
2317 let yaml = r#"
2318value: null
2319"#;
2320 let schema_yaml = r#"
2321type: object
2322properties:
2323 value:
2324 type: string
2325 default: "fallback"
2326"#;
2327 let mut config = Config::from_yaml(yaml).unwrap();
2328 let schema = Schema::from_yaml(schema_yaml).unwrap();
2329 config.set_schema(schema);
2330
2331 assert_eq!(
2333 config.get("value").unwrap(),
2334 Value::String("fallback".into())
2335 );
2336 }
2337
2338 #[test]
2339 fn test_null_value_preserved_when_null_allowed() {
2340 use crate::schema::Schema;
2341
2342 let yaml = r#"
2343value: null
2344"#;
2345 let schema_yaml = r#"
2346type: object
2347properties:
2348 value:
2349 type:
2350 - string
2351 - "null"
2352 default: "fallback"
2353"#;
2354 let mut config = Config::from_yaml(yaml).unwrap();
2355 let schema = Schema::from_yaml(schema_yaml).unwrap();
2356 config.set_schema(schema);
2357
2358 assert_eq!(config.get("value").unwrap(), Value::Null);
2360 }
2361
2362 #[test]
2363 fn test_set_and_get_schema() {
2364 use crate::schema::Schema;
2365
2366 let yaml = r#"
2367name: test
2368"#;
2369 let mut config = Config::from_yaml(yaml).unwrap();
2370
2371 assert!(config.get_schema().is_none());
2373
2374 let schema = Schema::from_yaml(
2375 r#"
2376type: object
2377properties:
2378 name:
2379 type: string
2380"#,
2381 )
2382 .unwrap();
2383 config.set_schema(schema);
2384
2385 assert!(config.get_schema().is_some());
2387 }
2388}