1use std::fmt;
13
14use figment::value::{Dict, Value};
15use serde::de::DeserializeOwned;
16
17use crate::error::{Error, ErrorKind};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21#[non_exhaustive]
22pub enum ChangeKind {
23 Added,
25 Removed,
27 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#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Change {
44 pub path: String,
46 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#[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 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 #[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 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 #[must_use]
130 pub fn contains(&self, path: &str) -> bool {
131 self.at(path).is_some()
132 }
133
134 #[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 #[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 #[must_use]
173 pub fn top_level_keys(&self) -> Vec<String> {
174 self.values.keys().cloned().collect()
175 }
176
177 pub(crate) fn values(&self) -> &Dict {
179 &self.values
180 }
181
182 #[must_use]
184 pub fn is_empty(&self) -> bool {
185 self.values.is_empty()
186 }
187}
188
189fn 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
205fn 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 (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
244fn 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 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}