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]
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//! # A key that moved to another section
21//!
22//! `db::timeout` — a section, `::`, then a path inside it — is the old
23//! spelling of a key that used to live in a *different* top-level section:
24//!
25//! ```rust,no_run
26//! # #[cfg(feature = "toml")] {
27//! # use serde::Deserialize;
28//! # #[dynamic_config::dynamic_config]
29//! # #[derive(Deserialize)] struct ServerConfig { timeout: u64 }
30//! // `timeout` used to be `[db] timeout`; it is `[server] timeout` now.
31//! ServerConfig::alias("db::timeout", "timeout")?;
32//! # }
33//! # Ok::<(), dynamic_config::Error>(())
34//! ```
35//!
36//! **The type that owns the key today declares where it used to live**, and
37//! never the other way round. A `DbConfig::moved_to("server::timeout")` would
38//! be a claim on somebody else's section, resolved only if that call happened
39//! to run before `ServerConfig::init()` — and in the migration this exists for,
40//! the field has just been *deleted* from `DbConfig`, so there may be no type
41//! left to make the claim. Declared on the destination, an alias is read from
42//! the same `static` the load already consults, and reads at the call site as
43//! what it is: this field used to be over there.
44//!
45//! Only the old path may name a section. The new path is always this
46//! configuration's own, because that is the only section this type is loading.
47//!
48//! ## The old section is read from this configuration's own documents
49//!
50//! Every source this configuration lists is parsed whole — a top-level key
51//! becomes a section — so the other section is already in hand and costs no
52//! second read. What is *not* in hand is the other section's environment
53//! layer, defaults, flags or overrides: those are built from this load's own
54//! section key, and inventing a second set of them would be inventing a second
55//! precedence order.
56//!
57//! So the boundary is a real one, and it is structural rather than
58//! documentary: **two sections loaded by two builders from two file lists are
59//! two configurations, not a rename.** An alias reaches into the documents
60//! *this* load reads; a section that lives in a file this builder does not
61//! list resolves to nothing, and the load carries on without it. The upside of
62//! drawing the line there is that a watcher keeps working — the file the old
63//! spelling sits in is a file this configuration already watches.
64//!
65//! The environment's old spelling has its own answer, and a better one:
66//! `bind_env("APP_DB_TIMEOUT", "timeout")` names the variable exactly,
67//! whatever section it was once built from.
68//!
69//! An alias that supplies nothing is **not** reported as a problem, here or in
70//! the same-section case. The steady state of a *finished* migration is an
71//! alias with nothing left to carry — every file has been rewritten — so a
72//! report that flagged it would fire on precisely the deployments that did the
73//! work, and be trained away in a week. What a live alias does is visible where
74//! it matters: `check()` lists the key with the file that supplied it, and
75//! `explain` names the old spelling.
76//!
77//! # It fills a gap rather than overriding
78//!
79//! An alias supplies the new path **only when nothing else does**. A file that
80//! has been updated wins over one that has not, whatever order they merge in,
81//! and a deployment migrating one machine at a time does not get a surprise.
82//!
83//! # Where an aliased value traces back to
84//!
85//! `source_of` reports the **file that holds the old spelling**, not the alias:
86//! it names the file to edit. The alias layer carries the old path's own
87//! provenance across, so the answer is the same whether the old spelling sat
88//! next to the new one or in another section entirely.
89//!
90//! That leaves a second question a cross-section alias raises — *which*
91//! spelling, over there — and `explain` is where it is answered: the alias row
92//! names the old path next to the layer that supplied it, so
93//! `alias db::timeout in /etc/app.toml` says both hops.
94//!
95//! # The old key stops being an unknown key
96//!
97//! Unknown-key detection exists to catch typos, and an alias that silenced it
98//! would be worse than no alias: `pool.szie` would become a supported spelling.
99//! So an aliased path is registered as *known* rather than ignored — `check()`
100//! reports it as an alias, and anything else still shows up as a typo with a
101//! suggestion.
102//!
103//! A cross-section alias registers nothing, because the old key is not in this
104//! section: `db` does not become a known key of `[server]`, and a stray `db`
105//! table there is still a typo. The other side of it stands too — `[db]`'s own
106//! `check()` goes on reporting the key left behind as unknown. That is not a
107//! gap to be closed: the key really is no longer part of that section's schema,
108//! and the report naming it is how a half-finished migration stays visible.
109//! Silencing it would take a global side table keyed by section, consulted by a
110//! type that may never have heard of the alias — action at a distance whose
111//! failure mode is a typo nobody reports.
112
113use std::collections::BTreeMap;
114use std::sync::Mutex;
115
116use crate::error::Error;
117
118/// What separates a section from a path inside it, in an alias's old path.
119///
120/// `::` and not `.`, because a dotted path already means "deeper in *this*
121/// section" and one spelling cannot mean both. A reader can see at the call
122/// site which aliases reach outside, and [`check_path`](crate::layer::check_path)
123/// refuses `::` everywhere else so that a section qualifier in a path that
124/// cannot honour one is an error rather than a key with a strange name.
125pub(crate) const SECTION: &str = "::";
126
127/// Splits `db::timeout` into `(Some("db"), "timeout")`; an unqualified path
128/// into `(None, path)`.
129pub(crate) fn split_section(path: &str) -> (Option<&str>, &str) {
130 match path.split_once(SECTION) {
131 Some((section, rest)) => (Some(section), rest),
132 None => (None, path),
133 }
134}
135
136/// Checks an alias's old path, which may carry one section qualifier.
137///
138/// A section is a *top-level* key: it has no dots and no second qualifier, so
139/// `a::b::c` and `a.b::c` are refused rather than quietly meaning something.
140fn check_old_path(from: &str) -> Result<(), Error> {
141 let (section, path) = split_section(from);
142
143 if let Some(section) = section {
144 if section.is_empty() || section.contains('.') || path.contains(SECTION) {
145 return Err(Error::new(
146 crate::ErrorKind::Type,
147 format!(
148 "`{from}` is not a usable old key path: `{SECTION}` names one \
149 top-level section, as in `db{SECTION}pool.size`"
150 ),
151 ));
152 }
153 }
154
155 crate::layer::check_path(path)
156}
157
158/// The old paths that still resolve, for one configuration type.
159///
160/// `Aliases::new()` is `const`, so this lives in a `static` — which is how
161/// `#[dynamic_config]` emits it.
162#[derive(Debug, Default)]
163pub struct Aliases {
164 /// Old path → current path.
165 entries: Mutex<BTreeMap<String, String>>,
166}
167
168impl Aliases {
169 /// No aliases.
170 #[must_use]
171 pub const fn new() -> Self {
172 Self {
173 entries: Mutex::new(BTreeMap::new()),
174 }
175 }
176
177 /// A value found at `from` also appears at `to`, if nothing supplies `to`.
178 ///
179 /// `from` is the old path — the one in files written before the rename —
180 /// and `to` is where the field lives now.
181 ///
182 /// `from` may name another top-level section, `db::timeout`; `to` may not,
183 /// because the only section this configuration loads is its own.
184 ///
185 /// # Errors
186 ///
187 /// If either path names nothing, if `to` names a section, or if they are
188 /// the same path: an alias to itself is a loop that would never resolve to
189 /// anything new.
190 pub fn add(&self, from: &str, to: &str) -> Result<(), Error> {
191 check_old_path(from)?;
192
193 if to.contains(SECTION) {
194 return Err(Error::new(
195 crate::ErrorKind::Type,
196 format!(
197 "`{to}` names another section, and an alias's new path is \
198 always in this configuration's own section; a section \
199 qualifier belongs on the old path, as in \
200 `alias(\"{to}\", ..)`"
201 ),
202 ));
203 }
204
205 crate::layer::check_path(to)?;
206
207 if from == to {
208 return Err(Error::new(
209 crate::ErrorKind::Type,
210 format!("`{from}` cannot be an alias for itself"),
211 ));
212 }
213
214 {
215 let mut entries = self.lock();
216
217 // Chains resolve — `a → b` plus `b → c` carries a value from `a`
218 // to `c`, in one deterministic pass — but a *cycle* would resolve
219 // to whichever alias happened to fire first, silently. Walk the
220 // chain the new edge would create; if it comes back around, the
221 // rename is contradictory and the caller should hear so now.
222 //
223 // A section-qualified old path can only ever be the *head* of a
224 // chain: `to` is never qualified, so no edge can point back at one.
225 // That is what bounds a cross-section rename to a single hop —
226 // by construction rather than by a depth counter.
227 let mut cursor = to.to_owned();
228 let mut hops = 0usize;
229
230 while let Some(next) = entries.get(&cursor) {
231 if next == from || hops > entries.len() {
232 return Err(Error::new(
233 crate::ErrorKind::Type,
234 format!(
235 "`{from}` -> `{to}` closes an alias cycle; renames \
236 must form a chain, not a loop"
237 ),
238 ));
239 }
240
241 cursor = next.clone();
242 hops += 1;
243 }
244
245 entries.insert(from.to_owned(), to.to_owned());
246 }
247
248 Ok(())
249 }
250
251 /// Drops every alias.
252 pub fn clear(&self) {
253 self.lock().clear();
254 }
255
256 /// Whether anything is aliased.
257 #[must_use]
258 pub fn is_empty(&self) -> bool {
259 self.lock().is_empty()
260 }
261
262 /// Every `(old, current)` pair, in path order.
263 #[must_use]
264 pub fn pairs(&self) -> Vec<(String, String)> {
265 self.lock()
266 .iter()
267 .map(|(from, to)| (from.clone(), to.clone()))
268 .collect()
269 }
270
271 /// The top-level keys an alias makes legitimate, so unknown-key detection
272 /// does not report them as typos.
273 ///
274 /// A section-qualified old path contributes nothing: its key is in another
275 /// section, so making `db` legitimate *here* would silence a genuine stray
276 /// `db` table in this one.
277 #[must_use]
278 pub fn known_keys(&self) -> Vec<String> {
279 self.lock()
280 .keys()
281 .filter(|path| !path.contains(SECTION))
282 .filter_map(|path| path.split('.').next().map(str::to_owned))
283 .collect()
284 }
285
286 fn lock(&self) -> std::sync::MutexGuard<'_, BTreeMap<String, String>> {
287 self.entries
288 .lock()
289 .unwrap_or_else(std::sync::PoisonError::into_inner)
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 #[test]
298 fn a_path_that_names_nothing_is_refused() {
299 let aliases = Aliases::new();
300
301 assert!(aliases.add("", "pool.max_size").is_err());
302 assert!(aliases.add("pool..size", "pool.max_size").is_err());
303 assert!(aliases.add("pool.size", "").is_err());
304 }
305
306 #[test]
307 fn an_alias_to_itself_is_refused() {
308 let aliases = Aliases::new();
309
310 let error = aliases.add("pool.size", "pool.size").unwrap_err();
311
312 assert!(error.to_string().contains("itself"), "{error}");
313 }
314
315 #[test]
316 fn the_old_paths_top_level_key_counts_as_known() {
317 let aliases = Aliases::new();
318
319 aliases.add("legacy.size", "pool.max_size").unwrap();
320 aliases.add("host", "hostname").unwrap();
321
322 let known = aliases.known_keys();
323
324 assert!(known.contains(&"legacy".to_owned()));
325 assert!(known.contains(&"host".to_owned()));
326 }
327
328 #[test]
329 fn a_section_qualified_old_path_is_accepted() {
330 let aliases = Aliases::new();
331
332 aliases.add("db::timeout", "timeout").unwrap();
333
334 assert_eq!(
335 aliases.pairs(),
336 vec![("db::timeout".to_owned(), "timeout".to_owned())]
337 );
338 }
339
340 #[test]
341 fn only_the_old_path_may_name_a_section() {
342 let aliases = Aliases::new();
343
344 let error = aliases.add("timeout", "server::timeout").unwrap_err();
345
346 assert!(error.to_string().contains("own section"), "{error}");
347 }
348
349 #[test]
350 fn a_qualifier_names_one_top_level_section() {
351 let aliases = Aliases::new();
352
353 assert!(aliases.add("::timeout", "timeout").is_err(), "no section");
354 assert!(aliases.add("a::b::c", "timeout").is_err(), "two of them");
355 assert!(aliases.add("a.b::c", "timeout").is_err(), "not top level");
356 assert!(aliases.add("db::", "timeout").is_err(), "no path");
357 }
358
359 /// A qualified old path can only be the head of a chain, so the cycle walk
360 /// — which follows unqualified targets — can never reach one. Pinned
361 /// because it is what bounds a cross-section rename to one hop.
362 #[test]
363 fn a_cross_section_alias_cannot_be_chained_into() {
364 let aliases = Aliases::new();
365
366 aliases.add("db::timeout", "timeout").unwrap();
367 aliases.add("timeout", "deadline").unwrap();
368
369 let targets: Vec<String> = aliases.pairs().into_iter().map(|(_, to)| to).collect();
370
371 assert!(
372 targets.iter().all(|to| !to.contains(SECTION)),
373 "nothing points at a section-qualified path: {targets:?}"
374 );
375 }
376
377 #[test]
378 fn a_foreign_sections_key_is_not_known_in_this_one() {
379 let aliases = Aliases::new();
380
381 aliases.add("db::timeout", "timeout").unwrap();
382
383 assert!(
384 aliases.known_keys().is_empty(),
385 "`db` is another section's key, not a legitimate key here"
386 );
387 }
388
389 #[test]
390 fn aliasing_the_same_path_twice_replaces_rather_than_layers() {
391 let aliases = Aliases::new();
392
393 aliases.add("old", "first").unwrap();
394 aliases.add("old", "second").unwrap();
395
396 assert_eq!(
397 aliases.pairs(),
398 vec![("old".to_owned(), "second".to_owned())]
399 );
400 }
401}