use std::collections::BTreeMap;
use std::fmt;
use figment::value::{Dict, Value};
use serde::de::DeserializeOwned;
use crate::error::{Error, ErrorKind, Origin};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ChangeKind {
Added,
Removed,
Modified,
}
impl fmt::Display for ChangeKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Added => "added",
Self::Removed => "removed",
Self::Modified => "changed",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Change {
pub path: String,
pub kind: ChangeKind,
}
impl fmt::Display for Change {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} {}", self.path, self.kind)
}
}
#[derive(Clone, Default)]
pub struct Snapshot {
values: Dict,
provenance: BTreeMap<String, Origin>,
}
impl Snapshot {
pub(crate) fn new(values: Dict) -> Self {
Self {
values,
provenance: BTreeMap::new(),
}
}
pub(crate) fn attach_provenance(&mut self, provenance: BTreeMap<String, Origin>) {
self.provenance = provenance;
}
#[must_use]
pub fn source_of(&self, path: &str) -> Option<&Origin> {
self.provenance.get(path)
}
#[must_use]
pub fn to_value(&self) -> crate::Value {
crate::value::Value::Table(
self.values
.iter()
.map(|(key, value)| (key.clone(), crate::value::from_figment(value)))
.collect(),
)
}
pub fn extract<T: DeserializeOwned>(&self) -> Result<T, Error> {
Value::from(self.values.clone())
.deserialize()
.map_err(|error: figment::Error| {
let mut translated = Error::new(ErrorKind::Type, error.to_string());
for segment in error.path.iter().rev() {
translated = translated.prepend_key(segment);
}
translated
})
}
#[must_use]
pub fn diff(&self, other: &Self) -> Vec<Change> {
let mut changes = Vec::new();
compare(&self.values, &other.values, &mut Vec::new(), &mut changes);
changes.sort_by(|left, right| left.path.cmp(&right.path));
changes
}
pub fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, Error> {
let value = self.at(path).ok_or_else(|| {
Error::new(ErrorKind::Missing, "no value at this path").prepend_key(path)
})?;
value.deserialize().map_err(|error: figment::Error| {
Error::new(ErrorKind::Type, error.to_string()).prepend_key(path)
})
}
pub(crate) fn without_top_level(&self, key: &str) -> Self {
let mut values = self.values().clone();
values.remove(key);
let prefix = format!("{key}.");
let provenance = self
.provenance
.iter()
.filter(|(path, _)| *path != key && !path.starts_with(&prefix))
.map(|(path, origin)| (path.clone(), origin.clone()))
.collect();
Self { values, provenance }
}
#[must_use]
pub fn contains(&self, path: &str) -> bool {
self.at(path).is_some()
}
#[must_use]
pub fn sub(&self, path: &str) -> Option<Self> {
match self.at(path)? {
Value::Dict(_, nested) => {
let prefix = format!("{path}.");
let provenance = self
.provenance
.iter()
.filter_map(|(leaf, origin)| {
leaf.strip_prefix(&prefix)
.map(|rest| (rest.to_owned(), origin.clone()))
})
.collect();
Some(Self {
values: nested.clone(),
provenance,
})
}
_ => None,
}
}
fn at(&self, path: &str) -> Option<&Value> {
let mut segments = path.split('.');
let mut current = self.values.get(segments.next()?)?;
for segment in segments {
let Value::Dict(_, nested) = current else {
return None;
};
current = nested.get(segment)?;
}
Some(current)
}
#[must_use]
pub fn leaf_paths(&self) -> Vec<String> {
let mut paths = Vec::new();
collect_leaves(&self.values, &mut Vec::new(), &mut paths);
paths
}
#[must_use]
pub fn top_level_keys(&self) -> Vec<String> {
self.values.keys().cloned().collect()
}
pub(crate) fn values(&self) -> &Dict {
&self.values
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.values.is_empty()
}
}
impl fmt::Debug for Snapshot {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Snapshot")
.field("keys", &self.top_level_keys())
.field("leaves", &self.leaf_paths().len())
.field("provenance", &self.provenance.len())
.finish_non_exhaustive()
}
}
pub fn changed_paths<T: serde::Serialize>(previous: &T, current: &T) -> Result<Vec<Change>, Error> {
let as_snapshot = |value: &T| -> Result<Snapshot, Error> {
match Value::serialize(value) {
Ok(Value::Dict(_, dict)) => Ok(Snapshot::new(dict)),
Ok(_) => Err(Error::new(
ErrorKind::Type,
"only a table has paths to compare; this serializes to a scalar",
)),
Err(error) => Err(Error::new(ErrorKind::Type, error.to_string())),
}
};
Ok(as_snapshot(previous)?.diff(&as_snapshot(current)?))
}
fn collect_leaves(values: &Dict, path: &mut Vec<String>, paths: &mut Vec<String>) {
for (key, value) in values {
path.push(key.clone());
match value {
Value::Dict(_, nested) if !nested.is_empty() => {
collect_leaves(nested, path, paths);
}
_ => paths.push(path.join(".")),
}
path.pop();
}
}
fn compare(previous: &Dict, current: &Dict, path: &mut Vec<String>, changes: &mut Vec<Change>) {
for (key, before) in previous {
path.push(key.clone());
match current.get(key) {
Some(after) => compare_values(before, after, path, changes),
None => changes.push(change(path, ChangeKind::Removed)),
}
path.pop();
}
for key in current.keys() {
if previous.contains_key(key) {
continue;
}
path.push(key.clone());
changes.push(change(path, ChangeKind::Added));
path.pop();
}
}
fn compare_values(
before: &Value,
after: &Value,
path: &mut Vec<String>,
changes: &mut Vec<Change>,
) {
match (before, after) {
(Value::Dict(_, before), Value::Dict(_, after)) => compare(before, after, path, changes),
_ if values_equal(before, after) => {}
_ => changes.push(change(path, ChangeKind::Modified)),
}
}
fn values_equal(before: &Value, after: &Value) -> bool {
format!("{before:?}") == format!("{after:?}")
}
fn change(path: &[String], kind: ChangeKind) -> Change {
Change {
path: path.join("."),
kind,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn dict(entries: &[(&str, Value)]) -> Dict {
entries
.iter()
.map(|(key, value)| ((*key).to_owned(), value.clone()))
.collect()
}
fn snapshot(entries: &[(&str, Value)]) -> Snapshot {
Snapshot::new(dict(entries))
}
#[test]
fn identical_snapshots_have_no_changes() {
let one = snapshot(&[("host", "a".into()), ("port", 1u16.into())]);
let two = snapshot(&[("host", "a".into()), ("port", 1u16.into())]);
assert!(one.diff(&two).is_empty());
}
#[test]
fn a_modified_value_names_its_key_but_not_its_value() {
let one = snapshot(&[("password", "hunter2".into())]);
let two = snapshot(&[("password", "letmein".into())]);
let changes = one.diff(&two);
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].path, "password");
assert_eq!(changes[0].kind, ChangeKind::Modified);
let rendered = changes[0].to_string();
assert_eq!(rendered, "password changed");
assert!(!rendered.contains("hunter2"), "{rendered}");
assert!(!rendered.contains("letmein"), "{rendered}");
}
#[test]
fn additions_and_removals_are_told_apart() {
let one = snapshot(&[("gone", 1u16.into())]);
let two = snapshot(&[("fresh", 1u16.into())]);
let changes = one.diff(&two);
assert_eq!(
changes,
[
Change {
path: "fresh".to_owned(),
kind: ChangeKind::Added,
},
Change {
path: "gone".to_owned(),
kind: ChangeKind::Removed,
},
]
);
}
#[test]
fn a_change_inside_a_table_is_reported_at_the_leaf() {
let one = snapshot(&[(
"pool",
Value::from(dict(&[("max", 1u16.into()), ("min", 1u16.into())])),
)]);
let two = snapshot(&[(
"pool",
Value::from(dict(&[("max", 2u16.into()), ("min", 1u16.into())])),
)]);
let changes = one.diff(&two);
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].path, "pool.max", "not just `pool`");
}
#[test]
fn a_table_replaced_by_a_scalar_is_one_change() {
let one = snapshot(&[("pool", Value::from(dict(&[("max", 1u16.into())])))]);
let two = snapshot(&[("pool", 1u16.into())]);
let changes = one.diff(&two);
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].path, "pool");
assert_eq!(changes[0].kind, ChangeKind::Modified);
}
#[test]
fn a_value_can_be_read_by_path_without_a_struct() {
let snapshot = snapshot(&[
("host", "a".into()),
("pool", Value::from(dict(&[("max", 32u16.into())]))),
]);
assert_eq!(snapshot.get::<String>("host").unwrap(), "a");
assert_eq!(snapshot.get::<u16>("pool.max").unwrap(), 32);
assert!(snapshot.contains("pool.max"));
assert!(!snapshot.contains("pool.min"));
}
#[test]
fn a_missing_path_and_a_wrong_type_are_told_apart() {
let snapshot = snapshot(&[("host", "a".into())]);
assert_eq!(
snapshot.get::<String>("nowhere").unwrap_err().kind(),
ErrorKind::Missing
);
assert_eq!(
snapshot.get::<u16>("host").unwrap_err().kind(),
ErrorKind::Type
);
assert_eq!(
snapshot.get::<u16>("host.port").unwrap_err().kind(),
ErrorKind::Missing
);
}
#[test]
fn a_sub_snapshot_carries_only_its_own_table() {
let snapshot = snapshot(&[
("host", "a".into()),
("pool", Value::from(dict(&[("max", 32u16.into())]))),
]);
let pool = snapshot.sub("pool").expect("`pool` is a table");
assert_eq!(pool.get::<u16>("max").unwrap(), 32);
assert!(!pool.contains("host"));
assert!(snapshot.sub("host").is_none(), "a scalar is not a table");
}
#[test]
fn leaf_paths_reach_into_nested_tables() {
let snapshot = snapshot(&[
("host", "a".into()),
("pool", Value::from(dict(&[("max", 1u16.into())]))),
]);
assert_eq!(snapshot.leaf_paths(), ["host", "pool.max"]);
assert_eq!(snapshot.top_level_keys(), ["host", "pool"]);
}
#[test]
fn extraction_reports_the_path_it_failed_at() {
#[derive(serde::Deserialize, Debug)]
#[allow(dead_code)]
struct Target {
port: u16,
}
let error = snapshot(&[("port", "not-a-number".into())])
.extract::<Target>()
.unwrap_err();
assert_eq!(error.path(), "port");
}
mod properties {
use super::*;
use proptest::prelude::*;
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn diff_reports_paths_never_values(
a in prop::collection::btree_map("[a-z]{1,8}", "[a-zA-Z0-9]{4,16}", 0..8),
b in prop::collection::btree_map("[a-z]{1,8}", "[a-zA-Z0-9]{4,16}", 0..8),
) {
let left = Snapshot::new(
a.iter().map(|(k, v)| (k.clone(), Value::from(v.clone()))).collect(),
);
let right = Snapshot::new(
b.iter().map(|(k, v)| (k.clone(), Value::from(v.clone()))).collect(),
);
for change in left.diff(&right) {
let rendered = change.to_string();
for value in a.values().chain(b.values()) {
prop_assert!(
!rendered.contains(value.as_str()),
"a diff must name paths, never values: {}",
rendered
);
}
}
}
}
}
}