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}