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]
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}