1use std::collections::BTreeMap;
13use std::fmt;
14
15use serde::de::DeserializeOwned;
16
17use crate::error::{Error, ErrorKind, Origin};
18use crate::value::Value;
19
20type Table = BTreeMap<String, Value>;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
25#[non_exhaustive]
26pub enum ChangeKind {
27 Added,
29 Removed,
31 Modified,
33}
34
35impl fmt::Display for ChangeKind {
36 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37 f.write_str(match self {
38 Self::Added => "added",
39 Self::Removed => "removed",
40 Self::Modified => "changed",
41 })
42 }
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct Change {
48 pub path: String,
50 pub kind: ChangeKind,
52}
53
54impl fmt::Display for Change {
55 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56 write!(f, "{} {}", self.path, self.kind)
57 }
58}
59
60#[derive(Clone, Default)]
66pub struct Snapshot {
67 values: Table,
68 provenance: BTreeMap<String, Origin>,
72}
73
74impl Snapshot {
75 pub(crate) fn new(values: Table) -> Self {
76 Self {
77 values,
78 provenance: BTreeMap::new(),
79 }
80 }
81
82 pub(crate) fn attach_provenance(&mut self, provenance: BTreeMap<String, Origin>) {
84 self.provenance = provenance;
85 }
86
87 #[must_use]
99 pub fn source_of(&self, path: &str) -> Option<&Origin> {
100 self.provenance.get(path)
101 }
102
103 #[must_use]
111 pub fn to_value(&self) -> crate::Value {
112 Value::Table(self.values.clone())
113 }
114
115 pub fn extract<T: DeserializeOwned>(&self) -> Result<T, Error> {
126 let tree = self.to_value();
127
128 T::deserialize(crate::de::Reader(&tree)).map_err(crate::de::Error::into_error)
129 }
130
131 #[must_use]
136 pub fn diff(&self, other: &Self) -> Vec<Change> {
137 let mut changes = Vec::new();
138
139 compare(&self.values, &other.values, &mut Vec::new(), &mut changes);
140 changes.sort_by(|left, right| left.path.cmp(&right.path));
141
142 changes
143 }
144
145 pub fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, Error> {
164 let value = self.at(path).ok_or_else(|| {
165 Error::new(ErrorKind::Missing, "no value at this path").prepend_key(path)
166 })?;
167
168 T::deserialize(crate::de::Reader(value))
171 .map_err(|error| error.into_error().prepend_key(path))
172 }
173
174 pub(crate) fn without_top_level(&self, key: &str) -> Self {
177 let mut values = self.values().clone();
178 values.remove(key);
179
180 let prefix = format!("{key}.");
181 let provenance = self
182 .provenance
183 .iter()
184 .filter(|(path, _)| *path != key && !path.starts_with(&prefix))
185 .map(|(path, origin)| (path.clone(), origin.clone()))
186 .collect();
187
188 Self { values, provenance }
189 }
190
191 #[must_use]
193 pub fn contains(&self, path: &str) -> bool {
194 self.at(path).is_some()
195 }
196
197 #[must_use]
204 pub fn sub(&self, path: &str) -> Option<Self> {
205 match self.at(path)? {
206 Value::Table(nested) => {
207 let prefix = format!("{path}.");
208 let provenance = self
209 .provenance
210 .iter()
211 .filter_map(|(leaf, origin)| {
212 leaf.strip_prefix(&prefix)
213 .map(|rest| (rest.to_owned(), origin.clone()))
214 })
215 .collect();
216
217 Some(Self {
218 values: nested.clone(),
219 provenance,
220 })
221 }
222 _ => None,
223 }
224 }
225
226 fn at(&self, path: &str) -> Option<&Value> {
227 let mut segments = path.split('.');
228 let mut current = self.values.get(segments.next()?)?;
229
230 for segment in segments {
231 let Value::Table(nested) = current else {
232 return None;
233 };
234
235 current = nested.get(segment)?;
236 }
237
238 Some(current)
239 }
240
241 #[must_use]
254 pub fn fingerprint(&self, secrets: &[&str]) -> String {
255 let document = match self.to_value() {
256 crate::Value::Table(table) => table,
257 _ => std::collections::BTreeMap::new(),
260 };
261
262 crate::fingerprint::rendered(&document, secrets)
263 }
264
265 #[must_use]
267 pub fn leaf_paths(&self) -> Vec<String> {
268 let mut paths = Vec::new();
269
270 collect_leaves(&self.values, &mut Vec::new(), &mut paths);
271
272 paths
273 }
274
275 #[must_use]
277 pub fn top_level_keys(&self) -> Vec<String> {
278 self.values.keys().cloned().collect()
279 }
280
281 pub(crate) fn values(&self) -> &Table {
283 &self.values
284 }
285
286 #[must_use]
288 pub fn is_empty(&self) -> bool {
289 self.values.is_empty()
290 }
291}
292
293impl fmt::Debug for Snapshot {
298 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
299 f.debug_struct("Snapshot")
300 .field("keys", &self.top_level_keys())
301 .field("leaves", &self.leaf_paths().len())
302 .field("provenance", &self.provenance.len())
303 .finish_non_exhaustive()
304 }
305}
306
307pub fn changed_paths<T: serde::Serialize>(previous: &T, current: &T) -> Result<Vec<Change>, Error> {
331 let as_snapshot = |value: &T| -> Result<Snapshot, Error> {
332 match crate::ser::to_value(value) {
333 Ok(crate::Value::Table(table)) => Ok(Snapshot::new(table)),
334 Ok(_) => Err(Error::new(
335 ErrorKind::Type,
336 "only a table has paths to compare; this serializes to a scalar",
337 )),
338 Err(error) => Err(Error::new(ErrorKind::Type, error.to_string())),
339 }
340 };
341
342 Ok(as_snapshot(previous)?.diff(&as_snapshot(current)?))
343}
344
345fn collect_leaves(values: &Table, path: &mut Vec<String>, paths: &mut Vec<String>) {
347 for (key, value) in values {
348 path.push(key.clone());
349
350 match value {
351 Value::Table(nested) if !nested.is_empty() => {
352 collect_leaves(nested, path, paths);
353 }
354 _ => paths.push(path.join(".")),
355 }
356
357 path.pop();
358 }
359}
360
361fn compare(previous: &Table, current: &Table, path: &mut Vec<String>, changes: &mut Vec<Change>) {
363 for (key, before) in previous {
364 path.push(key.clone());
365
366 match current.get(key) {
367 Some(after) => compare_values(before, after, path, changes),
368 None => changes.push(change(path, ChangeKind::Removed)),
369 }
370
371 path.pop();
372 }
373
374 for key in current.keys() {
375 if previous.contains_key(key) {
376 continue;
377 }
378
379 path.push(key.clone());
380 changes.push(change(path, ChangeKind::Added));
381 path.pop();
382 }
383}
384
385fn compare_values(
386 before: &Value,
387 after: &Value,
388 path: &mut Vec<String>,
389 changes: &mut Vec<Change>,
390) {
391 match (before, after) {
392 (Value::Table(before), Value::Table(after)) => compare(before, after, path, changes),
395 _ if before == after => {}
398 _ => changes.push(change(path, ChangeKind::Modified)),
399 }
400}
401
402fn change(path: &[String], kind: ChangeKind) -> Change {
403 Change {
404 path: path.join("."),
405 kind,
406 }
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412
413 fn dict(entries: &[(&str, Value)]) -> Table {
414 entries
415 .iter()
416 .map(|(key, value)| ((*key).to_owned(), value.clone()))
417 .collect()
418 }
419
420 fn snapshot(entries: &[(&str, Value)]) -> Snapshot {
421 Snapshot::new(dict(entries))
422 }
423
424 #[test]
425 fn identical_snapshots_have_no_changes() {
426 let one = snapshot(&[("host", "a".into()), ("port", 1u16.into())]);
427 let two = snapshot(&[("host", "a".into()), ("port", 1u16.into())]);
428
429 assert!(one.diff(&two).is_empty());
430 }
431
432 fn from_provider(key: &str, value: impl serde::Serialize) -> Value {
442 let supplied = figment::Figment::from((key, value))
443 .find_value(key)
444 .expect("the provider supplies exactly this key");
445
446 crate::backend::figment::from_figment(&supplied)
447 }
448
449 #[test]
450 fn the_same_value_from_two_providers_is_not_a_change() {
451 let (left, right) = (from_provider("host", "a"), from_provider("host", "a"));
452
453 assert!(snapshot(&[("host", left)])
454 .diff(&snapshot(&[("host", right)]))
455 .is_empty());
456 }
457
458 #[test]
463 fn the_same_number_at_two_widths_is_not_a_change() {
464 let narrow = snapshot(&[("port", from_provider("port", 1u8))]);
465 let wide = snapshot(&[("port", from_provider("port", 1u64))]);
466
467 assert!(narrow.diff(&wide).is_empty());
468 }
469
470 #[test]
473 fn an_integer_and_a_float_are_different_values() {
474 let integer = snapshot(&[("ratio", Value::from(1u8))]);
475 let float = snapshot(&[("ratio", Value::from(1.0f64))]);
476
477 let changes = integer.diff(&float);
478
479 assert_eq!(changes.len(), 1);
480 assert_eq!(changes[0].path, "ratio");
481 assert_eq!(changes[0].kind, ChangeKind::Modified);
482 }
483
484 #[test]
485 fn a_modified_value_names_its_key_but_not_its_value() {
486 let one = snapshot(&[("password", "hunter2".into())]);
487 let two = snapshot(&[("password", "letmein".into())]);
488
489 let changes = one.diff(&two);
490
491 assert_eq!(changes.len(), 1);
492 assert_eq!(changes[0].path, "password");
493 assert_eq!(changes[0].kind, ChangeKind::Modified);
494
495 let rendered = changes[0].to_string();
496 assert_eq!(rendered, "password changed");
497 assert!(!rendered.contains("hunter2"), "{rendered}");
498 assert!(!rendered.contains("letmein"), "{rendered}");
499 }
500
501 #[test]
502 fn additions_and_removals_are_told_apart() {
503 let one = snapshot(&[("gone", 1u16.into())]);
504 let two = snapshot(&[("fresh", 1u16.into())]);
505
506 let changes = one.diff(&two);
507
508 assert_eq!(
509 changes,
510 [
511 Change {
512 path: "fresh".to_owned(),
513 kind: ChangeKind::Added,
514 },
515 Change {
516 path: "gone".to_owned(),
517 kind: ChangeKind::Removed,
518 },
519 ]
520 );
521 }
522
523 #[test]
524 fn a_change_inside_a_table_is_reported_at_the_leaf() {
525 let one = snapshot(&[(
526 "pool",
527 Value::from(dict(&[("max", 1u16.into()), ("min", 1u16.into())])),
528 )]);
529 let two = snapshot(&[(
530 "pool",
531 Value::from(dict(&[("max", 2u16.into()), ("min", 1u16.into())])),
532 )]);
533
534 let changes = one.diff(&two);
535
536 assert_eq!(changes.len(), 1);
537 assert_eq!(changes[0].path, "pool.max", "not just `pool`");
538 }
539
540 #[test]
541 fn a_table_replaced_by_a_scalar_is_one_change() {
542 let one = snapshot(&[("pool", Value::from(dict(&[("max", 1u16.into())])))]);
543 let two = snapshot(&[("pool", 1u16.into())]);
544
545 let changes = one.diff(&two);
546
547 assert_eq!(changes.len(), 1);
548 assert_eq!(changes[0].path, "pool");
549 assert_eq!(changes[0].kind, ChangeKind::Modified);
550 }
551
552 #[test]
553 fn a_value_can_be_read_by_path_without_a_struct() {
554 let snapshot = snapshot(&[
555 ("host", "a".into()),
556 ("pool", Value::from(dict(&[("max", 32u16.into())]))),
557 ]);
558
559 assert_eq!(snapshot.get::<String>("host").unwrap(), "a");
560 assert_eq!(snapshot.get::<u16>("pool.max").unwrap(), 32);
561 assert!(snapshot.contains("pool.max"));
562 assert!(!snapshot.contains("pool.min"));
563 }
564
565 #[test]
566 fn a_missing_path_and_a_wrong_type_are_told_apart() {
567 let snapshot = snapshot(&[("host", "a".into())]);
568
569 assert_eq!(
570 snapshot.get::<String>("nowhere").unwrap_err().kind(),
571 ErrorKind::Missing
572 );
573 assert_eq!(
574 snapshot.get::<u16>("host").unwrap_err().kind(),
575 ErrorKind::Type
576 );
577 assert_eq!(
579 snapshot.get::<u16>("host.port").unwrap_err().kind(),
580 ErrorKind::Missing
581 );
582 }
583
584 #[test]
585 fn a_sub_snapshot_carries_only_its_own_table() {
586 let snapshot = snapshot(&[
587 ("host", "a".into()),
588 ("pool", Value::from(dict(&[("max", 32u16.into())]))),
589 ]);
590
591 let pool = snapshot.sub("pool").expect("`pool` is a table");
592
593 assert_eq!(pool.get::<u16>("max").unwrap(), 32);
594 assert!(!pool.contains("host"));
595
596 assert!(snapshot.sub("host").is_none(), "a scalar is not a table");
597 }
598
599 #[test]
600 fn leaf_paths_reach_into_nested_tables() {
601 let snapshot = snapshot(&[
602 ("host", "a".into()),
603 ("pool", Value::from(dict(&[("max", 1u16.into())]))),
604 ]);
605
606 assert_eq!(snapshot.leaf_paths(), ["host", "pool.max"]);
607 assert_eq!(snapshot.top_level_keys(), ["host", "pool"]);
608 }
609
610 #[test]
611 fn extraction_reports_the_path_it_failed_at() {
612 #[derive(serde::Deserialize, Debug)]
613 #[allow(dead_code)]
614 struct Target {
615 port: u16,
616 }
617
618 let error = snapshot(&[("port", "not-a-number".into())])
619 .extract::<Target>()
620 .unwrap_err();
621
622 assert_eq!(error.path(), "port");
623 }
624
625 mod properties {
626 use super::*;
627 use proptest::prelude::*;
628
629 proptest! {
630 #![proptest_config(ProptestConfig::with_cases(256))]
631
632 #[test]
635 fn diff_reports_paths_never_values(
636 a in prop::collection::btree_map("[a-z]{1,8}", "[a-zA-Z0-9]{4,16}", 0..8),
637 b in prop::collection::btree_map("[a-z]{1,8}", "[a-zA-Z0-9]{4,16}", 0..8),
638 ) {
639 let left = Snapshot::new(
640 a.iter().map(|(k, v)| (k.clone(), Value::from(v.clone()))).collect(),
641 );
642 let right = Snapshot::new(
643 b.iter().map(|(k, v)| (k.clone(), Value::from(v.clone()))).collect(),
644 );
645
646 for change in left.diff(&right) {
647 let rendered = change.to_string();
648
649 for value in a.values().chain(b.values()) {
650 prop_assert!(
651 !rendered.contains(value.as_str()),
652 "a diff must name paths, never values: {}",
653 rendered
654 );
655 }
656 }
657 }
658 }
659 }
660}