Skip to main content

dynamic_config/
snapshot.rs

1//! Comparing one resolved configuration against another.
2//!
3//! "Configuration reloaded" is a nearly useless log line during an incident:
4//! the question is always *what* changed. A snapshot is the resolved section
5//! before it becomes a struct, so two of them can be compared key by key.
6//!
7//! **Only paths are reported, never values.** That keeps a reload of
8//! `db.password` from doing in the log exactly what `#[config(secret)]` exists
9//! to prevent. Code that needs the values already has both sides in an
10//! `on_reload` callback.
11
12use std::fmt;
13
14use figment::value::{Dict, Value};
15use serde::de::DeserializeOwned;
16
17use crate::error::{Error, ErrorKind};
18
19/// What happened to one key between two snapshots.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21#[non_exhaustive]
22pub enum ChangeKind {
23    /// Nothing supplied it before.
24    Added,
25    /// Nothing supplies it any more.
26    Removed,
27    /// It has a different value.
28    Modified,
29}
30
31impl fmt::Display for ChangeKind {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        f.write_str(match self {
34            Self::Added => "added",
35            Self::Removed => "removed",
36            Self::Modified => "changed",
37        })
38    }
39}
40
41/// One difference between two snapshots.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Change {
44    /// Dotted key path, relative to the section.
45    pub path: String,
46    /// What happened to it.
47    pub kind: ChangeKind,
48}
49
50impl fmt::Display for Change {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        write!(f, "{} {}", self.path, self.kind)
53    }
54}
55
56/// A resolved configuration section, before it becomes a struct.
57///
58/// Obtained from [`snapshot`](crate::snapshot), compared with
59/// [`diff`](Self::diff), and turned into a struct with
60/// [`extract`](Self::extract).
61#[derive(Debug, Clone, Default)]
62pub struct Snapshot {
63    values: Dict,
64}
65
66impl Snapshot {
67    pub(crate) fn new(values: Dict) -> Self {
68        Self { values }
69    }
70
71    /// Deserializes the section into `T`.
72    ///
73    /// # Errors
74    ///
75    /// If a required value is missing or cannot become the field's type.
76    ///
77    /// Errors here carry the key path but **not** the originating file or
78    /// variable: provenance lives in figment's metadata, which a snapshot has
79    /// already left behind. Call [`load`](crate::load) when the error is going
80    /// to be read by a person.
81    pub fn extract<T: DeserializeOwned>(&self) -> Result<T, Error> {
82        Value::from(self.values.clone())
83            .deserialize()
84            .map_err(|error: figment::Error| {
85                let mut translated = Error::new(ErrorKind::Type, error.to_string());
86
87                for segment in error.path.iter().rev() {
88                    translated = translated.prepend_key(segment);
89                }
90
91                translated
92            })
93    }
94
95    /// Every key that differs between `self` and `other`, in path order.
96    ///
97    /// `self` is the earlier snapshot, so a key present only in `other` is
98    /// [`Added`](ChangeKind::Added).
99    #[must_use]
100    pub fn diff(&self, other: &Self) -> Vec<Change> {
101        let mut changes = Vec::new();
102
103        compare(&self.values, &other.values, &mut Vec::new(), &mut changes);
104        changes.sort_by(|left, right| left.path.cmp(&right.path));
105
106        changes
107    }
108
109    /// Reads one value by dotted path, without a struct to hold it.
110    ///
111    /// For the shape a program does not know at compile time: a plugin's
112    /// section, a user-defined table. Everything else should go through a
113    /// struct, where a typo is a compile error rather than a runtime one.
114    ///
115    /// # Errors
116    ///
117    /// If nothing supplies `path`, or the value cannot become `T`.
118    pub fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, Error> {
119        let value = self.at(path).ok_or_else(|| {
120            Error::new(ErrorKind::Missing, "no value at this path").prepend_key(path)
121        })?;
122
123        value.deserialize().map_err(|error: figment::Error| {
124            Error::new(ErrorKind::Type, error.to_string()).prepend_key(path)
125        })
126    }
127
128    /// Whether anything supplies `path`.
129    #[must_use]
130    pub fn contains(&self, path: &str) -> bool {
131        self.at(path).is_some()
132    }
133
134    /// The table at `path`, as a snapshot of its own.
135    ///
136    /// The analogue of Viper's `Sub`: hand a subsystem the part of the
137    /// configuration it owns and nothing else.
138    #[must_use]
139    pub fn sub(&self, path: &str) -> Option<Self> {
140        match self.at(path)? {
141            Value::Dict(_, nested) => Some(Self::new(nested.clone())),
142            _ => None,
143        }
144    }
145
146    fn at(&self, path: &str) -> Option<&Value> {
147        let mut segments = path.split('.');
148        let mut current = self.values.get(segments.next()?)?;
149
150        for segment in segments {
151            let Value::Dict(_, nested) = current else {
152                return None;
153            };
154
155            current = nested.get(segment)?;
156        }
157
158        Some(current)
159    }
160
161    /// The dotted path of every leaf, in order.
162    #[must_use]
163    pub fn leaf_paths(&self) -> Vec<String> {
164        let mut paths = Vec::new();
165
166        collect_leaves(&self.values, &mut Vec::new(), &mut paths);
167
168        paths
169    }
170
171    /// The section's immediate keys — the level a struct's fields map onto.
172    #[must_use]
173    pub fn top_level_keys(&self) -> Vec<String> {
174        self.values.keys().cloned().collect()
175    }
176
177    /// The resolved tree, for the few places that need it whole.
178    pub(crate) fn values(&self) -> &Dict {
179        &self.values
180    }
181
182    /// Whether the section resolved to nothing at all.
183    #[must_use]
184    pub fn is_empty(&self) -> bool {
185        self.values.is_empty()
186    }
187}
188
189/// Records the dotted path of every leaf, treating an empty table as one.
190fn collect_leaves(values: &Dict, path: &mut Vec<String>, paths: &mut Vec<String>) {
191    for (key, value) in values {
192        path.push(key.clone());
193
194        match value {
195            Value::Dict(_, nested) if !nested.is_empty() => {
196                collect_leaves(nested, path, paths);
197            }
198            _ => paths.push(path.join(".")),
199        }
200
201        path.pop();
202    }
203}
204
205/// Walks two tables in step, recording the leaves that differ.
206fn compare(previous: &Dict, current: &Dict, path: &mut Vec<String>, changes: &mut Vec<Change>) {
207    for (key, before) in previous {
208        path.push(key.clone());
209
210        match current.get(key) {
211            Some(after) => compare_values(before, after, path, changes),
212            None => changes.push(change(path, ChangeKind::Removed)),
213        }
214
215        path.pop();
216    }
217
218    for key in current.keys() {
219        if previous.contains_key(key) {
220            continue;
221        }
222
223        path.push(key.clone());
224        changes.push(change(path, ChangeKind::Added));
225        path.pop();
226    }
227}
228
229fn compare_values(
230    before: &Value,
231    after: &Value,
232    path: &mut Vec<String>,
233    changes: &mut Vec<Change>,
234) {
235    match (before, after) {
236        // Two tables are compared key by key, so a change deep inside one is
237        // reported at the leaf that actually moved rather than at the table.
238        (Value::Dict(_, before), Value::Dict(_, after)) => compare(before, after, path, changes),
239        _ if values_equal(before, after) => {}
240        _ => changes.push(change(path, ChangeKind::Modified)),
241    }
242}
243
244/// figment values carry a provenance tag that takes part in `PartialEq`, so two
245/// identical values from different providers compare unequal. Rendering strips
246/// the tag, which is the comparison anyone actually means here.
247fn values_equal(before: &Value, after: &Value) -> bool {
248    format!("{before:?}") == format!("{after:?}")
249}
250
251fn change(path: &[String], kind: ChangeKind) -> Change {
252    Change {
253        path: path.join("."),
254        kind,
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    fn dict(entries: &[(&str, Value)]) -> Dict {
263        entries
264            .iter()
265            .map(|(key, value)| ((*key).to_owned(), value.clone()))
266            .collect()
267    }
268
269    fn snapshot(entries: &[(&str, Value)]) -> Snapshot {
270        Snapshot::new(dict(entries))
271    }
272
273    #[test]
274    fn identical_snapshots_have_no_changes() {
275        let one = snapshot(&[("host", "a".into()), ("port", 1u16.into())]);
276        let two = snapshot(&[("host", "a".into()), ("port", 1u16.into())]);
277
278        assert!(one.diff(&two).is_empty());
279    }
280
281    #[test]
282    fn a_modified_value_names_its_key_but_not_its_value() {
283        let one = snapshot(&[("password", "hunter2".into())]);
284        let two = snapshot(&[("password", "letmein".into())]);
285
286        let changes = one.diff(&two);
287
288        assert_eq!(changes.len(), 1);
289        assert_eq!(changes[0].path, "password");
290        assert_eq!(changes[0].kind, ChangeKind::Modified);
291
292        let rendered = changes[0].to_string();
293        assert_eq!(rendered, "password changed");
294        assert!(!rendered.contains("hunter2"), "{rendered}");
295        assert!(!rendered.contains("letmein"), "{rendered}");
296    }
297
298    #[test]
299    fn additions_and_removals_are_told_apart() {
300        let one = snapshot(&[("gone", 1u16.into())]);
301        let two = snapshot(&[("fresh", 1u16.into())]);
302
303        let changes = one.diff(&two);
304
305        assert_eq!(
306            changes,
307            [
308                Change {
309                    path: "fresh".to_owned(),
310                    kind: ChangeKind::Added,
311                },
312                Change {
313                    path: "gone".to_owned(),
314                    kind: ChangeKind::Removed,
315                },
316            ]
317        );
318    }
319
320    #[test]
321    fn a_change_inside_a_table_is_reported_at_the_leaf() {
322        let one = snapshot(&[(
323            "pool",
324            Value::from(dict(&[("max", 1u16.into()), ("min", 1u16.into())])),
325        )]);
326        let two = snapshot(&[(
327            "pool",
328            Value::from(dict(&[("max", 2u16.into()), ("min", 1u16.into())])),
329        )]);
330
331        let changes = one.diff(&two);
332
333        assert_eq!(changes.len(), 1);
334        assert_eq!(changes[0].path, "pool.max", "not just `pool`");
335    }
336
337    #[test]
338    fn a_table_replaced_by_a_scalar_is_one_change() {
339        let one = snapshot(&[("pool", Value::from(dict(&[("max", 1u16.into())])))]);
340        let two = snapshot(&[("pool", 1u16.into())]);
341
342        let changes = one.diff(&two);
343
344        assert_eq!(changes.len(), 1);
345        assert_eq!(changes[0].path, "pool");
346        assert_eq!(changes[0].kind, ChangeKind::Modified);
347    }
348
349    #[test]
350    fn a_value_can_be_read_by_path_without_a_struct() {
351        let snapshot = snapshot(&[
352            ("host", "a".into()),
353            ("pool", Value::from(dict(&[("max", 32u16.into())]))),
354        ]);
355
356        assert_eq!(snapshot.get::<String>("host").unwrap(), "a");
357        assert_eq!(snapshot.get::<u16>("pool.max").unwrap(), 32);
358        assert!(snapshot.contains("pool.max"));
359        assert!(!snapshot.contains("pool.min"));
360    }
361
362    #[test]
363    fn a_missing_path_and_a_wrong_type_are_told_apart() {
364        let snapshot = snapshot(&[("host", "a".into())]);
365
366        assert_eq!(
367            snapshot.get::<String>("nowhere").unwrap_err().kind(),
368            ErrorKind::Missing
369        );
370        assert_eq!(
371            snapshot.get::<u16>("host").unwrap_err().kind(),
372            ErrorKind::Type
373        );
374        // Walking through a scalar is a missing path, not a type error.
375        assert_eq!(
376            snapshot.get::<u16>("host.port").unwrap_err().kind(),
377            ErrorKind::Missing
378        );
379    }
380
381    #[test]
382    fn a_sub_snapshot_carries_only_its_own_table() {
383        let snapshot = snapshot(&[
384            ("host", "a".into()),
385            ("pool", Value::from(dict(&[("max", 32u16.into())]))),
386        ]);
387
388        let pool = snapshot.sub("pool").expect("`pool` is a table");
389
390        assert_eq!(pool.get::<u16>("max").unwrap(), 32);
391        assert!(!pool.contains("host"));
392
393        assert!(snapshot.sub("host").is_none(), "a scalar is not a table");
394    }
395
396    #[test]
397    fn leaf_paths_reach_into_nested_tables() {
398        let snapshot = snapshot(&[
399            ("host", "a".into()),
400            ("pool", Value::from(dict(&[("max", 1u16.into())]))),
401        ]);
402
403        assert_eq!(snapshot.leaf_paths(), ["host", "pool.max"]);
404        assert_eq!(snapshot.top_level_keys(), ["host", "pool"]);
405    }
406
407    #[test]
408    fn extraction_reports_the_path_it_failed_at() {
409        #[derive(serde::Deserialize, Debug)]
410        #[allow(dead_code)]
411        struct Target {
412            port: u16,
413        }
414
415        let error = snapshot(&[("port", "not-a-number".into())])
416            .extract::<Target>()
417            .unwrap_err();
418
419        assert_eq!(error.path(), "port");
420    }
421}