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}