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 pub fn extract<T: DeserializeOwned>(&self) -> Result<T, Error> {
111 Value::from(self.values.clone())
112 .deserialize()
113 .map_err(|error: figment::Error| {
114 let mut translated = Error::new(ErrorKind::Type, error.to_string());
115
116 for segment in error.path.iter().rev() {
117 translated = translated.prepend_key(segment);
118 }
119
120 translated
121 })
122 }
123
124 #[must_use]
129 pub fn diff(&self, other: &Self) -> Vec<Change> {
130 let mut changes = Vec::new();
131
132 compare(&self.values, &other.values, &mut Vec::new(), &mut changes);
133 changes.sort_by(|left, right| left.path.cmp(&right.path));
134
135 changes
136 }
137
138 pub fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, Error> {
148 let value = self.at(path).ok_or_else(|| {
149 Error::new(ErrorKind::Missing, "no value at this path").prepend_key(path)
150 })?;
151
152 value.deserialize().map_err(|error: figment::Error| {
153 Error::new(ErrorKind::Type, error.to_string()).prepend_key(path)
154 })
155 }
156
157 pub(crate) fn without_top_level(&self, key: &str) -> Self {
160 let mut values = self.values().clone();
161 values.remove(key);
162
163 let prefix = format!("{key}.");
164 let provenance = self
165 .provenance
166 .iter()
167 .filter(|(path, _)| *path != key && !path.starts_with(&prefix))
168 .map(|(path, origin)| (path.clone(), origin.clone()))
169 .collect();
170
171 Self { values, provenance }
172 }
173
174 #[must_use]
176 pub fn contains(&self, path: &str) -> bool {
177 self.at(path).is_some()
178 }
179
180 #[must_use]
187 pub fn sub(&self, path: &str) -> Option<Self> {
188 match self.at(path)? {
189 Value::Dict(_, nested) => {
190 let prefix = format!("{path}.");
191 let provenance = self
192 .provenance
193 .iter()
194 .filter_map(|(leaf, origin)| {
195 leaf.strip_prefix(&prefix)
196 .map(|rest| (rest.to_owned(), origin.clone()))
197 })
198 .collect();
199
200 Some(Self {
201 values: nested.clone(),
202 provenance,
203 })
204 }
205 _ => None,
206 }
207 }
208
209 fn at(&self, path: &str) -> Option<&Value> {
210 let mut segments = path.split('.');
211 let mut current = self.values.get(segments.next()?)?;
212
213 for segment in segments {
214 let Value::Dict(_, nested) = current else {
215 return None;
216 };
217
218 current = nested.get(segment)?;
219 }
220
221 Some(current)
222 }
223
224 #[must_use]
226 pub fn leaf_paths(&self) -> Vec<String> {
227 let mut paths = Vec::new();
228
229 collect_leaves(&self.values, &mut Vec::new(), &mut paths);
230
231 paths
232 }
233
234 #[must_use]
236 pub fn top_level_keys(&self) -> Vec<String> {
237 self.values.keys().cloned().collect()
238 }
239
240 pub(crate) fn values(&self) -> &Dict {
242 &self.values
243 }
244
245 #[must_use]
247 pub fn is_empty(&self) -> bool {
248 self.values.is_empty()
249 }
250}
251
252impl fmt::Debug for Snapshot {
257 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
258 f.debug_struct("Snapshot")
259 .field("keys", &self.top_level_keys())
260 .field("leaves", &self.leaf_paths().len())
261 .field("provenance", &self.provenance.len())
262 .finish_non_exhaustive()
263 }
264}
265
266pub fn changed_paths<T: serde::Serialize>(previous: &T, current: &T) -> Result<Vec<Change>, Error> {
290 let as_snapshot = |value: &T| -> Result<Snapshot, Error> {
291 match Value::serialize(value) {
292 Ok(Value::Dict(_, dict)) => Ok(Snapshot::new(dict)),
293 Ok(_) => Err(Error::new(
294 ErrorKind::Type,
295 "only a table has paths to compare; this serializes to a scalar",
296 )),
297 Err(error) => Err(Error::new(ErrorKind::Type, error.to_string())),
298 }
299 };
300
301 Ok(as_snapshot(previous)?.diff(&as_snapshot(current)?))
302}
303
304fn collect_leaves(values: &Dict, path: &mut Vec<String>, paths: &mut Vec<String>) {
306 for (key, value) in values {
307 path.push(key.clone());
308
309 match value {
310 Value::Dict(_, nested) if !nested.is_empty() => {
311 collect_leaves(nested, path, paths);
312 }
313 _ => paths.push(path.join(".")),
314 }
315
316 path.pop();
317 }
318}
319
320fn compare(previous: &Dict, current: &Dict, path: &mut Vec<String>, changes: &mut Vec<Change>) {
322 for (key, before) in previous {
323 path.push(key.clone());
324
325 match current.get(key) {
326 Some(after) => compare_values(before, after, path, changes),
327 None => changes.push(change(path, ChangeKind::Removed)),
328 }
329
330 path.pop();
331 }
332
333 for key in current.keys() {
334 if previous.contains_key(key) {
335 continue;
336 }
337
338 path.push(key.clone());
339 changes.push(change(path, ChangeKind::Added));
340 path.pop();
341 }
342}
343
344fn compare_values(
345 before: &Value,
346 after: &Value,
347 path: &mut Vec<String>,
348 changes: &mut Vec<Change>,
349) {
350 match (before, after) {
351 (Value::Dict(_, before), Value::Dict(_, after)) => compare(before, after, path, changes),
354 _ if values_equal(before, after) => {}
355 _ => changes.push(change(path, ChangeKind::Modified)),
356 }
357}
358
359fn values_equal(before: &Value, after: &Value) -> bool {
363 format!("{before:?}") == format!("{after:?}")
364}
365
366fn change(path: &[String], kind: ChangeKind) -> Change {
367 Change {
368 path: path.join("."),
369 kind,
370 }
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376
377 fn dict(entries: &[(&str, Value)]) -> Dict {
378 entries
379 .iter()
380 .map(|(key, value)| ((*key).to_owned(), value.clone()))
381 .collect()
382 }
383
384 fn snapshot(entries: &[(&str, Value)]) -> Snapshot {
385 Snapshot::new(dict(entries))
386 }
387
388 #[test]
389 fn identical_snapshots_have_no_changes() {
390 let one = snapshot(&[("host", "a".into()), ("port", 1u16.into())]);
391 let two = snapshot(&[("host", "a".into()), ("port", 1u16.into())]);
392
393 assert!(one.diff(&two).is_empty());
394 }
395
396 #[test]
397 fn a_modified_value_names_its_key_but_not_its_value() {
398 let one = snapshot(&[("password", "hunter2".into())]);
399 let two = snapshot(&[("password", "letmein".into())]);
400
401 let changes = one.diff(&two);
402
403 assert_eq!(changes.len(), 1);
404 assert_eq!(changes[0].path, "password");
405 assert_eq!(changes[0].kind, ChangeKind::Modified);
406
407 let rendered = changes[0].to_string();
408 assert_eq!(rendered, "password changed");
409 assert!(!rendered.contains("hunter2"), "{rendered}");
410 assert!(!rendered.contains("letmein"), "{rendered}");
411 }
412
413 #[test]
414 fn additions_and_removals_are_told_apart() {
415 let one = snapshot(&[("gone", 1u16.into())]);
416 let two = snapshot(&[("fresh", 1u16.into())]);
417
418 let changes = one.diff(&two);
419
420 assert_eq!(
421 changes,
422 [
423 Change {
424 path: "fresh".to_owned(),
425 kind: ChangeKind::Added,
426 },
427 Change {
428 path: "gone".to_owned(),
429 kind: ChangeKind::Removed,
430 },
431 ]
432 );
433 }
434
435 #[test]
436 fn a_change_inside_a_table_is_reported_at_the_leaf() {
437 let one = snapshot(&[(
438 "pool",
439 Value::from(dict(&[("max", 1u16.into()), ("min", 1u16.into())])),
440 )]);
441 let two = snapshot(&[(
442 "pool",
443 Value::from(dict(&[("max", 2u16.into()), ("min", 1u16.into())])),
444 )]);
445
446 let changes = one.diff(&two);
447
448 assert_eq!(changes.len(), 1);
449 assert_eq!(changes[0].path, "pool.max", "not just `pool`");
450 }
451
452 #[test]
453 fn a_table_replaced_by_a_scalar_is_one_change() {
454 let one = snapshot(&[("pool", Value::from(dict(&[("max", 1u16.into())])))]);
455 let two = snapshot(&[("pool", 1u16.into())]);
456
457 let changes = one.diff(&two);
458
459 assert_eq!(changes.len(), 1);
460 assert_eq!(changes[0].path, "pool");
461 assert_eq!(changes[0].kind, ChangeKind::Modified);
462 }
463
464 #[test]
465 fn a_value_can_be_read_by_path_without_a_struct() {
466 let snapshot = snapshot(&[
467 ("host", "a".into()),
468 ("pool", Value::from(dict(&[("max", 32u16.into())]))),
469 ]);
470
471 assert_eq!(snapshot.get::<String>("host").unwrap(), "a");
472 assert_eq!(snapshot.get::<u16>("pool.max").unwrap(), 32);
473 assert!(snapshot.contains("pool.max"));
474 assert!(!snapshot.contains("pool.min"));
475 }
476
477 #[test]
478 fn a_missing_path_and_a_wrong_type_are_told_apart() {
479 let snapshot = snapshot(&[("host", "a".into())]);
480
481 assert_eq!(
482 snapshot.get::<String>("nowhere").unwrap_err().kind(),
483 ErrorKind::Missing
484 );
485 assert_eq!(
486 snapshot.get::<u16>("host").unwrap_err().kind(),
487 ErrorKind::Type
488 );
489 assert_eq!(
491 snapshot.get::<u16>("host.port").unwrap_err().kind(),
492 ErrorKind::Missing
493 );
494 }
495
496 #[test]
497 fn a_sub_snapshot_carries_only_its_own_table() {
498 let snapshot = snapshot(&[
499 ("host", "a".into()),
500 ("pool", Value::from(dict(&[("max", 32u16.into())]))),
501 ]);
502
503 let pool = snapshot.sub("pool").expect("`pool` is a table");
504
505 assert_eq!(pool.get::<u16>("max").unwrap(), 32);
506 assert!(!pool.contains("host"));
507
508 assert!(snapshot.sub("host").is_none(), "a scalar is not a table");
509 }
510
511 #[test]
512 fn leaf_paths_reach_into_nested_tables() {
513 let snapshot = snapshot(&[
514 ("host", "a".into()),
515 ("pool", Value::from(dict(&[("max", 1u16.into())]))),
516 ]);
517
518 assert_eq!(snapshot.leaf_paths(), ["host", "pool.max"]);
519 assert_eq!(snapshot.top_level_keys(), ["host", "pool"]);
520 }
521
522 #[test]
523 fn extraction_reports_the_path_it_failed_at() {
524 #[derive(serde::Deserialize, Debug)]
525 #[allow(dead_code)]
526 struct Target {
527 port: u16,
528 }
529
530 let error = snapshot(&[("port", "not-a-number".into())])
531 .extract::<Target>()
532 .unwrap_err();
533
534 assert_eq!(error.path(), "port");
535 }
536
537 mod properties {
538 use super::*;
539 use proptest::prelude::*;
540
541 proptest! {
542 #![proptest_config(ProptestConfig::with_cases(256))]
543
544 #[test]
547 fn diff_reports_paths_never_values(
548 a in prop::collection::btree_map("[a-z]{1,8}", "[a-zA-Z0-9]{4,16}", 0..8),
549 b in prop::collection::btree_map("[a-z]{1,8}", "[a-zA-Z0-9]{4,16}", 0..8),
550 ) {
551 let left = Snapshot::new(
552 a.iter().map(|(k, v)| (k.clone(), Value::from(v.clone()))).collect(),
553 );
554 let right = Snapshot::new(
555 b.iter().map(|(k, v)| (k.clone(), Value::from(v.clone()))).collect(),
556 );
557
558 for change in left.diff(&right) {
559 let rendered = change.to_string();
560
561 for value in a.values().chain(b.values()) {
562 prop_assert!(
563 !rendered.contains(value.as_str()),
564 "a diff must name paths, never values: {}",
565 rendered
566 );
567 }
568 }
569 }
570 }
571 }
572}