smart_config/source/
mod.rs

1use std::{
2    any,
3    collections::{BTreeMap, HashSet},
4    iter,
5    marker::PhantomData,
6    mem,
7    sync::Arc,
8};
9
10pub use self::{env::Environment, json::Json, yaml::Yaml};
11use crate::{
12    DescribeConfig, DeserializeConfig, DeserializeConfigError, ParseError, ParseErrors,
13    de::{DeserializeContext, DeserializerOptions},
14    fallback::Fallbacks,
15    metadata::{BasicTypes, ConfigTag, ConfigVariant, TypeSuffixes},
16    schema::{ConfigData, ConfigRef, ConfigSchema},
17    utils::{EnumVariant, JsonObject, merge_json},
18    value::{Map, Pointer, Value, ValueOrigin, WithOrigin},
19    visit::Serializer,
20};
21
22#[macro_use]
23mod macros;
24mod env;
25mod json;
26#[cfg(test)]
27mod tests;
28mod yaml;
29
30/// Kind of a [`ConfigSource`].
31pub trait ConfigSourceKind: crate::utils::Sealed {
32    #[doc(hidden)] // implementation detail
33    const IS_FLAT: bool;
34}
35
36/// Marker for hierarchical configuration sources (e.g. JSON or YAML files).
37#[derive(Debug)]
38pub struct Hierarchical(());
39
40impl crate::utils::Sealed for Hierarchical {}
41impl ConfigSourceKind for Hierarchical {
42    const IS_FLAT: bool = false;
43}
44
45/// Marker for key–value / flat configuration sources (e.g., env variables or command-line args).
46#[derive(Debug)]
47pub struct Flat(());
48
49impl crate::utils::Sealed for Flat {}
50impl ConfigSourceKind for Flat {
51    const IS_FLAT: bool = true;
52}
53
54/// Source of configuration parameters that can be added to a [`ConfigRepository`].
55pub trait ConfigSource {
56    /// Kind of the source.
57    type Kind: ConfigSourceKind;
58    /// Converts this source into config contents.
59    fn into_contents(self) -> WithOrigin<Map>;
60}
61
62/// Wraps a hierarchical source into a prefix.
63#[derive(Debug, Clone)]
64pub struct Prefixed<T> {
65    inner: T,
66    prefix: String,
67}
68
69impl<T: ConfigSource<Kind = Hierarchical>> Prefixed<T> {
70    /// Wraps the provided source.
71    pub fn new(inner: T, prefix: impl Into<String>) -> Self {
72        Self {
73            inner,
74            prefix: prefix.into(),
75        }
76    }
77}
78
79impl<T: ConfigSource<Kind = Hierarchical>> ConfigSource for Prefixed<T> {
80    type Kind = Hierarchical;
81
82    fn into_contents(self) -> WithOrigin<Map> {
83        let contents = self.inner.into_contents();
84
85        let origin = Arc::new(ValueOrigin::Synthetic {
86            source: contents.origin.clone(),
87            transform: format!("prefixed with `{}`", self.prefix),
88        });
89
90        if let Some((parent, key_in_parent)) = Pointer(&self.prefix).split_last() {
91            let mut root = WithOrigin::new(Value::Object(Map::new()), origin.clone());
92            root.ensure_object(parent, |_| origin.clone())
93                .insert(key_in_parent.to_owned(), contents.map(Value::Object));
94            root.map(|value| match value {
95                Value::Object(map) => map,
96                _ => unreachable!(), // guaranteed by `ensure_object`
97            })
98        } else {
99            contents
100        }
101    }
102}
103
104/// Prioritized list of configuration sources. Can be used to push multiple sources at once
105/// into a [`ConfigRepository`].
106#[derive(Debug, Clone, Default)]
107pub struct ConfigSources {
108    inner: Vec<(WithOrigin<Map>, bool)>,
109}
110
111impl ConfigSources {
112    /// Pushes a configuration source at the end of the list.
113    pub fn push<S: ConfigSource>(&mut self, source: S) {
114        self.inner
115            .push((source.into_contents(), <S::Kind>::IS_FLAT));
116    }
117}
118
119/// Information about a source returned from [`ConfigRepository::sources()`].
120#[derive(Debug, Clone)]
121#[non_exhaustive]
122pub struct SourceInfo {
123    /// Origin of the source.
124    pub origin: Arc<ValueOrigin>,
125    /// Number of params in the source after it has undergone preprocessing (i.e., merging aliases etc.).
126    pub param_count: usize,
127}
128
129/// Configuration serialization options.
130#[derive(Debug, Clone, Default)]
131pub struct SerializerOptions {
132    pub(crate) diff_with_default: bool,
133    pub(crate) secret_placeholder: Option<String>,
134    pub(crate) flat: bool,
135}
136
137impl SerializerOptions {
138    /// Will serialize only params with values differing from the default value.
139    pub fn diff_with_default() -> Self {
140        Self {
141            diff_with_default: true,
142            secret_placeholder: None,
143            flat: false,
144        }
145    }
146
147    /// Use flat config structure, as opposed to the default hierarchical one.
148    ///
149    /// In the flat structure, all params are placed in a single JSON object with full dot-separated param paths
150    /// (e.g., `api.http.port`) used as keys. Because param serializations can still be objects or arrays,
151    /// the produced object may not be completely flat.
152    ///
153    /// Use
154    #[must_use]
155    pub fn flat(mut self, flat: bool) -> Self {
156        self.flat = flat;
157        self
158    }
159
160    /// Sets the placeholder string value for secret params. By default, secrets will be output as-is.
161    #[must_use]
162    pub fn with_secret_placeholder(mut self, placeholder: impl Into<String>) -> Self {
163        self.secret_placeholder = Some(placeholder.into());
164        self
165    }
166
167    /// Serializes a config to JSON, recursively visiting its nested configs.
168    pub fn serialize<C: DescribeConfig>(self, config: &C) -> JsonObject {
169        let mut visitor = Serializer::new(&C::DESCRIPTION, "", self);
170        config.visit_config(&mut visitor);
171        visitor.into_inner()
172    }
173}
174
175/// Configuration repository containing zero or more [configuration sources](ConfigSource).
176/// Sources are preprocessed and merged according to the provided [`ConfigSchema`].
177///
178/// # Merging sources
179///
180/// [`Self::with()`] merges a new source into this repo. The new source has higher priority and will overwrite
181/// values defined in old sources, including via parameter aliases.
182///
183/// # Type coercion
184///
185/// When processing [`ConfigSource`]s, values can be *coerced* depending on the [expected type](BasicTypes)
186/// at the corresponding location [as indicated](crate::de::DeserializeParam::EXPECTING) by the param deserializer.
187/// Currently, coercion only happens if the original value is a string.
188///
189/// - If the expected type is [`BasicTypes::INTEGER`], [`BasicTypes::FLOAT`], or [`BasicTypes::BOOL`],
190///   the number / Boolean is [parsed](str::parse()) from the string. If parsing succeeds, the value is replaced.
191///
192/// Coercion is not performed if the param deserializer doesn't specify an expected type.
193///
194/// This means that it's possible to supply values for structured params from env vars without much hassle:
195///
196/// ```rust
197/// # use std::collections::HashMap;
198/// use smart_config::{testing, DescribeConfig, DeserializeConfig, Environment};
199///
200/// #[derive(Debug, DescribeConfig, DeserializeConfig)]
201/// struct CoercingConfig {
202///     flag: bool,
203///     ints: Vec<u64>,
204///     map: HashMap<String, u32>,
205/// }
206///
207/// let mut env = Environment::from_iter("APP_", [
208///     ("APP_FLAG", "true"),
209///     ("APP_INTS__JSON", "[2, 3, 5]"),
210///     ("APP_MAP__JSON", r#"{ "value": 5 }"#),
211/// ]);
212/// // Coerce `__json`-suffixed env vars to JSON
213/// env.coerce_json()?;
214/// // `testing` functions create a repository internally
215/// let config: CoercingConfig = testing::test(env)?;
216/// assert!(config.flag);
217/// assert_eq!(config.ints, [2, 3, 5]);
218/// assert_eq!(config.map, HashMap::from([("value".into(), 5)]));
219/// # anyhow::Ok(())
220/// ```
221///
222/// # Other preprocessing
223///
224/// Besides type coercion, sources undergo a couple of additional transforms:
225///
226/// - **Garbage collection:** All values not corresponding to params or their ancestor objects
227///   are removed.
228/// - **Hiding secrets:** Values corresponding to [secret params](crate::de#secrets) are wrapped in
229///   opaque, zero-on-drop wrappers.
230#[derive(Debug, Clone)]
231pub struct ConfigRepository<'a> {
232    schema: &'a ConfigSchema,
233    prefixes_for_canonical_configs: HashSet<Pointer<'a>>,
234    de_options: DeserializerOptions,
235    sources: Vec<SourceInfo>,
236    merged: WithOrigin,
237}
238
239impl<'a> ConfigRepository<'a> {
240    /// Creates an empty config repo based on the provided schema.
241    pub fn new(schema: &'a ConfigSchema) -> Self {
242        let prefixes_for_canonical_configs: HashSet<_> = schema
243            .iter_ll()
244            .flat_map(|(path, _)| path.with_ancestors())
245            .chain([Pointer("")])
246            .collect();
247
248        let this = Self {
249            schema,
250            prefixes_for_canonical_configs,
251            de_options: DeserializerOptions::default(),
252            sources: vec![],
253            merged: WithOrigin {
254                inner: Value::Object(Map::default()),
255                origin: Arc::default(),
256            },
257        };
258        if let Some(fallbacks) = Fallbacks::new(schema) {
259            this.with(fallbacks)
260        } else {
261            this
262        }
263    }
264
265    /// Returns the wrapped configuration schema.
266    pub fn schema(&self) -> &'a ConfigSchema {
267        self.schema
268    }
269
270    /// Accesses options used during `serde`-powered deserialization.
271    pub fn deserializer_options(&mut self) -> &mut DeserializerOptions {
272        &mut self.de_options
273    }
274
275    /// Extends this environment with a new configuration source.
276    #[must_use]
277    pub fn with<S: ConfigSource>(mut self, source: S) -> Self {
278        self.insert_inner(source.into_contents(), <S::Kind>::IS_FLAT);
279        self
280    }
281
282    #[tracing::instrument(
283        level = "debug",
284        name = "ConfigRepository::insert",
285        skip(self, contents)
286    )]
287    fn insert_inner(&mut self, contents: WithOrigin<Map>, is_flat: bool) {
288        let mut source_value = if is_flat {
289            WithOrigin::nest_kvs(contents.inner, self.schema, &contents.origin)
290        } else {
291            WithOrigin {
292                inner: Value::Object(contents.inner),
293                origin: contents.origin.clone(),
294            }
295        };
296
297        let param_count =
298            source_value.preprocess_source(self.schema, &self.prefixes_for_canonical_configs);
299        tracing::debug!(param_count, "Inserted source into config repo");
300        self.merged
301            .guided_merge(source_value, self.schema, Pointer(""));
302        self.sources.push(SourceInfo {
303            origin: contents.origin,
304            param_count,
305        });
306    }
307
308    ///  Extends this environment with a multiple configuration sources.
309    #[must_use]
310    pub fn with_all(mut self, sources: ConfigSources) -> Self {
311        for (contents, is_flat) in sources.inner {
312            self.insert_inner(contents, is_flat);
313        }
314        self
315    }
316
317    /// Provides information about sources merged in this repository.
318    pub fn sources(&self) -> &[SourceInfo] {
319        &self.sources
320    }
321
322    #[doc(hidden)] // not stable yet
323    pub fn merged(&self) -> &WithOrigin {
324        &self.merged
325    }
326
327    /// Returns canonical JSON for all configurations contained in the schema, with values filled both from the contained sources
328    /// and from defaults.
329    ///
330    /// This method differs from [`Self::merged()`] by taking defaults into account.
331    ///
332    /// # Errors
333    ///
334    /// If parsing any of the configs in the schema fails, returns parsing errors early (i.e., errors are **not** exhaustive).
335    /// Importantly, missing config / parameter errors are swallowed provided this is the only kind of errors for the config,
336    /// and the corresponding config serialization is skipped.
337    #[doc(hidden)] // not stable yet
338    pub fn canonicalize(&self, options: &SerializerOptions) -> Result<JsonObject, ParseErrors> {
339        let mut json = serde_json::Map::new();
340        for config_parser in self.iter() {
341            if !config_parser.config().is_top_level() {
342                // The config should be serialized as a part of the parent config.
343                continue;
344            }
345
346            let parsed = match config_parser.parse_opt() {
347                Ok(Some(config)) => config,
348                Ok(None) => continue,
349                Err(err) => return Err(err),
350            };
351
352            let metadata = config_parser.config().metadata();
353            let prefix = config_parser.config().prefix();
354            let mut visitor = Serializer::new(metadata, prefix, options.clone());
355            (metadata.visitor)(parsed.as_ref(), &mut visitor);
356            let serialized = visitor.into_inner();
357
358            if options.flat {
359                json.extend(serialized);
360            } else {
361                merge_json(&mut json, metadata, prefix, serialized);
362            }
363        }
364        Ok(json)
365    }
366
367    /// Iterates over parsers for all configs in the schema.
368    pub fn iter(&self) -> impl Iterator<Item = ConfigParser<'_, ()>> + '_ {
369        self.schema.iter().map(|config_ref| ConfigParser {
370            repo: self,
371            config_ref,
372            _config: PhantomData,
373        })
374    }
375
376    /// Returns a parser for the single configuration of the specified type.
377    ///
378    /// # Errors
379    ///
380    /// Errors if the config is not a part of the schema or is mounted to multiple locations.
381    pub fn single<C: DeserializeConfig>(&self) -> anyhow::Result<ConfigParser<'_, C>> {
382        let config_ref = self.schema.single(&C::DESCRIPTION)?;
383        Ok(ConfigParser {
384            repo: self,
385            config_ref,
386            _config: PhantomData,
387        })
388    }
389
390    /// Gets a parser for a configuration of the specified type mounted at the canonical `prefix`.
391    /// If the config is not present at `prefix`, returns `None`.
392    pub fn get<'s, C: DeserializeConfig>(&'s self, prefix: &'s str) -> Option<ConfigParser<'s, C>> {
393        let config_ref = self.schema.get(&C::DESCRIPTION, prefix)?;
394        Some(ConfigParser {
395            repo: self,
396            config_ref,
397            _config: PhantomData,
398        })
399    }
400}
401
402/// Parser of configuration input in a [`ConfigRepository`].
403#[derive(Debug)]
404pub struct ConfigParser<'a, C> {
405    repo: &'a ConfigRepository<'a>,
406    config_ref: ConfigRef<'a>,
407    _config: PhantomData<C>,
408}
409
410impl ConfigParser<'_, ()> {
411    /// Attempts to parse the related config from the repository input. Returns the boxed parsed config.
412    ///
413    /// # Errors
414    ///
415    /// Returns parsing errors if any.
416    #[doc(hidden)] // not stable yet
417    #[allow(clippy::redundant_closure_for_method_calls)] // false positive because of lifetimes
418    pub fn parse(&self) -> Result<Box<dyn any::Any>, ParseErrors> {
419        self.with_context(|ctx| ctx.deserialize_any_config())
420    }
421
422    /// Attempts to parse an optional config from the repository input. Returns the boxed parsed config.
423    /// If there's no data for the config, returns `Ok(None)`. This includes the case when some required params are missing,
424    /// and this is the only type of errors encountered.
425    ///
426    /// # Errors
427    ///
428    /// Returns parsing errors if any.
429    #[doc(hidden)] // not stable yet
430    #[allow(clippy::redundant_closure_for_method_calls)] // false positive because of lifetimes
431    pub fn parse_opt(&self) -> Result<Option<Box<dyn any::Any>>, ParseErrors> {
432        self.with_context(|ctx| ctx.deserialize_any_config_opt())
433    }
434}
435
436impl<'a, C> ConfigParser<'a, C> {
437    /// Returns a reference to the configuration.
438    pub fn config(&self) -> ConfigRef<'a> {
439        self.config_ref
440    }
441
442    fn with_context<R>(
443        &self,
444        action: impl FnOnce(DeserializeContext<'_>) -> Result<R, DeserializeConfigError>,
445    ) -> Result<R, ParseErrors> {
446        let mut errors = ParseErrors::default();
447        let prefix = self.config_ref.prefix();
448        let metadata = self.config_ref.data.metadata;
449        let ctx = DeserializeContext::new(
450            &self.repo.de_options,
451            &self.repo.merged,
452            prefix.to_owned(),
453            metadata,
454            &mut errors,
455        );
456        action(ctx).map_err(|_| {
457            if errors.len() == 0 {
458                errors.push(ParseError::generic(prefix.to_owned(), metadata));
459            }
460            errors
461        })
462    }
463}
464
465impl<C: DeserializeConfig> ConfigParser<'_, C> {
466    /// Performs parsing.
467    ///
468    /// # Errors
469    ///
470    /// Returns errors encountered during parsing. This list of errors is as full as possible (i.e.,
471    /// there is no short-circuiting on encountering an error).
472    #[allow(clippy::redundant_closure_for_method_calls)] // doesn't work as an fn pointer because of the context lifetime
473    pub fn parse(self) -> Result<C, ParseErrors> {
474        self.with_context(|ctx| ctx.deserialize_config::<C>())
475    }
476
477    /// Parses an optional config. Returns `None` if the config object is not present (i.e., none of the config params / sub-configs
478    /// are set); otherwise, tries to perform parsing.
479    ///
480    /// # Errors
481    ///
482    /// Returns errors encountered during parsing.
483    #[allow(clippy::redundant_closure_for_method_calls)] // doesn't work as an fn pointer because of the context lifetime
484    pub fn parse_opt(self) -> Result<Option<C>, ParseErrors> {
485        self.with_context(|ctx| ctx.deserialize_config_opt::<C>())
486    }
487}
488
489impl WithOrigin {
490    fn preprocess_source(
491        &mut self,
492        schema: &ConfigSchema,
493        prefixes_for_canonical_configs: &HashSet<Pointer<'_>>,
494    ) -> usize {
495        self.expand_enum_shorthands(schema);
496        self.copy_aliased_values(schema);
497        self.mark_secrets(schema);
498        self.convert_serde_enums(schema);
499        self.nest_object_params_and_sub_configs(schema);
500        self.nest_array_params(schema);
501        self.collect_garbage(schema, prefixes_for_canonical_configs, Pointer(""))
502    }
503
504    #[tracing::instrument(level = "debug", skip_all)]
505    fn copy_aliased_values(&mut self, schema: &ConfigSchema) {
506        for (prefix, config_data) in schema.iter_ll() {
507            let (new_values, new_map_origin) = self.copy_aliases_for_config(config_data);
508            if new_values.is_empty() {
509                continue;
510            }
511
512            let new_map_origin = new_map_origin.map(|source| {
513                Arc::new(ValueOrigin::Synthetic {
514                    source,
515                    transform: format!("copy to '{prefix}' per aliasing rules"),
516                })
517            });
518            // `unwrap()` below is safe: if there is no `current_map`, `new_values` are obtained from the alias maps,
519            // meaning that `new_map_origin` has been set.
520            self.ensure_object(prefix, |_| new_map_origin.clone().unwrap())
521                .extend(new_values);
522        }
523    }
524
525    #[must_use = "returned map should be inserted into the config"]
526    fn copy_aliases_for_config(&self, config: &ConfigData) -> (Map, Option<Arc<ValueOrigin>>) {
527        let prefix = config.prefix();
528        let canonical_map = match self.get(prefix).map(|val| &val.inner) {
529            Some(Value::Object(map)) => Some(map),
530            Some(_) => {
531                tracing::warn!(
532                    prefix = prefix.0,
533                    config = ?config.metadata.ty,
534                    "canonical config location contains a non-object"
535                );
536                return (Map::new(), None);
537            }
538            None => None,
539        };
540
541        let mut new_values = Map::new();
542        let mut new_map_origin = None;
543
544        for param in config.metadata.params {
545            // Create a prioritized iterator of all candidate paths
546            let all_paths = config.all_paths_for_param(param);
547
548            for (path, alias_options) in all_paths {
549                let (prefix, name) = Pointer(&path)
550                    .split_last()
551                    .expect("param paths are never empty");
552                let Some(map) = self.get(prefix) else {
553                    continue;
554                };
555                let map_origin = &map.origin;
556                let Some(map) = map.inner.as_object() else {
557                    continue;
558                };
559
560                // Find all values in `map` that either match `name` exactly, or have the `{name}_{type_suffix}` form.
561                let matching_values: Vec<_> =
562                    if let Some(suffixes) = param.type_description().suffixes() {
563                        let matching_values = map.iter().filter_map(|(key, val)| {
564                            let suffix = if key == name {
565                                None // Exact match
566                            } else {
567                                let key_suffix = Self::strip_prefix(key, name)?;
568                                if !suffixes.contains(key_suffix) {
569                                    return None;
570                                }
571                                Some(key_suffix)
572                            };
573                            Some((suffix, val))
574                        });
575                        matching_values.collect()
576                    } else if let Some(val) = map.get(name) {
577                        // Shortcut: we only need to check the exact param name if no suffixes are defined by the param deserializer.
578                        vec![(None, val)]
579                    } else {
580                        vec![]
581                    };
582
583                // Copy the found values.
584                for (suffix, val) in matching_values {
585                    let canonical_key_string;
586                    let canonical_key = if let Some(suffix) = suffix {
587                        canonical_key_string = format!("{}_{suffix}", param.name);
588                        &canonical_key_string
589                    } else {
590                        param.name
591                    };
592
593                    if canonical_map.is_some_and(|map| map.contains_key(canonical_key)) {
594                        // Key is already present in the original map
595                        continue;
596                    }
597
598                    if !new_values.contains_key(canonical_key) {
599                        if alias_options.is_deprecated {
600                            tracing::warn!(
601                                path,
602                                origin = %val.origin,
603                                config = ?config.metadata.ty,
604                                param = param.rust_field_name,
605                                canonical_path = prefix.join(canonical_key),
606                                "using deprecated alias; please use canonical_path instead"
607                            );
608                        }
609
610                        tracing::trace!(
611                            prefix = prefix.0,
612                            config = ?config.metadata.ty,
613                            param = param.rust_field_name,
614                            name,
615                            origin = ?map_origin,
616                            canonical_key,
617                            "copied aliased param"
618                        );
619                        new_values.insert(canonical_key.to_owned(), val.clone());
620                        if new_map_origin.is_none() {
621                            new_map_origin = Some(map_origin.clone());
622                        }
623                    }
624                }
625            }
626        }
627
628        (new_values, new_map_origin)
629    }
630
631    fn strip_prefix<'s>(s: &'s str, prefix: &str) -> Option<&'s str> {
632        s.strip_prefix(prefix)?
633            .strip_prefix('_')
634            .filter(|suffix| !suffix.is_empty())
635    }
636
637    /// Expands shorthands for enum configs: a single non-object value at a config location is replaced
638    /// with an object containing the tag and the shorthand param. This runs before de-aliasing so that
639    /// a shorthand at an aliased location is copied to the canonical one like any other param,
640    /// and before secret marking so that the shorthand param gets masked if it is secret.
641    #[tracing::instrument(level = "debug", skip_all)]
642    fn expand_enum_shorthands(&mut self, schema: &ConfigSchema) {
643        for (prefix, config_data) in schema.iter_ll() {
644            let Some(shorthand) = config_data.shorthand() else {
645                continue;
646            };
647            let tag = config_data
648                .metadata
649                .tag
650                .as_ref()
651                .expect("config with a shorthand is always tagged");
652
653            let alias_paths = config_data.aliases().map(|(alias, _)| Pointer(alias));
654            for path in iter::once(prefix).chain(alias_paths) {
655                let Some(value) = self.get_mut(path) else {
656                    continue;
657                };
658                if matches!(value.inner, Value::Object(_) | Value::Null) {
659                    continue;
660                }
661
662                tracing::debug!(
663                    prefix = path.0,
664                    config = ?config_data.metadata.ty,
665                    variant = shorthand.variant.name,
666                    "expanding enum config shorthand"
667                );
668                let origin = Arc::new(ValueOrigin::Synthetic {
669                    source: value.origin.clone(),
670                    transform: format!(
671                        "expanding shorthand for variant '{}'",
672                        shorthand.variant.name
673                    ),
674                });
675                let shorthand_value = mem::take(&mut value.inner);
676                let mut map = Map::new();
677                map.insert(
678                    tag.param.name.to_owned(),
679                    WithOrigin::new(shorthand.variant.name.to_owned().into(), origin.clone()),
680                );
681                map.insert(
682                    shorthand.param.name.to_owned(),
683                    WithOrigin::new(shorthand_value, value.origin.clone()),
684                );
685                value.inner = Value::Object(map);
686                value.origin = origin;
687            }
688        }
689    }
690
691    /// Wraps secret string values into `Value::SecretString(_)`.
692    fn mark_secrets(&mut self, schema: &ConfigSchema) {
693        for (prefix, config_data) in schema.iter_ll() {
694            let Some(Self {
695                inner: Value::Object(config_object),
696                ..
697            }) = self.get_mut(prefix)
698            else {
699                continue;
700            };
701
702            for param in config_data.metadata.params {
703                if !param.type_description().contains_secrets() {
704                    continue;
705                }
706                let Some(value) = config_object.get_mut(param.name) else {
707                    continue;
708                };
709
710                if let Value::String(str) = &mut value.inner {
711                    tracing::trace!(
712                        prefix = prefix.0,
713                        config = ?config_data.metadata.ty,
714                        param = param.rust_field_name,
715                        "marked param as secret"
716                    );
717                    str.make_secret();
718                } else {
719                    tracing::warn!(
720                        prefix = prefix.0,
721                        config = ?config_data.metadata.ty,
722                        param = param.rust_field_name,
723                        "param marked as secret has non-string value"
724                    );
725                }
726            }
727        }
728    }
729
730    #[tracing::instrument(level = "debug", skip_all)]
731    fn convert_serde_enums(&mut self, schema: &ConfigSchema) {
732        for config_data in schema.iter() {
733            let config_meta = config_data.metadata();
734            let prefix = Pointer(config_data.prefix());
735
736            let Some(tag) = &config_meta.tag else {
737                continue; // Not an enum config, nothing to do.
738            };
739            if !config_data.data.coerce_serde_enums {
740                continue;
741            }
742
743            let canonical_map = self.get(prefix).and_then(|val| val.inner.as_object());
744            let alias_maps = config_data
745                .aliases()
746                .filter_map(|(alias, _)| self.get(Pointer(alias))?.inner.as_object());
747
748            if canonical_map.is_some_and(|map| map.contains_key(tag.param.name)) {
749                // The source contains the relevant tag. It's sufficient to check the canonical map only since we've performed de-aliasing for tags already.
750                continue;
751            }
752
753            let _span_guard = tracing::info_span!(
754                "convert_serde_enum",
755                config = ?config_meta.ty,
756                prefix = prefix.0,
757                tag = tag.param.name,
758            )
759            .entered();
760
761            if let Some((variant, variant_content)) =
762                Self::detect_serde_enum_variant(canonical_map, alias_maps, tag)
763            {
764                tracing::debug!(
765                    variant = variant.name,
766                    origin = %variant_content.origin,
767                    "adding detected tag variant"
768                );
769                let origin = ValueOrigin::Synthetic {
770                    source: variant_content.origin.clone(),
771                    transform: "coercing serde enum".to_owned(),
772                };
773
774                let canonical_map = self.ensure_object(prefix, |_| {
775                    Arc::new(ValueOrigin::Synthetic {
776                        source: Arc::default(),
777                        transform: "enum coercion".to_string(),
778                    })
779                });
780                canonical_map.insert(
781                    tag.param.name.to_owned(),
782                    WithOrigin::new(variant.name.to_owned().into(), Arc::new(origin)),
783                );
784            }
785        }
786    }
787
788    fn detect_serde_enum_variant<'a>(
789        canonical_map: Option<&'a Map>,
790        alias_maps: impl Iterator<Item = &'a Map>,
791        tag: &'static ConfigTag,
792    ) -> Option<(&'static ConfigVariant, &'a Self)> {
793        let all_variant_names = tag.variants.iter().flat_map(|variant| {
794            iter::once(variant.name)
795                .chain(variant.aliases.iter().copied())
796                .filter_map(move |name| Some((EnumVariant::new(name)?.to_snake_case(), variant)))
797        });
798
799        // We need to look for variant fields in the alias maps because they were not copied during de-aliasing.
800        let mut variant_match = None;
801        for map in canonical_map.into_iter().chain(alias_maps) {
802            for (candidate_field_name, variant) in all_variant_names.clone() {
803                if map.contains_key(&candidate_field_name) {
804                    if let Some((_, prev_field, _)) = &variant_match
805                        && *prev_field != candidate_field_name
806                    {
807                        tracing::info!(
808                            prev_field,
809                            field = candidate_field_name,
810                            "multiple serde-like variant fields present"
811                        );
812                        return None;
813                    }
814                    variant_match = Some((map, candidate_field_name, variant));
815                }
816            }
817        }
818
819        let Some((map, field_name, variant)) = variant_match else {
820            return None; // No matches found
821        };
822        let variant_content = map.get(&field_name).unwrap();
823        if !matches!(&variant_content.inner, Value::Object(_)) {
824            tracing::info!(
825                field = field_name,
826                "variant contents is not an object, skipping"
827            );
828            return None;
829        }
830        Some((variant, variant_content))
831    }
832
833    /// Removes all values that do not correspond to canonical params or their ancestors.
834    fn collect_garbage(
835        &mut self,
836        schema: &ConfigSchema,
837        prefixes_for_canonical_configs: &HashSet<Pointer<'_>>,
838        at: Pointer<'_>,
839    ) -> usize {
840        if schema.contains_canonical_param(at) {
841            1
842        } else if prefixes_for_canonical_configs.contains(&at) {
843            if let Value::Object(map) = &mut self.inner {
844                let mut count = 0;
845                map.retain(|key, value| {
846                    let child_path = at.join(key);
847                    let descendant_count = value.collect_garbage(
848                        schema,
849                        prefixes_for_canonical_configs,
850                        Pointer(&child_path),
851                    );
852                    count += descendant_count;
853                    descendant_count > 0
854                });
855                count
856            } else {
857                // Retain a (probably erroneous) non-object value at config location to provide more intelligent errors.
858                1
859            }
860        } else {
861            // The object is neither a param nor a config or a config ancestor; remove it.
862            0
863        }
864    }
865
866    /// Nests values inside matching object params that have defined suffixes, or nested configs.
867    ///
868    /// For example, we have an object param at `test.param` and a source with a value at `test.param_ms`.
869    /// This transform will copy this value to `test.param.ms` (i.e., inside the param object), provided that
870    /// the source doesn't contain `test.param` or contains an object at this path.
871    #[tracing::instrument(level = "debug", skip_all)]
872    fn nest_object_params_and_sub_configs(&mut self, schema: &ConfigSchema) {
873        for (prefix, config_data) in schema.iter_ll() {
874            let Some(config_object) = self.get_mut(prefix) else {
875                continue;
876            };
877            let config_origin = &config_object.origin;
878            let Value::Object(config_object) = &mut config_object.inner else {
879                continue;
880            };
881
882            let params_with_suffixes = config_data.metadata.params.iter().filter_map(|param| {
883                let suffixes = param.type_description().suffixes()?;
884                Some((param.name, suffixes))
885            });
886            let nested_configs = config_data
887                .metadata
888                .nested_configs
889                .iter()
890                .filter_map(|nested| {
891                    (!nested.name.is_empty()).then_some((nested.name, TypeSuffixes::All))
892                });
893            let mut insertions = vec![];
894
895            // ms: $value -> $value // suffix: 'ms'
896
897            for (child_name, suffixes) in params_with_suffixes.chain(nested_configs) {
898                let target_object = match config_object.get(child_name) {
899                    None => None,
900                    Some(WithOrigin {
901                        inner: Value::Object(obj),
902                        ..
903                    }) => Some(obj),
904                    // Never overwrite non-objects with an object value.
905                    Some(_) => continue,
906                };
907
908                let matching_fields: Vec<_> = config_object
909                    .iter()
910                    .filter_map(|(name, field)| {
911                        let suffix = Self::strip_prefix(name, child_name)?;
912                        if !suffixes.contains(suffix) {
913                            return None;
914                        }
915                        if let Some(param_object) = target_object
916                            && param_object.contains_key(suffix)
917                        {
918                            return None; // Never overwrite existing fields
919                        }
920                        Some((suffix.to_owned(), field.clone()))
921                    })
922                    .collect();
923                if matching_fields.is_empty() {
924                    continue;
925                }
926
927                tracing::trace!(
928                    prefix = prefix.0,
929                    config = ?config_data.metadata.ty,
930                    child_name,
931                    fields = ?matching_fields.iter().map(|(name, _)| name).collect::<Vec<_>>(),
932                    "nesting for object param / config"
933                );
934                insertions.push((child_name, matching_fields));
935            }
936
937            for (child_name, matching_fields) in insertions {
938                if !config_object.contains_key(child_name) {
939                    let origin = Arc::new(ValueOrigin::Synthetic {
940                        source: config_origin.clone(),
941                        transform: format!("nesting for object param '{child_name}'"),
942                    });
943                    let val = Self::new(Value::Object(Map::new()), origin);
944                    config_object.insert(child_name.to_owned(), val);
945                }
946
947                let Value::Object(target_object) =
948                    &mut config_object.get_mut(child_name).unwrap().inner
949                else {
950                    unreachable!(); // Due to the checks above
951                };
952                target_object.extend(matching_fields);
953            }
954        }
955    }
956
957    /// Nests values inside matching array params.
958    ///
959    /// For example, we have an array param at `test.param` and a source with values at `test.param_0`, `test.param_1`, `test.param_2`
960    /// (and no `test.param`). This transform will copy these values as a 3-element array at `test.param`.
961    #[tracing::instrument(level = "debug", skip_all)]
962    fn nest_array_params(&mut self, schema: &ConfigSchema) {
963        for (prefix, config_data) in schema.iter_ll() {
964            let Some(config_object) = self.get_mut(prefix) else {
965                continue;
966            };
967            let config_origin = &config_object.origin;
968            let Value::Object(config_object) = &mut config_object.inner else {
969                continue;
970            };
971
972            for param in config_data.metadata.params {
973                if !param.expecting.contains(BasicTypes::ARRAY)
974                    || param.expecting.contains(BasicTypes::OBJECT)
975                {
976                    // If a param expects an object, a transform is ambiguous; `_${i}` suffix could be either an array index
977                    // or an object key.
978                    continue;
979                }
980                if config_object.contains_key(param.name) {
981                    // Unlike objects, we never extend existing arrays.
982                    continue;
983                }
984
985                let matching_fields: BTreeMap<_, _> = config_object
986                    .iter()
987                    .filter_map(|(name, field)| {
988                        let stripped_name = Self::strip_prefix(name, param.name)?;
989                        let idx: usize = stripped_name.parse().ok()?;
990                        Some((idx, field.clone()))
991                    })
992                    .collect();
993                let Some(&last_idx) = matching_fields.keys().next_back() else {
994                    continue; // No matching fields
995                };
996
997                if last_idx != matching_fields.len() - 1 {
998                    tracing::info!(
999                        prefix = prefix.0,
1000                        config = ?config_data.metadata.ty,
1001                        param = param.rust_field_name,
1002                        indexes = ?matching_fields.keys().copied().collect::<Vec<_>>(),
1003                        "indexes for array nesting are not sequential"
1004                    );
1005                    continue;
1006                }
1007
1008                tracing::trace!(
1009                    prefix = prefix.0,
1010                    config = ?config_data.metadata.ty,
1011                    param = param.rust_field_name,
1012                    len = matching_fields.len(),
1013                    "nesting for array param"
1014                );
1015
1016                let origin = Arc::new(ValueOrigin::Synthetic {
1017                    source: config_origin.clone(),
1018                    transform: format!("nesting for array param '{}'", param.name),
1019                });
1020                let array_items = matching_fields.into_values().collect();
1021                let val = Self::new(Value::Array(array_items), origin);
1022                config_object.insert(param.name.to_owned(), val);
1023            }
1024        }
1025    }
1026
1027    /// Nests a flat key–value map into a structured object using the provided `schema`.
1028    ///
1029    /// Has complexity `O(kvs.len() * log(n_params))`, which seems about the best possible option if `kvs` is not presorted.
1030    #[tracing::instrument(level = "debug", skip_all)]
1031    fn nest_kvs(kvs: Map, schema: &ConfigSchema, source_origin: &Arc<ValueOrigin>) -> Self {
1032        let mut dest = Self {
1033            inner: Value::Object(Map::new()),
1034            origin: source_origin.clone(),
1035        };
1036
1037        for (key, value) in kvs {
1038            // Get all params with full paths matching a prefix of `key` split on one of `_`s. E.g.,
1039            // for `key = "very_long_prefix_value"`, we'll try "very_long_prefix_value", "very_long_prefix", ..., "very".
1040            // If any of these prefixes corresponds to a param, we'll nest the value to align with the param.
1041            // For example, if `very.long_prefix.value` is a param, we'll nest the value to `very.long_prefix.value`,
1042            // and if `very_long.prefix.value` is a param as well, we'll copy the value to both places.
1043            //
1044            // For prefixes, we only copy the value if the param supports objects; e.g. if `very_long.prefix` is a param,
1045            // then we'll copy the value to `very_long.prefix_value`.
1046            let mut key_prefix = key.as_str();
1047            while !key_prefix.is_empty() {
1048                for (param_path, expecting) in schema.params_with_kv_path(key_prefix) {
1049                    let should_copy = key_prefix == key || expecting.contains(BasicTypes::OBJECT);
1050                    if should_copy {
1051                        tracing::trace!(
1052                            param_path = param_path.0,
1053                            ?expecting,
1054                            key,
1055                            key_prefix,
1056                            "copied key–value entry"
1057                        );
1058                        dest.copy_kv_entry(source_origin, param_path, &key, value.clone());
1059                    }
1060                }
1061
1062                key_prefix = match key_prefix.rsplit_once('_') {
1063                    Some((prefix, _)) => prefix,
1064                    None => break,
1065                };
1066            }
1067
1068            // A key–value entry may also be the shorthand for an enum config mounted at the corresponding path.
1069            for config_path in schema.shorthand_configs_with_kv_path(&key) {
1070                tracing::trace!(
1071                    config_path = config_path.0,
1072                    key,
1073                    "copied key–value entry as enum config shorthand"
1074                );
1075                dest.copy_kv_entry(source_origin, config_path, &key, value.clone());
1076            }
1077
1078            // Allow for array params.
1079            let Some((key_prefix, maybe_idx)) = key.rsplit_once('_') else {
1080                continue;
1081            };
1082            if !maybe_idx.bytes().all(|ch| ch.is_ascii_digit()) {
1083                continue;
1084            }
1085            for (param_path, expecting) in schema.params_with_kv_path(key_prefix) {
1086                if expecting.contains(BasicTypes::ARRAY) && !expecting.contains(BasicTypes::OBJECT)
1087                {
1088                    dest.copy_kv_entry(source_origin, param_path, &key, value.clone());
1089                }
1090            }
1091        }
1092        dest
1093    }
1094
1095    fn copy_kv_entry(
1096        &mut self,
1097        source_origin: &Arc<ValueOrigin>,
1098        param_path: Pointer<'_>,
1099        key: &str,
1100        value: WithOrigin,
1101    ) {
1102        // `unwrap()` is safe: params have non-empty paths
1103        let (parent, _) = param_path.split_last().unwrap();
1104        let field_name_start = if parent.0.is_empty() {
1105            parent.0.len()
1106        } else {
1107            parent.0.len() + 1 // skip `_` after the parent
1108        };
1109        let field_name = key[field_name_start..].to_owned();
1110
1111        let origin = Arc::new(ValueOrigin::Synthetic {
1112            source: source_origin.clone(),
1113            transform: format!("nesting kv entries for '{param_path}'"),
1114        });
1115        self.ensure_object(parent, |_| origin.clone())
1116            .insert(field_name, value);
1117    }
1118
1119    /// Deep merge stopped at params (i.e., params are always merged atomically).
1120    fn guided_merge(&mut self, overrides: Self, schema: &ConfigSchema, current_path: Pointer<'_>) {
1121        match (&mut self.inner, overrides.inner) {
1122            (Value::Object(this), Value::Object(other))
1123                if !schema.contains_canonical_param(current_path) =>
1124            {
1125                for (key, value) in other {
1126                    if let Some(existing_value) = this.get_mut(&key) {
1127                        let child_path = current_path.join(&key);
1128                        existing_value.guided_merge(value, schema, Pointer(&child_path));
1129                    } else {
1130                        this.insert(key, value);
1131                    }
1132                }
1133            }
1134            (this, value) => {
1135                *this = value;
1136                self.origin = overrides.origin;
1137            }
1138        }
1139    }
1140}