saluki_config/dynamic/
diff.rs

1//! Functions for diffing configuration values.
2
3use super::event::ConfigChangeEvent;
4
5/// Diffs two configuration values and returns a list of changes.
6///
7/// Only keys present in `new_config` are considered: a key that exists in `old_config` but is absent from
8/// `new_config` is silently treated as unchanged, and no [`ConfigChangeEvent`] is emitted for its removal.
9pub fn diff_config(old_config: &figment::value::Value, new_config: &figment::value::Value) -> Vec<ConfigChangeEvent> {
10    let mut changes = Vec::new();
11    diff_recursive(old_config, new_config, "", &mut changes);
12    changes
13}
14
15fn diff_recursive(
16    old_config: &figment::value::Value, new_config: &figment::value::Value, path: &str,
17    changes: &mut Vec<ConfigChangeEvent>,
18) {
19    if let (Some(old_dict), Some(new_dict)) = (old_config.as_dict(), new_config.as_dict()) {
20        for (key, new_value) in new_dict {
21            let current_path = if path.is_empty() {
22                key.clone()
23            } else {
24                format!("{}.{}", path, key)
25            };
26
27            match old_dict.get(key) {
28                Some(old_value) => {
29                    if old_value != new_value {
30                        if new_value.as_dict().is_some() && old_value.as_dict().is_some() {
31                            diff_recursive(old_value, new_value, &current_path, changes);
32                        } else {
33                            changes.push(ConfigChangeEvent {
34                                key: current_path,
35                                old_value: Some(serde_json::to_value(old_value).unwrap()),
36                                new_value: Some(serde_json::to_value(new_value).unwrap()),
37                            });
38                        }
39                    }
40                }
41                None => {
42                    changes.push(ConfigChangeEvent {
43                        key: current_path,
44                        old_value: None,
45                        new_value: Some(serde_json::to_value(new_value).unwrap()),
46                    });
47                }
48            }
49        }
50    }
51}
52
53#[cfg(test)]
54mod tests {
55    use figment::{providers::Serialized, value::Value, Figment};
56    use serde_json::json;
57
58    use super::*;
59
60    fn to_figment_value(json: serde_json::Value) -> Value {
61        let serialized = Serialized::defaults(json);
62        let value: Value = Figment::from(serialized).extract().unwrap();
63        value
64    }
65
66    #[test]
67    fn diff_config_detects_modified_added_and_nested_changes() {
68        let old_json = json!({
69            "a": "original",
70            "nested": {
71                "b": 100
72            },
73            "unchanged": true
74        });
75
76        let new_json = json!({
77            "a": "updated", // modified
78            "nested": {
79                "b": 200, // nested modified
80                "c": "new"  // nested added
81            },
82            "unchanged": true,
83            "d": "added" // added
84        });
85
86        let old_config = to_figment_value(old_json);
87        let new_config = to_figment_value(new_json);
88
89        let changes = diff_config(&old_config, &new_config);
90
91        // We expect 4 changes in total.
92        assert_eq!(changes.len(), 4);
93
94        assert!(changes.contains(&ConfigChangeEvent {
95            key: "a".to_string(),
96            old_value: Some("original".into()),
97            new_value: Some("updated".into())
98        }));
99        assert!(changes.contains(&ConfigChangeEvent {
100            key: "nested.b".to_string(),
101            old_value: Some(100.into()),
102            new_value: Some(200.into())
103        }));
104        assert!(changes.contains(&ConfigChangeEvent {
105            key: "nested.c".to_string(),
106            old_value: None,
107            new_value: Some("new".into())
108        }));
109        assert!(changes.contains(&ConfigChangeEvent {
110            key: "d".to_string(),
111            old_value: None,
112            new_value: Some("added".into())
113        }));
114    }
115
116    #[test]
117    fn diff_config_reports_no_changes_for_identical_configs() {
118        let old_json = json!({
119            "a": "original",
120            "nested": {
121                "b": 100
122            },
123        });
124
125        let new_json = old_json.clone();
126
127        let old_config = to_figment_value(old_json);
128        let new_config = to_figment_value(new_json);
129
130        let changes = diff_config(&old_config, &new_config);
131
132        assert!(changes.is_empty());
133    }
134
135    #[test]
136    fn diff_config_ignores_keys_removed_from_new_config() {
137        // `diff_config` only walks keys present in `new_config`, so a key that disappears between snapshots produces
138        // no change event at all. This test pins that silent-drop behavior (documented on `diff_config`): a consumer
139        // watching a removed key will never be told it went away.
140        let old_json = json!({
141            "kept": "same",
142            "removed": "gone",
143            "nested": {
144                "kept": 1,
145                "removed": 2
146            }
147        });
148
149        let new_json = json!({
150            "kept": "same",
151            "nested": {
152                "kept": 1
153            }
154        });
155
156        let old_config = to_figment_value(old_json);
157        let new_config = to_figment_value(new_json);
158
159        let changes = diff_config(&old_config, &new_config);
160
161        assert!(
162            changes.is_empty(),
163            "removed keys should produce no change events, got: {:?}",
164            changes
165        );
166    }
167}