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        {
86            let mut entries = self.lock();
87
88            // Chains resolve — `a → b` plus `b → c` carries a value from `a`
89            // to `c`, in one deterministic pass — but a *cycle* would resolve
90            // to whichever alias happened to fire first, silently. Walk the
91            // chain the new edge would create; if it comes back around, the
92            // rename is contradictory and the caller should hear so now.
93            let mut cursor = to.to_owned();
94            let mut hops = 0usize;
95
96            while let Some(next) = entries.get(&cursor) {
97                if next == from || hops > entries.len() {
98                    return Err(Error::new(
99                        crate::ErrorKind::Type,
100                        format!(
101                            "`{from}` -> `{to}` closes an alias cycle; renames \
102                             must form a chain, not a loop"
103                        ),
104                    ));
105                }
106
107                cursor = next.clone();
108                hops += 1;
109            }
110
111            entries.insert(from.to_owned(), to.to_owned());
112        }
113
114        Ok(())
115    }
116
117    /// Drops every alias.
118    pub fn clear(&self) {
119        self.lock().clear();
120    }
121
122    /// Whether anything is aliased.
123    #[must_use]
124    pub fn is_empty(&self) -> bool {
125        self.lock().is_empty()
126    }
127
128    /// Every `(old, current)` pair, in path order.
129    #[must_use]
130    pub fn pairs(&self) -> Vec<(String, String)> {
131        self.lock()
132            .iter()
133            .map(|(from, to)| (from.clone(), to.clone()))
134            .collect()
135    }
136
137    /// The top-level keys an alias makes legitimate, so unknown-key detection
138    /// does not report them as typos.
139    #[must_use]
140    pub fn known_keys(&self) -> Vec<String> {
141        self.lock()
142            .keys()
143            .filter_map(|path| path.split('.').next().map(str::to_owned))
144            .collect()
145    }
146
147    fn lock(&self) -> std::sync::MutexGuard<'_, BTreeMap<String, String>> {
148        self.entries
149            .lock()
150            .unwrap_or_else(std::sync::PoisonError::into_inner)
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn a_path_that_names_nothing_is_refused() {
160        let aliases = Aliases::new();
161
162        assert!(aliases.add("", "pool.max_size").is_err());
163        assert!(aliases.add("pool..size", "pool.max_size").is_err());
164        assert!(aliases.add("pool.size", "").is_err());
165    }
166
167    #[test]
168    fn an_alias_to_itself_is_refused() {
169        let aliases = Aliases::new();
170
171        let error = aliases.add("pool.size", "pool.size").unwrap_err();
172
173        assert!(error.to_string().contains("itself"), "{error}");
174    }
175
176    #[test]
177    fn the_old_paths_top_level_key_counts_as_known() {
178        let aliases = Aliases::new();
179
180        aliases.add("legacy.size", "pool.max_size").unwrap();
181        aliases.add("host", "hostname").unwrap();
182
183        let known = aliases.known_keys();
184
185        assert!(known.contains(&"legacy".to_owned()));
186        assert!(known.contains(&"host".to_owned()));
187    }
188
189    #[test]
190    fn aliasing_the_same_path_twice_replaces_rather_than_layers() {
191        let aliases = Aliases::new();
192
193        aliases.add("old", "first").unwrap();
194        aliases.add("old", "second").unwrap();
195
196        assert_eq!(
197            aliases.pairs(),
198            vec![("old".to_owned(), "second".to_owned())]
199        );
200    }
201}