smart_config/schema/
mod.rs

1//! Configuration schema.
2
3use std::{
4    any,
5    borrow::Cow,
6    collections::{BTreeMap, BTreeSet, HashMap},
7    iter,
8};
9
10use anyhow::Context;
11
12use self::mount::{MountingPoint, MountingPoints};
13use crate::{
14    metadata::{
15        AliasOptions, BasicTypes, ConfigMetadata, ConfigVariant, NestedConfigMetadata,
16        ParamMetadata,
17    },
18    utils::EnumVariant,
19    value::Pointer,
20};
21
22mod mount;
23#[cfg(test)]
24mod tests;
25
26#[derive(Debug, Clone, Copy)]
27struct ParentLink {
28    parent_ty: any::TypeId,
29    this_ref: &'static NestedConfigMetadata,
30}
31
32#[derive(Debug, Clone)]
33pub(crate) struct ConfigData {
34    pub(crate) metadata: &'static ConfigMetadata,
35    parent_link: Option<ParentLink>,
36    pub(crate) is_top_level: bool,
37    pub(crate) coerce_serde_enums: bool,
38    all_paths: Vec<(Cow<'static, str>, AliasOptions)>,
39}
40
41impl ConfigData {
42    pub(crate) fn prefix(&self) -> Pointer<'_> {
43        Pointer(self.all_paths[0].0.as_ref())
44    }
45
46    pub(crate) fn aliases(&self) -> impl Iterator<Item = (&str, AliasOptions)> + '_ {
47        self.all_paths
48            .iter()
49            .skip(1)
50            .map(|(path, options)| (path.as_ref(), *options))
51    }
52
53    pub(crate) fn all_paths_for_param(
54        &self,
55        param: &'static ParamMetadata,
56    ) -> impl Iterator<Item = (String, AliasOptions)> + '_ {
57        self.all_paths_for_child(param.name, param.aliases, param.tag_variant)
58    }
59
60    fn all_paths_for_child(
61        &self,
62        name: &'static str,
63        aliases: &'static [(&'static str, AliasOptions)],
64        tag_variant: Option<&'static ConfigVariant>,
65    ) -> impl Iterator<Item = (String, AliasOptions)> + '_ {
66        let local_names =
67            iter::once((name, AliasOptions::default())).chain(aliases.iter().copied());
68
69        let enum_names = if let (true, Some(variant)) = (self.coerce_serde_enums, tag_variant) {
70            let variant_names = iter::once(variant.name)
71                .chain(variant.aliases.iter().copied())
72                .filter_map(|name| Some(EnumVariant::new(name)?.to_snake_case()));
73            let local_names_ = local_names.clone();
74            let paths = variant_names.flat_map(move |variant_name| {
75                local_names_
76                    .clone()
77                    .filter_map(move |(name_or_path, options)| {
78                        if name_or_path.starts_with('.') {
79                            // Only consider simple aliases, not path ones.
80                            return None;
81                        }
82                        let full_path = Pointer(&variant_name).join(name_or_path);
83                        Some((Cow::Owned(full_path), options))
84                    })
85            });
86            Some(paths)
87        } else {
88            None
89        };
90        let enum_names = enum_names.into_iter().flatten();
91        let local_names = local_names
92            .map(|(name, options)| (Cow::Borrowed(name), options))
93            .chain(enum_names);
94
95        self.all_paths
96            .iter()
97            .flat_map(move |(alias, config_options)| {
98                local_names
99                    .clone()
100                    .filter_map(move |(name_or_path, options)| {
101                        let full_path = Pointer(alias).join_path(Pointer(&name_or_path))?;
102                        Some((full_path, options.combine(*config_options)))
103                    })
104            })
105    }
106}
107
108/// Reference to a specific configuration inside [`ConfigSchema`].
109#[derive(Debug, Clone, Copy)]
110pub struct ConfigRef<'a> {
111    schema: &'a ConfigSchema,
112    prefix: &'a str,
113    pub(crate) data: &'a ConfigData,
114}
115
116impl<'a> ConfigRef<'a> {
117    /// Gets the config prefix.
118    pub fn prefix(&self) -> &'a str {
119        self.prefix
120    }
121
122    /// Gets the config metadata.
123    pub fn metadata(&self) -> &'static ConfigMetadata {
124        self.data.metadata
125    }
126
127    /// Checks whether this config is top-level (i.e., was included into the schema directly, rather than as a sub-config).
128    pub fn is_top_level(&self) -> bool {
129        self.data.parent_link.is_none()
130    }
131
132    #[doc(hidden)] // not stabilized yet
133    pub fn parent_link(&self) -> Option<(Self, &'static NestedConfigMetadata)> {
134        let link = self.data.parent_link?;
135        let parent_prefix = if link.this_ref.name.is_empty() {
136            // Flattened config
137            self.prefix
138        } else {
139            let (parent, _) = Pointer(self.prefix).split_last().unwrap();
140            parent.0
141        };
142        let parent_ref = Self {
143            schema: self.schema,
144            prefix: parent_prefix,
145            data: self.schema.get_ll(parent_prefix, link.parent_ty)?,
146        };
147        Some((parent_ref, link.this_ref))
148    }
149
150    /// Iterates over all aliases for this config.
151    pub fn aliases(&self) -> impl Iterator<Item = (&'a str, AliasOptions)> + '_ {
152        self.data.aliases()
153    }
154
155    /// Returns a prioritized list of absolute paths to the specified param (higher-priority paths first).
156    /// For the result to make sense, the param must be a part of this config.
157    #[doc(hidden)] // too low-level
158    pub fn all_paths_for_param(
159        &self,
160        param: &'static ParamMetadata,
161    ) -> impl Iterator<Item = (String, AliasOptions)> + '_ {
162        self.data.all_paths_for_param(param)
163    }
164}
165
166/// Mutable reference to a specific configuration inside [`ConfigSchema`].
167#[derive(Debug)]
168pub struct ConfigMut<'a> {
169    schema: &'a mut ConfigSchema,
170    prefix: String,
171    type_id: any::TypeId,
172}
173
174impl ConfigMut<'_> {
175    /// Gets the config prefix.
176    pub fn prefix(&self) -> &str {
177        &self.prefix
178    }
179
180    /// Iterates over all aliases for this config.
181    pub fn aliases(&self) -> impl Iterator<Item = (&str, AliasOptions)> + '_ {
182        let data = &self.schema.configs[self.prefix.as_str()].inner[&self.type_id];
183        data.aliases()
184    }
185
186    /// Pushes an additional alias for the config.
187    ///
188    /// # Errors
189    ///
190    /// Returns an error if adding a config leads to violations of fundamental invariants
191    /// (same as for [`ConfigSchema::insert()`]).
192    pub fn push_alias(self, alias: &'static str) -> anyhow::Result<Self> {
193        self.push_alias_inner(alias, AliasOptions::new())
194    }
195
196    /// Same as [`Self::push_alias()`], but also marks the alias as deprecated.
197    ///
198    /// # Errors
199    ///
200    /// Returns an error if adding a config leads to violations of fundamental invariants
201    /// (same as for [`ConfigSchema::insert()`]).
202    pub fn push_deprecated_alias(self, alias: &'static str) -> anyhow::Result<Self> {
203        self.push_alias_inner(
204            alias,
205            AliasOptions {
206                is_deprecated: true,
207            },
208        )
209    }
210
211    fn push_alias_inner(self, alias: &'static str, options: AliasOptions) -> anyhow::Result<Self> {
212        let mut patched = PatchedSchema::new(self.schema);
213        patched.insert_alias(self.prefix.clone(), self.type_id, Pointer(alias), options)?;
214        patched.commit();
215        Ok(self)
216    }
217}
218
219#[derive(Debug, Clone, Default)]
220struct ConfigsForPrefix {
221    inner: HashMap<any::TypeId, ConfigData>,
222    by_depth: BTreeSet<(usize, any::TypeId)>,
223}
224
225impl ConfigsForPrefix {
226    fn by_depth(&self) -> impl Iterator<Item = &ConfigData> + '_ {
227        self.by_depth.iter().map(|(_, ty)| &self.inner[ty])
228    }
229
230    fn insert(&mut self, ty: any::TypeId, depth: Option<usize>, data: ConfigData) {
231        self.inner.insert(ty, data);
232        if let Some(depth) = depth {
233            self.by_depth.insert((depth, ty));
234        }
235    }
236
237    fn extend(&mut self, other: Self) {
238        self.inner.extend(other.inner);
239        self.by_depth.extend(other.by_depth);
240    }
241}
242
243/// Schema for configuration. Can contain multiple configs bound to different paths.
244// TODO: more docs; e.g., document global aliases
245#[derive(Debug, Clone, Default)]
246pub struct ConfigSchema {
247    // Order configs by canonical prefix for iteration etc. Also, this makes configs iterator topologically
248    // sorted, and makes it easy to query prefix ranges, but these properties aren't used for now.
249    configs: BTreeMap<Cow<'static, str>, ConfigsForPrefix>,
250    mounting_points: MountingPoints,
251    coerce_serde_enums: bool,
252}
253
254impl ConfigSchema {
255    /// Creates a schema consisting of a single configuration at the specified prefix.
256    #[allow(clippy::missing_panics_doc)]
257    pub fn new(metadata: &'static ConfigMetadata, prefix: &'static str) -> Self {
258        let mut this = Self::default();
259        this.insert(metadata, prefix)
260            .expect("internal error: failed inserting first config to the schema");
261        this
262    }
263
264    /// Switches coercing for serde-like enums. Coercion will add path aliases for all tagged params in enum configs
265    /// added to the schema afterward (or until `coerce_serde_enums(false)` is called). Coercion will apply
266    /// to nested enum configs as well.
267    ///
268    /// For example, if a config param named `param` corresponds to the tag `SomeTag`, then alias `.some_tag.param`
269    /// (`snake_cased` tag + param name) will be added for the param. Tag aliases and param aliases will result
270    /// in additional path aliases, as expected. For example, if `param` has alias `alias` and the tag has alias `AliasTag`,
271    /// then the param will have `.alias_tag.param`, `.alias_tag.alias` and `.some_tag.alias` aliases.
272    pub fn coerce_serde_enums(&mut self, coerce: bool) -> &mut Self {
273        self.coerce_serde_enums = coerce;
274        self
275    }
276
277    /// Iterates over all configs with their canonical prefixes.
278    pub(crate) fn iter_ll(&self) -> impl Iterator<Item = (Pointer<'_>, &ConfigData)> + '_ {
279        self.configs
280            .iter()
281            .flat_map(|(prefix, data)| data.inner.values().map(move |data| (Pointer(prefix), data)))
282    }
283
284    pub(crate) fn contains_canonical_param(&self, at: Pointer<'_>) -> bool {
285        self.mounting_points.get(at.0).is_some_and(|mount| {
286            matches!(
287                mount,
288                MountingPoint::Param {
289                    is_canonical: true,
290                    ..
291                }
292            )
293        })
294    }
295
296    pub(crate) fn params_with_kv_path<'s>(
297        &'s self,
298        kv_path: &'s str,
299    ) -> impl Iterator<Item = (Pointer<'s>, BasicTypes)> + 's {
300        self.mounting_points
301            .by_kv_path(kv_path)
302            .filter_map(|(path, mount)| {
303                let expecting = match mount {
304                    MountingPoint::Param { expecting, .. } => *expecting,
305                    MountingPoint::Config => return None,
306                };
307                Some((path, expecting))
308            })
309    }
310
311    /// Iterates over all configs contained in this schema. A unique key for a config is its type + location;
312    /// i.e., multiple returned refs may have the same config type xor same location (never both).
313    pub fn iter(&self) -> impl Iterator<Item = ConfigRef<'_>> + '_ {
314        self.configs.iter().flat_map(move |(prefix, data)| {
315            data.by_depth().map(move |data| ConfigRef {
316                schema: self,
317                prefix: prefix.as_ref(),
318                data,
319            })
320        })
321    }
322
323    /// Lists all prefixes for the specified config. This does not include aliases.
324    pub fn locate(&self, metadata: &'static ConfigMetadata) -> impl Iterator<Item = &str> + '_ {
325        let config_type_id = metadata.ty.id();
326        self.configs.iter().filter_map(move |(prefix, data)| {
327            data.inner
328                .contains_key(&config_type_id)
329                .then_some(prefix.as_ref())
330        })
331    }
332
333    /// Gets a reference to a config by ist unique key (metadata + canonical prefix).
334    pub fn get<'s>(
335        &'s self,
336        metadata: &'static ConfigMetadata,
337        prefix: &'s str,
338    ) -> Option<ConfigRef<'s>> {
339        let data = self.get_ll(prefix, metadata.ty.id())?;
340        Some(ConfigRef {
341            schema: self,
342            prefix,
343            data,
344        })
345    }
346
347    fn get_ll(&self, prefix: &str, ty: any::TypeId) -> Option<&ConfigData> {
348        self.configs.get(prefix)?.inner.get(&ty)
349    }
350
351    /// Gets a reference to a config by ist unique key (metadata + canonical prefix).
352    pub fn get_mut(
353        &mut self,
354        metadata: &'static ConfigMetadata,
355        prefix: &str,
356    ) -> Option<ConfigMut<'_>> {
357        let ty = metadata.ty.id();
358        if !self.configs.get(prefix)?.inner.contains_key(&ty) {
359            return None;
360        }
361
362        Some(ConfigMut {
363            schema: self,
364            prefix: prefix.to_owned(),
365            type_id: ty,
366        })
367    }
368
369    /// Returns a single reference to the specified config.
370    ///
371    /// # Errors
372    ///
373    /// Returns an error if the configuration is not registered or has more than one mount point.
374    #[allow(clippy::missing_panics_doc)] // false positive
375    pub fn single(&self, metadata: &'static ConfigMetadata) -> anyhow::Result<ConfigRef<'_>> {
376        let prefixes: Vec<_> = self.locate(metadata).take(2).collect();
377        match prefixes.as_slice() {
378            [] => anyhow::bail!(
379                "configuration `{}` is not registered in schema",
380                metadata.ty.name_in_code()
381            ),
382            &[prefix] => Ok(ConfigRef {
383                schema: self,
384                prefix,
385                data: &self.configs[prefix].inner[&metadata.ty.id()],
386            }),
387            [first, second] => anyhow::bail!(
388                "configuration `{}` is registered in at least 2 locations: {first:?}, {second:?}",
389                metadata.ty.name_in_code()
390            ),
391            _ => unreachable!(),
392        }
393    }
394
395    /// Returns a single mutable reference to the specified config.
396    ///
397    /// # Errors
398    ///
399    /// Returns an error if the configuration is not registered or has more than one mount point.
400    #[allow(clippy::missing_panics_doc)] // false positive
401    pub fn single_mut(
402        &mut self,
403        metadata: &'static ConfigMetadata,
404    ) -> anyhow::Result<ConfigMut<'_>> {
405        let mut it = self.locate(metadata);
406        let first_prefix = it.next().with_context(|| {
407            format!(
408                "configuration `{}` is not registered in schema",
409                metadata.ty.name_in_code()
410            )
411        })?;
412        if let Some(second_prefix) = it.next() {
413            anyhow::bail!(
414                "configuration `{}` is registered in at least 2 locations: {first_prefix:?}, {second_prefix:?}",
415                metadata.ty.name_in_code()
416            );
417        }
418
419        drop(it);
420        let prefix = first_prefix.to_owned();
421        Ok(ConfigMut {
422            schema: self,
423            type_id: metadata.ty.id(),
424            prefix,
425        })
426    }
427
428    /// Inserts a new configuration type at the specified place.
429    ///
430    /// # Errors
431    ///
432    /// Returns an error if adding a config leads to violations of fundamental invariants:
433    ///
434    /// - If a parameter in the new config (taking aliases into account, and params in nested / flattened configs)
435    ///   is mounted at the location of an existing config.
436    /// - Vice versa, if a config or nested config is mounted at the location of an existing param.
437    /// - If a parameter is mounted at the location of a parameter with disjoint [expected types](ParamMetadata.expecting).
438    pub fn insert(
439        &mut self,
440        metadata: &'static ConfigMetadata,
441        prefix: &'static str,
442    ) -> anyhow::Result<ConfigMut<'_>> {
443        let coerce_serde_enums = self.coerce_serde_enums;
444        let mut patched = PatchedSchema::new(self);
445        patched.insert_config(prefix, metadata, coerce_serde_enums)?;
446        patched.commit();
447        Ok(ConfigMut {
448            schema: self,
449            type_id: metadata.ty.id(),
450            prefix: prefix.to_owned(),
451        })
452    }
453}
454
455/// [`ConfigSchema`] together with a patch that can be atomically committed.
456#[derive(Debug)]
457#[must_use = "Should be `commit()`ted"]
458struct PatchedSchema<'a> {
459    base: &'a mut ConfigSchema,
460    patch: ConfigSchema,
461}
462
463impl<'a> PatchedSchema<'a> {
464    fn new(base: &'a mut ConfigSchema) -> Self {
465        Self {
466            base,
467            patch: ConfigSchema::default(),
468        }
469    }
470
471    fn mount(&self, path: &str) -> Option<&MountingPoint> {
472        self.patch
473            .mounting_points
474            .get(path)
475            .or_else(|| self.base.mounting_points.get(path))
476    }
477
478    fn insert_config(
479        &mut self,
480        prefix: &'static str,
481        metadata: &'static ConfigMetadata,
482        coerce_serde_enums: bool,
483    ) -> anyhow::Result<()> {
484        self.insert_recursively(
485            prefix.into(),
486            true,
487            ConfigData {
488                metadata,
489                parent_link: None,
490                is_top_level: true,
491                coerce_serde_enums,
492                all_paths: vec![(prefix.into(), AliasOptions::new())],
493            },
494        )
495    }
496
497    fn insert_recursively(
498        &mut self,
499        prefix: Cow<'static, str>,
500        is_new: bool,
501        data: ConfigData,
502    ) -> anyhow::Result<()> {
503        let depth = is_new.then_some(0_usize);
504        let mut pending_configs = vec![(prefix, data, depth)];
505
506        // Insert / update all nested configs recursively.
507        while let Some((prefix, data, depth)) = pending_configs.pop() {
508            // Check whether the config is already present; if so, no need to insert the config
509            // or any nested configs.
510            if is_new && self.base.get_ll(&prefix, data.metadata.ty.id()).is_some() {
511                continue;
512            }
513
514            let child_depth = depth.map(|d| d + 1);
515            let new_configs = Self::list_nested_configs(Pointer(&prefix), &data)
516                .map(|(prefix, data)| (prefix.into(), data, child_depth));
517            pending_configs.extend(new_configs);
518            self.insert_inner(prefix, depth, data)?;
519        }
520        Ok(())
521    }
522
523    fn insert_alias(
524        &mut self,
525        prefix: String,
526        config_id: any::TypeId,
527        alias: Pointer<'static>,
528        options: AliasOptions,
529    ) -> anyhow::Result<()> {
530        let config_data = &self.base.configs[prefix.as_str()].inner[&config_id];
531        if config_data
532            .all_paths
533            .iter()
534            .any(|(name, _)| name == alias.0)
535        {
536            return Ok(()); // shortcut in the no-op case
537        }
538
539        let metadata = config_data.metadata;
540        self.insert_recursively(
541            prefix.into(),
542            false,
543            ConfigData {
544                metadata,
545                parent_link: config_data.parent_link,
546                is_top_level: config_data.is_top_level,
547                coerce_serde_enums: config_data.coerce_serde_enums,
548                all_paths: vec![(alias.0.into(), options)],
549            },
550        )
551    }
552
553    fn list_nested_configs<'i>(
554        prefix: Pointer<'i>,
555        data: &'i ConfigData,
556    ) -> impl Iterator<Item = (String, ConfigData)> + 'i {
557        data.metadata.nested_configs.iter().map(move |nested| {
558            let all_paths =
559                data.all_paths_for_child(nested.name, nested.aliases, nested.tag_variant);
560            let all_paths = all_paths
561                .map(|(path, options)| (Cow::Owned(path), options))
562                .collect();
563
564            let config_data = ConfigData {
565                metadata: nested.meta,
566                parent_link: Some(ParentLink {
567                    parent_ty: data.metadata.ty.id(),
568                    this_ref: nested,
569                }),
570                is_top_level: false,
571                coerce_serde_enums: data.coerce_serde_enums,
572                all_paths,
573            };
574            (prefix.join(nested.name), config_data)
575        })
576    }
577
578    fn insert_inner(
579        &mut self,
580        prefix: Cow<'static, str>,
581        depth: Option<usize>,
582        mut data: ConfigData,
583    ) -> anyhow::Result<()> {
584        let config_name = data.metadata.ty.name_in_code();
585        let config_paths = data.all_paths.iter().map(|(name, _)| name.as_ref());
586        let config_paths = iter::once(prefix.as_ref()).chain(config_paths);
587
588        for path in config_paths {
589            if let Some(mount) = self.mount(path) {
590                match mount {
591                    MountingPoint::Config => { /* OK */ }
592                    MountingPoint::Param { .. } => {
593                        anyhow::bail!(
594                            "Cannot mount config `{}` at `{path}` because parameter(s) are already mounted at this path",
595                            data.metadata.ty.name_in_code()
596                        );
597                    }
598                }
599            }
600            self.patch
601                .mounting_points
602                .insert(path.to_owned(), MountingPoint::Config);
603        }
604
605        for param in data.metadata.params {
606            let all_paths = data.all_paths_for_param(param);
607
608            for (name_i, (full_name, _)) in all_paths.enumerate() {
609                let mut was_canonical = false;
610                if let Some(mount) = self.mount(&full_name) {
611                    let prev_expecting = match mount {
612                        MountingPoint::Param {
613                            expecting,
614                            is_canonical,
615                        } => {
616                            was_canonical = *is_canonical;
617                            *expecting
618                        }
619                        MountingPoint::Config => {
620                            anyhow::bail!(
621                                "Cannot insert param `{name}` [Rust field: `{field}`] from config `{config_name}` at `{full_name}`: \
622                                 config(s) are already mounted at this path",
623                                name = param.name,
624                                field = param.rust_field_name
625                            );
626                        }
627                    };
628
629                    if prev_expecting != param.expecting {
630                        anyhow::bail!(
631                            "Cannot insert param `{name}` [Rust field: `{field}`] from config `{config_name}` at `{full_name}`: \
632                             it expects {expecting}, while the existing param(s) mounted at this path expect {prev_expecting}",
633                            name = param.name,
634                            field = param.rust_field_name,
635                            expecting = param.expecting
636                        );
637                    }
638                }
639                let is_canonical = was_canonical || name_i == 0;
640                self.patch.mounting_points.insert(
641                    full_name,
642                    MountingPoint::Param {
643                        expecting: param.expecting,
644                        is_canonical,
645                    },
646                );
647            }
648        }
649
650        // `data` is the new data for the config, so we need to consult `base` for existing data.
651        // Unlike with params, by design we never insert same config entries in the same patch,
652        // so it's safe to *only* consult `base`.
653        let config_id = data.metadata.ty.id();
654        let prev_data = self.base.get_ll(&prefix, config_id);
655        if let Some(prev_data) = prev_data {
656            // Append new aliases to the end since their ordering determines alias priority
657            let mut all_paths = prev_data.all_paths.clone();
658            all_paths.extend_from_slice(&data.all_paths);
659            data.all_paths = all_paths;
660        }
661
662        self.patch
663            .configs
664            .entry(prefix)
665            .or_default()
666            .insert(config_id, depth, data);
667        Ok(())
668    }
669
670    fn commit(self) {
671        for (prefix, data) in self.patch.configs {
672            let prev_data = self.base.configs.entry(prefix).or_default();
673            prev_data.extend(data);
674        }
675        self.base.mounting_points.extend(self.patch.mounting_points);
676    }
677}