1use std::collections::BTreeMap;
13use std::fmt;
14
15use figment::value::{Dict, Value};
16use serde::de::DeserializeOwned;
17
18use crate::error::{Error, ErrorKind, Origin};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22#[non_exhaustive]
23pub enum ChangeKind {
24 Added,
26 Removed,
28 Modified,
30}
31
32impl fmt::Display for ChangeKind {
33 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34 f.write_str(match self {
35 Self::Added => "added",
36 Self::Removed => "removed",
37 Self::Modified => "changed",
38 })
39 }
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Change {
45 pub path: String,
47 pub kind: ChangeKind,
49}
50
51impl fmt::Display for Change {
52 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53 write!(f, "{} {}", self.path, self.kind)
54 }
55}
56
57#[derive(Clone, Default)]
63pub struct Snapshot {
64 values: Dict,
65 provenance: BTreeMap<String, Origin>,
69}
70
71impl Snapshot {
72 pub(crate) fn new(values: Dict) -> Self {
73 Self {
74 values,
75 provenance: BTreeMap::new(),
76 }
77 }
78
79 pub(crate) fn attach_provenance(&mut self, provenance: BTreeMap<String, Origin>) {
81 self.provenance = provenance;
82 }
83
84 #[must_use]
96 pub fn source_of(&self, path: &str) -> Option<&Origin> {
97 self.provenance.get(path)
98 }
99
100 #[must_use]
108 pub fn to_value(&self) -> crate::Value {
109 crate::value::Value::Table(
110 self.values
111 .iter()
112 .map(|(key, value)| (key.clone(), crate::value::from_figment(value)))
113 .collect(),
114 )
115 }
116
117 pub fn extract<T: DeserializeOwned>(&self) -> Result<T, Error> {
128 Value::from(self.values.clone())
129 .deserialize()
130 .map_err(|error: figment::Error| crate::loader::translate(&error))
136 }
137
138 #[must_use]
143 pub fn diff(&self, other: &Self) -> Vec<Change> {
144 let mut changes = Vec::new();
145
146 compare(&self.values, &other.values, &mut Vec::new(), &mut changes);
147 changes.sort_by(|left, right| left.path.cmp(&right.path));
148
149 changes
150 }
151
152 pub fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, Error> {
171 let value = self.at(path).ok_or_else(|| {
172 Error::new(ErrorKind::Missing, "no value at this path").prepend_key(path)
173 })?;
174
175 value
178 .deserialize()
179 .map_err(|error: figment::Error| crate::loader::translate(&error).prepend_key(path))
180 }
181
182 pub(crate) fn without_top_level(&self, key: &str) -> Self {
185 let mut values = self.values().clone();
186 values.remove(key);
187
188 let prefix = format!("{key}.");
189 let provenance = self
190 .provenance
191 .iter()
192 .filter(|(path, _)| *path != key && !path.starts_with(&prefix))
193 .map(|(path, origin)| (path.clone(), origin.clone()))
194 .collect();
195
196 Self { values, provenance }
197 }
198
199 #[must_use]
201 pub fn contains(&self, path: &str) -> bool {
202 self.at(path).is_some()
203 }
204
205 #[must_use]
212 pub fn sub(&self, path: &str) -> Option<Self> {
213 match self.at(path)? {
214 Value::Dict(_, nested) => {
215 let prefix = format!("{path}.");
216 let provenance = self
217 .provenance
218 .iter()
219 .filter_map(|(leaf, origin)| {
220 leaf.strip_prefix(&prefix)
221 .map(|rest| (rest.to_owned(), origin.clone()))
222 })
223 .collect();
224
225 Some(Self {
226 values: nested.clone(),
227 provenance,
228 })
229 }
230 _ => None,
231 }
232 }
233
234 fn at(&self, path: &str) -> Option<&Value> {
235 let mut segments = path.split('.');
236 let mut current = self.values.get(segments.next()?)?;
237
238 for segment in segments {
239 let Value::Dict(_, nested) = current else {
240 return None;
241 };
242
243 current = nested.get(segment)?;
244 }
245
246 Some(current)
247 }
248
249 #[must_use]
251 pub fn leaf_paths(&self) -> Vec<String> {
252 let mut paths = Vec::new();
253
254 collect_leaves(&self.values, &mut Vec::new(), &mut paths);
255
256 paths
257 }
258
259 #[must_use]
261 pub fn top_level_keys(&self) -> Vec<String> {
262 self.values.keys().cloned().collect()
263 }
264
265 pub(crate) fn values(&self) -> &Dict {
267 &self.values
268 }
269
270 #[must_use]
272 pub fn is_empty(&self) -> bool {
273 self.values.is_empty()
274 }
275}
276
277impl fmt::Debug for Snapshot {
282 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
283 f.debug_struct("Snapshot")
284 .field("keys", &self.top_level_keys())
285 .field("leaves", &self.leaf_paths().len())
286 .field("provenance", &self.provenance.len())
287 .finish_non_exhaustive()
288 }
289}
290
291pub fn changed_paths<T: serde::Serialize>(previous: &T, current: &T) -> Result<Vec<Change>, Error> {
315 let as_snapshot = |value: &T| -> Result<Snapshot, Error> {
316 match Value::serialize(value) {
317 Ok(Value::Dict(_, dict)) => Ok(Snapshot::new(dict)),
318 Ok(_) => Err(Error::new(
319 ErrorKind::Type,
320 "only a table has paths to compare; this serializes to a scalar",
321 )),
322 Err(error) => Err(Error::new(ErrorKind::Type, error.to_string())),
323 }
324 };
325
326 Ok(as_snapshot(previous)?.diff(&as_snapshot(current)?))
327}
328
329fn collect_leaves(values: &Dict, path: &mut Vec<String>, paths: &mut Vec<String>) {
331 for (key, value) in values {
332 path.push(key.clone());
333
334 match value {
335 Value::Dict(_, nested) if !nested.is_empty() => {
336 collect_leaves(nested, path, paths);
337 }
338 _ => paths.push(path.join(".")),
339 }
340
341 path.pop();
342 }
343}
344
345fn compare(previous: &Dict, current: &Dict, path: &mut Vec<String>, changes: &mut Vec<Change>) {
347 for (key, before) in previous {
348 path.push(key.clone());
349
350 match current.get(key) {
351 Some(after) => compare_values(before, after, path, changes),
352 None => changes.push(change(path, ChangeKind::Removed)),
353 }
354
355 path.pop();
356 }
357
358 for key in current.keys() {
359 if previous.contains_key(key) {
360 continue;
361 }
362
363 path.push(key.clone());
364 changes.push(change(path, ChangeKind::Added));
365 path.pop();
366 }
367}
368
369fn compare_values(
370 before: &Value,
371 after: &Value,
372 path: &mut Vec<String>,
373 changes: &mut Vec<Change>,
374) {
375 match (before, after) {
376 (Value::Dict(_, before), Value::Dict(_, after)) => compare(before, after, path, changes),
379 _ if values_equal(before, after) => {}
380 _ => changes.push(change(path, ChangeKind::Modified)),
381 }
382}
383
384fn values_equal(before: &Value, after: &Value) -> bool {
396 crate::value::from_figment(before) == crate::value::from_figment(after)
397}
398
399fn change(path: &[String], kind: ChangeKind) -> Change {
400 Change {
401 path: path.join("."),
402 kind,
403 }
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409
410 fn dict(entries: &[(&str, Value)]) -> Dict {
411 entries
412 .iter()
413 .map(|(key, value)| ((*key).to_owned(), value.clone()))
414 .collect()
415 }
416
417 fn snapshot(entries: &[(&str, Value)]) -> Snapshot {
418 Snapshot::new(dict(entries))
419 }
420
421 #[test]
422 fn identical_snapshots_have_no_changes() {
423 let one = snapshot(&[("host", "a".into()), ("port", 1u16.into())]);
424 let two = snapshot(&[("host", "a".into()), ("port", 1u16.into())]);
425
426 assert!(one.diff(&two).is_empty());
427 }
428
429 fn from_provider(key: &str, value: impl serde::Serialize) -> Value {
432 figment::Figment::from((key, value))
433 .find_value(key)
434 .expect("the provider supplies exactly this key")
435 }
436
437 #[test]
438 fn the_same_value_from_two_providers_is_not_a_change() {
439 let (left, right) = (from_provider("host", "a"), from_provider("host", "a"));
440
441 assert_ne!(
442 left.tag(),
443 right.tag(),
444 "the point of this test is two differently tagged values"
445 );
446
447 assert!(snapshot(&[("host", left)])
448 .diff(&snapshot(&[("host", right)]))
449 .is_empty());
450 }
451
452 #[test]
457 fn the_same_number_at_two_widths_is_not_a_change() {
458 let narrow = snapshot(&[("port", Value::from(1u8))]);
459 let wide = snapshot(&[("port", Value::from(1u64))]);
460
461 assert!(narrow.diff(&wide).is_empty());
462 }
463
464 #[test]
467 fn an_integer_and_a_float_are_different_values() {
468 let integer = snapshot(&[("ratio", Value::from(1u8))]);
469 let float = snapshot(&[("ratio", Value::from(1.0f64))]);
470
471 let changes = integer.diff(&float);
472
473 assert_eq!(changes.len(), 1);
474 assert_eq!(changes[0].path, "ratio");
475 assert_eq!(changes[0].kind, ChangeKind::Modified);
476 }
477
478 #[test]
479 fn a_modified_value_names_its_key_but_not_its_value() {
480 let one = snapshot(&[("password", "hunter2".into())]);
481 let two = snapshot(&[("password", "letmein".into())]);
482
483 let changes = one.diff(&two);
484
485 assert_eq!(changes.len(), 1);
486 assert_eq!(changes[0].path, "password");
487 assert_eq!(changes[0].kind, ChangeKind::Modified);
488
489 let rendered = changes[0].to_string();
490 assert_eq!(rendered, "password changed");
491 assert!(!rendered.contains("hunter2"), "{rendered}");
492 assert!(!rendered.contains("letmein"), "{rendered}");
493 }
494
495 #[test]
496 fn additions_and_removals_are_told_apart() {
497 let one = snapshot(&[("gone", 1u16.into())]);
498 let two = snapshot(&[("fresh", 1u16.into())]);
499
500 let changes = one.diff(&two);
501
502 assert_eq!(
503 changes,
504 [
505 Change {
506 path: "fresh".to_owned(),
507 kind: ChangeKind::Added,
508 },
509 Change {
510 path: "gone".to_owned(),
511 kind: ChangeKind::Removed,
512 },
513 ]
514 );
515 }
516
517 #[test]
518 fn a_change_inside_a_table_is_reported_at_the_leaf() {
519 let one = snapshot(&[(
520 "pool",
521 Value::from(dict(&[("max", 1u16.into()), ("min", 1u16.into())])),
522 )]);
523 let two = snapshot(&[(
524 "pool",
525 Value::from(dict(&[("max", 2u16.into()), ("min", 1u16.into())])),
526 )]);
527
528 let changes = one.diff(&two);
529
530 assert_eq!(changes.len(), 1);
531 assert_eq!(changes[0].path, "pool.max", "not just `pool`");
532 }
533
534 #[test]
535 fn a_table_replaced_by_a_scalar_is_one_change() {
536 let one = snapshot(&[("pool", Value::from(dict(&[("max", 1u16.into())])))]);
537 let two = snapshot(&[("pool", 1u16.into())]);
538
539 let changes = one.diff(&two);
540
541 assert_eq!(changes.len(), 1);
542 assert_eq!(changes[0].path, "pool");
543 assert_eq!(changes[0].kind, ChangeKind::Modified);
544 }
545
546 #[test]
547 fn a_value_can_be_read_by_path_without_a_struct() {
548 let snapshot = snapshot(&[
549 ("host", "a".into()),
550 ("pool", Value::from(dict(&[("max", 32u16.into())]))),
551 ]);
552
553 assert_eq!(snapshot.get::<String>("host").unwrap(), "a");
554 assert_eq!(snapshot.get::<u16>("pool.max").unwrap(), 32);
555 assert!(snapshot.contains("pool.max"));
556 assert!(!snapshot.contains("pool.min"));
557 }
558
559 #[test]
560 fn a_missing_path_and_a_wrong_type_are_told_apart() {
561 let snapshot = snapshot(&[("host", "a".into())]);
562
563 assert_eq!(
564 snapshot.get::<String>("nowhere").unwrap_err().kind(),
565 ErrorKind::Missing
566 );
567 assert_eq!(
568 snapshot.get::<u16>("host").unwrap_err().kind(),
569 ErrorKind::Type
570 );
571 assert_eq!(
573 snapshot.get::<u16>("host.port").unwrap_err().kind(),
574 ErrorKind::Missing
575 );
576 }
577
578 #[test]
579 fn a_sub_snapshot_carries_only_its_own_table() {
580 let snapshot = snapshot(&[
581 ("host", "a".into()),
582 ("pool", Value::from(dict(&[("max", 32u16.into())]))),
583 ]);
584
585 let pool = snapshot.sub("pool").expect("`pool` is a table");
586
587 assert_eq!(pool.get::<u16>("max").unwrap(), 32);
588 assert!(!pool.contains("host"));
589
590 assert!(snapshot.sub("host").is_none(), "a scalar is not a table");
591 }
592
593 #[test]
594 fn leaf_paths_reach_into_nested_tables() {
595 let snapshot = snapshot(&[
596 ("host", "a".into()),
597 ("pool", Value::from(dict(&[("max", 1u16.into())]))),
598 ]);
599
600 assert_eq!(snapshot.leaf_paths(), ["host", "pool.max"]);
601 assert_eq!(snapshot.top_level_keys(), ["host", "pool"]);
602 }
603
604 #[test]
605 fn extraction_reports_the_path_it_failed_at() {
606 #[derive(serde::Deserialize, Debug)]
607 #[allow(dead_code)]
608 struct Target {
609 port: u16,
610 }
611
612 let error = snapshot(&[("port", "not-a-number".into())])
613 .extract::<Target>()
614 .unwrap_err();
615
616 assert_eq!(error.path(), "port");
617 }
618
619 mod properties {
620 use super::*;
621 use proptest::prelude::*;
622
623 proptest! {
624 #![proptest_config(ProptestConfig::with_cases(256))]
625
626 #[test]
629 fn diff_reports_paths_never_values(
630 a in prop::collection::btree_map("[a-z]{1,8}", "[a-zA-Z0-9]{4,16}", 0..8),
631 b in prop::collection::btree_map("[a-z]{1,8}", "[a-zA-Z0-9]{4,16}", 0..8),
632 ) {
633 let left = Snapshot::new(
634 a.iter().map(|(k, v)| (k.clone(), Value::from(v.clone()))).collect(),
635 );
636 let right = Snapshot::new(
637 b.iter().map(|(k, v)| (k.clone(), Value::from(v.clone()))).collect(),
638 );
639
640 for change in left.diff(&right) {
641 let rendered = change.to_string();
642
643 for value in a.values().chain(b.values()) {
644 prop_assert!(
645 !rendered.contains(value.as_str()),
646 "a diff must name paths, never values: {}",
647 rendered
648 );
649 }
650 }
651 }
652 }
653 }
654}