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