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| {
131 let mut translated = Error::new(ErrorKind::Type, error.to_string());
132
133 for segment in error.path.iter().rev() {
134 translated = translated.prepend_key(segment);
135 }
136
137 translated
138 })
139 }
140
141 #[must_use]
146 pub fn diff(&self, other: &Self) -> Vec<Change> {
147 let mut changes = Vec::new();
148
149 compare(&self.values, &other.values, &mut Vec::new(), &mut changes);
150 changes.sort_by(|left, right| left.path.cmp(&right.path));
151
152 changes
153 }
154
155 pub fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, Error> {
165 let value = self.at(path).ok_or_else(|| {
166 Error::new(ErrorKind::Missing, "no value at this path").prepend_key(path)
167 })?;
168
169 value.deserialize().map_err(|error: figment::Error| {
170 Error::new(ErrorKind::Type, error.to_string()).prepend_key(path)
171 })
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::Dict(_, 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::Dict(_, nested) = current else {
232 return None;
233 };
234
235 current = nested.get(segment)?;
236 }
237
238 Some(current)
239 }
240
241 #[must_use]
243 pub fn leaf_paths(&self) -> Vec<String> {
244 let mut paths = Vec::new();
245
246 collect_leaves(&self.values, &mut Vec::new(), &mut paths);
247
248 paths
249 }
250
251 #[must_use]
253 pub fn top_level_keys(&self) -> Vec<String> {
254 self.values.keys().cloned().collect()
255 }
256
257 pub(crate) fn values(&self) -> &Dict {
259 &self.values
260 }
261
262 #[must_use]
264 pub fn is_empty(&self) -> bool {
265 self.values.is_empty()
266 }
267}
268
269impl fmt::Debug for Snapshot {
274 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
275 f.debug_struct("Snapshot")
276 .field("keys", &self.top_level_keys())
277 .field("leaves", &self.leaf_paths().len())
278 .field("provenance", &self.provenance.len())
279 .finish_non_exhaustive()
280 }
281}
282
283pub fn changed_paths<T: serde::Serialize>(previous: &T, current: &T) -> Result<Vec<Change>, Error> {
307 let as_snapshot = |value: &T| -> Result<Snapshot, Error> {
308 match Value::serialize(value) {
309 Ok(Value::Dict(_, dict)) => Ok(Snapshot::new(dict)),
310 Ok(_) => Err(Error::new(
311 ErrorKind::Type,
312 "only a table has paths to compare; this serializes to a scalar",
313 )),
314 Err(error) => Err(Error::new(ErrorKind::Type, error.to_string())),
315 }
316 };
317
318 Ok(as_snapshot(previous)?.diff(&as_snapshot(current)?))
319}
320
321fn collect_leaves(values: &Dict, path: &mut Vec<String>, paths: &mut Vec<String>) {
323 for (key, value) in values {
324 path.push(key.clone());
325
326 match value {
327 Value::Dict(_, nested) if !nested.is_empty() => {
328 collect_leaves(nested, path, paths);
329 }
330 _ => paths.push(path.join(".")),
331 }
332
333 path.pop();
334 }
335}
336
337fn compare(previous: &Dict, current: &Dict, path: &mut Vec<String>, changes: &mut Vec<Change>) {
339 for (key, before) in previous {
340 path.push(key.clone());
341
342 match current.get(key) {
343 Some(after) => compare_values(before, after, path, changes),
344 None => changes.push(change(path, ChangeKind::Removed)),
345 }
346
347 path.pop();
348 }
349
350 for key in current.keys() {
351 if previous.contains_key(key) {
352 continue;
353 }
354
355 path.push(key.clone());
356 changes.push(change(path, ChangeKind::Added));
357 path.pop();
358 }
359}
360
361fn compare_values(
362 before: &Value,
363 after: &Value,
364 path: &mut Vec<String>,
365 changes: &mut Vec<Change>,
366) {
367 match (before, after) {
368 (Value::Dict(_, before), Value::Dict(_, after)) => compare(before, after, path, changes),
371 _ if values_equal(before, after) => {}
372 _ => changes.push(change(path, ChangeKind::Modified)),
373 }
374}
375
376fn values_equal(before: &Value, after: &Value) -> bool {
380 format!("{before:?}") == format!("{after:?}")
381}
382
383fn change(path: &[String], kind: ChangeKind) -> Change {
384 Change {
385 path: path.join("."),
386 kind,
387 }
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393
394 fn dict(entries: &[(&str, Value)]) -> Dict {
395 entries
396 .iter()
397 .map(|(key, value)| ((*key).to_owned(), value.clone()))
398 .collect()
399 }
400
401 fn snapshot(entries: &[(&str, Value)]) -> Snapshot {
402 Snapshot::new(dict(entries))
403 }
404
405 #[test]
406 fn identical_snapshots_have_no_changes() {
407 let one = snapshot(&[("host", "a".into()), ("port", 1u16.into())]);
408 let two = snapshot(&[("host", "a".into()), ("port", 1u16.into())]);
409
410 assert!(one.diff(&two).is_empty());
411 }
412
413 #[test]
414 fn a_modified_value_names_its_key_but_not_its_value() {
415 let one = snapshot(&[("password", "hunter2".into())]);
416 let two = snapshot(&[("password", "letmein".into())]);
417
418 let changes = one.diff(&two);
419
420 assert_eq!(changes.len(), 1);
421 assert_eq!(changes[0].path, "password");
422 assert_eq!(changes[0].kind, ChangeKind::Modified);
423
424 let rendered = changes[0].to_string();
425 assert_eq!(rendered, "password changed");
426 assert!(!rendered.contains("hunter2"), "{rendered}");
427 assert!(!rendered.contains("letmein"), "{rendered}");
428 }
429
430 #[test]
431 fn additions_and_removals_are_told_apart() {
432 let one = snapshot(&[("gone", 1u16.into())]);
433 let two = snapshot(&[("fresh", 1u16.into())]);
434
435 let changes = one.diff(&two);
436
437 assert_eq!(
438 changes,
439 [
440 Change {
441 path: "fresh".to_owned(),
442 kind: ChangeKind::Added,
443 },
444 Change {
445 path: "gone".to_owned(),
446 kind: ChangeKind::Removed,
447 },
448 ]
449 );
450 }
451
452 #[test]
453 fn a_change_inside_a_table_is_reported_at_the_leaf() {
454 let one = snapshot(&[(
455 "pool",
456 Value::from(dict(&[("max", 1u16.into()), ("min", 1u16.into())])),
457 )]);
458 let two = snapshot(&[(
459 "pool",
460 Value::from(dict(&[("max", 2u16.into()), ("min", 1u16.into())])),
461 )]);
462
463 let changes = one.diff(&two);
464
465 assert_eq!(changes.len(), 1);
466 assert_eq!(changes[0].path, "pool.max", "not just `pool`");
467 }
468
469 #[test]
470 fn a_table_replaced_by_a_scalar_is_one_change() {
471 let one = snapshot(&[("pool", Value::from(dict(&[("max", 1u16.into())])))]);
472 let two = snapshot(&[("pool", 1u16.into())]);
473
474 let changes = one.diff(&two);
475
476 assert_eq!(changes.len(), 1);
477 assert_eq!(changes[0].path, "pool");
478 assert_eq!(changes[0].kind, ChangeKind::Modified);
479 }
480
481 #[test]
482 fn a_value_can_be_read_by_path_without_a_struct() {
483 let snapshot = snapshot(&[
484 ("host", "a".into()),
485 ("pool", Value::from(dict(&[("max", 32u16.into())]))),
486 ]);
487
488 assert_eq!(snapshot.get::<String>("host").unwrap(), "a");
489 assert_eq!(snapshot.get::<u16>("pool.max").unwrap(), 32);
490 assert!(snapshot.contains("pool.max"));
491 assert!(!snapshot.contains("pool.min"));
492 }
493
494 #[test]
495 fn a_missing_path_and_a_wrong_type_are_told_apart() {
496 let snapshot = snapshot(&[("host", "a".into())]);
497
498 assert_eq!(
499 snapshot.get::<String>("nowhere").unwrap_err().kind(),
500 ErrorKind::Missing
501 );
502 assert_eq!(
503 snapshot.get::<u16>("host").unwrap_err().kind(),
504 ErrorKind::Type
505 );
506 assert_eq!(
508 snapshot.get::<u16>("host.port").unwrap_err().kind(),
509 ErrorKind::Missing
510 );
511 }
512
513 #[test]
514 fn a_sub_snapshot_carries_only_its_own_table() {
515 let snapshot = snapshot(&[
516 ("host", "a".into()),
517 ("pool", Value::from(dict(&[("max", 32u16.into())]))),
518 ]);
519
520 let pool = snapshot.sub("pool").expect("`pool` is a table");
521
522 assert_eq!(pool.get::<u16>("max").unwrap(), 32);
523 assert!(!pool.contains("host"));
524
525 assert!(snapshot.sub("host").is_none(), "a scalar is not a table");
526 }
527
528 #[test]
529 fn leaf_paths_reach_into_nested_tables() {
530 let snapshot = snapshot(&[
531 ("host", "a".into()),
532 ("pool", Value::from(dict(&[("max", 1u16.into())]))),
533 ]);
534
535 assert_eq!(snapshot.leaf_paths(), ["host", "pool.max"]);
536 assert_eq!(snapshot.top_level_keys(), ["host", "pool"]);
537 }
538
539 #[test]
540 fn extraction_reports_the_path_it_failed_at() {
541 #[derive(serde::Deserialize, Debug)]
542 #[allow(dead_code)]
543 struct Target {
544 port: u16,
545 }
546
547 let error = snapshot(&[("port", "not-a-number".into())])
548 .extract::<Target>()
549 .unwrap_err();
550
551 assert_eq!(error.path(), "port");
552 }
553
554 mod properties {
555 use super::*;
556 use proptest::prelude::*;
557
558 proptest! {
559 #![proptest_config(ProptestConfig::with_cases(256))]
560
561 #[test]
564 fn diff_reports_paths_never_values(
565 a in prop::collection::btree_map("[a-z]{1,8}", "[a-zA-Z0-9]{4,16}", 0..8),
566 b in prop::collection::btree_map("[a-z]{1,8}", "[a-zA-Z0-9]{4,16}", 0..8),
567 ) {
568 let left = Snapshot::new(
569 a.iter().map(|(k, v)| (k.clone(), Value::from(v.clone()))).collect(),
570 );
571 let right = Snapshot::new(
572 b.iter().map(|(k, v)| (k.clone(), Value::from(v.clone()))).collect(),
573 );
574
575 for change in left.diff(&right) {
576 let rendered = change.to_string();
577
578 for value in a.values().chain(b.values()) {
579 prop_assert!(
580 !rendered.contains(value.as_str()),
581 "a diff must name paths, never values: {}",
582 rendered
583 );
584 }
585 }
586 }
587 }
588 }
589}