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 pub(crate) fn without_top_level(&self, key: &str) -> Self {
131 let mut values = self.values().clone();
132 values.remove(key);
133
134 Self::new(values)
135 }
136
137 #[must_use]
139 pub fn contains(&self, path: &str) -> bool {
140 self.at(path).is_some()
141 }
142
143 #[must_use]
148 pub fn sub(&self, path: &str) -> Option<Self> {
149 match self.at(path)? {
150 Value::Dict(_, nested) => Some(Self::new(nested.clone())),
151 _ => None,
152 }
153 }
154
155 fn at(&self, path: &str) -> Option<&Value> {
156 let mut segments = path.split('.');
157 let mut current = self.values.get(segments.next()?)?;
158
159 for segment in segments {
160 let Value::Dict(_, nested) = current else {
161 return None;
162 };
163
164 current = nested.get(segment)?;
165 }
166
167 Some(current)
168 }
169
170 #[must_use]
172 pub fn leaf_paths(&self) -> Vec<String> {
173 let mut paths = Vec::new();
174
175 collect_leaves(&self.values, &mut Vec::new(), &mut paths);
176
177 paths
178 }
179
180 #[must_use]
182 pub fn top_level_keys(&self) -> Vec<String> {
183 self.values.keys().cloned().collect()
184 }
185
186 pub(crate) fn values(&self) -> &Dict {
188 &self.values
189 }
190
191 #[must_use]
193 pub fn is_empty(&self) -> bool {
194 self.values.is_empty()
195 }
196}
197
198fn collect_leaves(values: &Dict, path: &mut Vec<String>, paths: &mut Vec<String>) {
200 for (key, value) in values {
201 path.push(key.clone());
202
203 match value {
204 Value::Dict(_, nested) if !nested.is_empty() => {
205 collect_leaves(nested, path, paths);
206 }
207 _ => paths.push(path.join(".")),
208 }
209
210 path.pop();
211 }
212}
213
214fn compare(previous: &Dict, current: &Dict, path: &mut Vec<String>, changes: &mut Vec<Change>) {
216 for (key, before) in previous {
217 path.push(key.clone());
218
219 match current.get(key) {
220 Some(after) => compare_values(before, after, path, changes),
221 None => changes.push(change(path, ChangeKind::Removed)),
222 }
223
224 path.pop();
225 }
226
227 for key in current.keys() {
228 if previous.contains_key(key) {
229 continue;
230 }
231
232 path.push(key.clone());
233 changes.push(change(path, ChangeKind::Added));
234 path.pop();
235 }
236}
237
238fn compare_values(
239 before: &Value,
240 after: &Value,
241 path: &mut Vec<String>,
242 changes: &mut Vec<Change>,
243) {
244 match (before, after) {
245 (Value::Dict(_, before), Value::Dict(_, after)) => compare(before, after, path, changes),
248 _ if values_equal(before, after) => {}
249 _ => changes.push(change(path, ChangeKind::Modified)),
250 }
251}
252
253fn values_equal(before: &Value, after: &Value) -> bool {
257 format!("{before:?}") == format!("{after:?}")
258}
259
260fn change(path: &[String], kind: ChangeKind) -> Change {
261 Change {
262 path: path.join("."),
263 kind,
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270
271 fn dict(entries: &[(&str, Value)]) -> Dict {
272 entries
273 .iter()
274 .map(|(key, value)| ((*key).to_owned(), value.clone()))
275 .collect()
276 }
277
278 fn snapshot(entries: &[(&str, Value)]) -> Snapshot {
279 Snapshot::new(dict(entries))
280 }
281
282 #[test]
283 fn identical_snapshots_have_no_changes() {
284 let one = snapshot(&[("host", "a".into()), ("port", 1u16.into())]);
285 let two = snapshot(&[("host", "a".into()), ("port", 1u16.into())]);
286
287 assert!(one.diff(&two).is_empty());
288 }
289
290 #[test]
291 fn a_modified_value_names_its_key_but_not_its_value() {
292 let one = snapshot(&[("password", "hunter2".into())]);
293 let two = snapshot(&[("password", "letmein".into())]);
294
295 let changes = one.diff(&two);
296
297 assert_eq!(changes.len(), 1);
298 assert_eq!(changes[0].path, "password");
299 assert_eq!(changes[0].kind, ChangeKind::Modified);
300
301 let rendered = changes[0].to_string();
302 assert_eq!(rendered, "password changed");
303 assert!(!rendered.contains("hunter2"), "{rendered}");
304 assert!(!rendered.contains("letmein"), "{rendered}");
305 }
306
307 #[test]
308 fn additions_and_removals_are_told_apart() {
309 let one = snapshot(&[("gone", 1u16.into())]);
310 let two = snapshot(&[("fresh", 1u16.into())]);
311
312 let changes = one.diff(&two);
313
314 assert_eq!(
315 changes,
316 [
317 Change {
318 path: "fresh".to_owned(),
319 kind: ChangeKind::Added,
320 },
321 Change {
322 path: "gone".to_owned(),
323 kind: ChangeKind::Removed,
324 },
325 ]
326 );
327 }
328
329 #[test]
330 fn a_change_inside_a_table_is_reported_at_the_leaf() {
331 let one = snapshot(&[(
332 "pool",
333 Value::from(dict(&[("max", 1u16.into()), ("min", 1u16.into())])),
334 )]);
335 let two = snapshot(&[(
336 "pool",
337 Value::from(dict(&[("max", 2u16.into()), ("min", 1u16.into())])),
338 )]);
339
340 let changes = one.diff(&two);
341
342 assert_eq!(changes.len(), 1);
343 assert_eq!(changes[0].path, "pool.max", "not just `pool`");
344 }
345
346 #[test]
347 fn a_table_replaced_by_a_scalar_is_one_change() {
348 let one = snapshot(&[("pool", Value::from(dict(&[("max", 1u16.into())])))]);
349 let two = snapshot(&[("pool", 1u16.into())]);
350
351 let changes = one.diff(&two);
352
353 assert_eq!(changes.len(), 1);
354 assert_eq!(changes[0].path, "pool");
355 assert_eq!(changes[0].kind, ChangeKind::Modified);
356 }
357
358 #[test]
359 fn a_value_can_be_read_by_path_without_a_struct() {
360 let snapshot = snapshot(&[
361 ("host", "a".into()),
362 ("pool", Value::from(dict(&[("max", 32u16.into())]))),
363 ]);
364
365 assert_eq!(snapshot.get::<String>("host").unwrap(), "a");
366 assert_eq!(snapshot.get::<u16>("pool.max").unwrap(), 32);
367 assert!(snapshot.contains("pool.max"));
368 assert!(!snapshot.contains("pool.min"));
369 }
370
371 #[test]
372 fn a_missing_path_and_a_wrong_type_are_told_apart() {
373 let snapshot = snapshot(&[("host", "a".into())]);
374
375 assert_eq!(
376 snapshot.get::<String>("nowhere").unwrap_err().kind(),
377 ErrorKind::Missing
378 );
379 assert_eq!(
380 snapshot.get::<u16>("host").unwrap_err().kind(),
381 ErrorKind::Type
382 );
383 assert_eq!(
385 snapshot.get::<u16>("host.port").unwrap_err().kind(),
386 ErrorKind::Missing
387 );
388 }
389
390 #[test]
391 fn a_sub_snapshot_carries_only_its_own_table() {
392 let snapshot = snapshot(&[
393 ("host", "a".into()),
394 ("pool", Value::from(dict(&[("max", 32u16.into())]))),
395 ]);
396
397 let pool = snapshot.sub("pool").expect("`pool` is a table");
398
399 assert_eq!(pool.get::<u16>("max").unwrap(), 32);
400 assert!(!pool.contains("host"));
401
402 assert!(snapshot.sub("host").is_none(), "a scalar is not a table");
403 }
404
405 #[test]
406 fn leaf_paths_reach_into_nested_tables() {
407 let snapshot = snapshot(&[
408 ("host", "a".into()),
409 ("pool", Value::from(dict(&[("max", 1u16.into())]))),
410 ]);
411
412 assert_eq!(snapshot.leaf_paths(), ["host", "pool.max"]);
413 assert_eq!(snapshot.top_level_keys(), ["host", "pool"]);
414 }
415
416 #[test]
417 fn extraction_reports_the_path_it_failed_at() {
418 #[derive(serde::Deserialize, Debug)]
419 #[allow(dead_code)]
420 struct Target {
421 port: u16,
422 }
423
424 let error = snapshot(&[("port", "not-a-number".into())])
425 .extract::<Target>()
426 .unwrap_err();
427
428 assert_eq!(error.path(), "port");
429 }
430
431 mod properties {
432 use super::*;
433 use proptest::prelude::*;
434
435 proptest! {
436 #![proptest_config(ProptestConfig::with_cases(256))]
437
438 #[test]
441 fn diff_reports_paths_never_values(
442 a in prop::collection::btree_map("[a-z]{1,8}", "[a-zA-Z0-9]{4,16}", 0..8),
443 b in prop::collection::btree_map("[a-z]{1,8}", "[a-zA-Z0-9]{4,16}", 0..8),
444 ) {
445 let left = Snapshot::new(
446 a.iter().map(|(k, v)| (k.clone(), Value::from(v.clone()))).collect(),
447 );
448 let right = Snapshot::new(
449 b.iter().map(|(k, v)| (k.clone(), Value::from(v.clone()))).collect(),
450 );
451
452 for change in left.diff(&right) {
453 let rendered = change.to_string();
454
455 for value in a.values().chain(b.values()) {
456 prop_assert!(
457 !rendered.contains(value.as_str()),
458 "a diff must name paths, never values: {}",
459 rendered
460 );
461 }
462 }
463 }
464 }
465 }
466}