Skip to main content

dynamic_config/
aliases.rs

1//! Old key paths that still work after a rename.
2//!
3//! `#[serde(alias = "..")]` covers a renamed *field*. It does not cover a
4//! renamed *path*: a value that moved from `pool.size` to `pool.max_size`, or
5//! out of one section into another, is a different key as far as the loader is
6//! concerned.
7//!
8//! ```rust,no_run
9//! # #[cfg(feature = "toml")] {
10//! # use serde::Deserialize;
11//! # #[dynamic_config::dynamic_config(files = ["config.toml"], key = "db")]
12//! # #[derive(Deserialize)] struct DbConfig { pool: Pool }
13//! # #[derive(Deserialize)] struct Pool { max_size: u16 }
14//! // Files written before the rename keep working.
15//! DbConfig::alias("pool.size", "pool.max_size")?;
16//! # }
17//! # Ok::<(), dynamic_config::Error>(())
18//! ```
19//!
20//! # It fills a gap rather than overriding
21//!
22//! An alias supplies the new path **only when nothing else does**. A file that
23//! has been updated wins over one that has not, whatever order they merge in,
24//! and a deployment migrating one machine at a time does not get a surprise.
25//!
26//! # Where an aliased value traces back to
27//!
28//! `source_of` reports the **file that holds the old spelling**, not the alias.
29//! figment attributes every path under a section to whichever provider supplied
30//! the section, so the alias itself never surfaces — and that is the more useful
31//! answer: it names the file to edit.
32//!
33//! # The old key stops being an unknown key
34//!
35//! Unknown-key detection exists to catch typos, and an alias that silenced it
36//! would be worse than no alias: `pool.szie` would become a supported spelling.
37//! So an aliased path is registered as *known* rather than ignored — `check()`
38//! reports it as an alias, and anything else still shows up as a typo with a
39//! suggestion.
40
41use std::collections::BTreeMap;
42use std::sync::Mutex;
43
44use crate::error::Error;
45
46/// The old paths that still resolve, for one configuration type.
47///
48/// `Aliases::new()` is `const`, so this lives in a `static` — which is how
49/// `#[dynamic_config]` emits it.
50#[derive(Debug, Default)]
51pub struct Aliases {
52    /// Old path → current path.
53    entries: Mutex<BTreeMap<String, String>>,
54}
55
56impl Aliases {
57    /// No aliases.
58    #[must_use]
59    pub const fn new() -> Self {
60        Self {
61            entries: Mutex::new(BTreeMap::new()),
62        }
63    }
64
65    /// A value found at `from` also appears at `to`, if nothing supplies `to`.
66    ///
67    /// `from` is the old path — the one in files written before the rename —
68    /// and `to` is where the field lives now.
69    ///
70    /// # Errors
71    ///
72    /// If either path names nothing, or they are the same path: an alias to
73    /// itself is a loop that would never resolve to anything new.
74    pub fn add(&self, from: &str, to: &str) -> Result<(), Error> {
75        crate::layer::check_path(from)?;
76        crate::layer::check_path(to)?;
77
78        if from == to {
79            return Err(Error::new(
80                crate::ErrorKind::Type,
81                format!("`{from}` cannot be an alias for itself"),
82            ));
83        }
84
85        self.lock().insert(from.to_owned(), to.to_owned());
86
87        Ok(())
88    }
89
90    /// Drops every alias.
91    pub fn clear(&self) {
92        self.lock().clear();
93    }
94
95    /// Whether anything is aliased.
96    #[must_use]
97    pub fn is_empty(&self) -> bool {
98        self.lock().is_empty()
99    }
100
101    /// Every `(old, current)` pair, in path order.
102    #[must_use]
103    pub fn pairs(&self) -> Vec<(String, String)> {
104        self.lock()
105            .iter()
106            .map(|(from, to)| (from.clone(), to.clone()))
107            .collect()
108    }
109
110    /// The top-level keys an alias makes legitimate, so unknown-key detection
111    /// does not report them as typos.
112    #[must_use]
113    pub fn known_keys(&self) -> Vec<String> {
114        self.lock()
115            .keys()
116            .filter_map(|path| path.split('.').next().map(str::to_owned))
117            .collect()
118    }
119
120    fn lock(&self) -> std::sync::MutexGuard<'_, BTreeMap<String, String>> {
121        self.entries
122            .lock()
123            .unwrap_or_else(std::sync::PoisonError::into_inner)
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn a_path_that_names_nothing_is_refused() {
133        let aliases = Aliases::new();
134
135        assert!(aliases.add("", "pool.max_size").is_err());
136        assert!(aliases.add("pool..size", "pool.max_size").is_err());
137        assert!(aliases.add("pool.size", "").is_err());
138    }
139
140    #[test]
141    fn an_alias_to_itself_is_refused() {
142        let aliases = Aliases::new();
143
144        let error = aliases.add("pool.size", "pool.size").unwrap_err();
145
146        assert!(error.to_string().contains("itself"), "{error}");
147    }
148
149    #[test]
150    fn the_old_paths_top_level_key_counts_as_known() {
151        let aliases = Aliases::new();
152
153        aliases.add("legacy.size", "pool.max_size").unwrap();
154        aliases.add("host", "hostname").unwrap();
155
156        let known = aliases.known_keys();
157
158        assert!(known.contains(&"legacy".to_owned()));
159        assert!(known.contains(&"host".to_owned()));
160    }
161
162    #[test]
163    fn aliasing_the_same_path_twice_replaces_rather_than_layers() {
164        let aliases = Aliases::new();
165
166        aliases.add("old", "first").unwrap();
167        aliases.add("old", "second").unwrap();
168
169        assert_eq!(
170            aliases.pairs(),
171            vec![("old".to_owned(), "second".to_owned())]
172        );
173    }
174}