use knf::{MergeError, MergeOptions, merge};
use serde_json::Value;
use Expect::{Doc, Error};
struct Case {
name: &'static str,
layers: &'static [&'static str],
strict: bool,
shallow: &'static [&'static str],
expect: Expect,
}
enum Expect {
Doc(&'static str),
Error(&'static str),
}
const fn ok(name: &'static str, layers: &'static [&'static str], doc: &'static str) -> Case {
Case {
name,
layers,
strict: false,
shallow: &[],
expect: Doc(doc),
}
}
const fn strict(name: &'static str, layers: &'static [&'static str], doc: &'static str) -> Case {
Case {
name,
layers,
strict: true,
shallow: &[],
expect: Doc(doc),
}
}
const fn conflict(name: &'static str, layers: &'static [&'static str], path: &'static str) -> Case {
Case {
name,
layers,
strict: true,
shallow: &[],
expect: Error(path),
}
}
const fn shallow(name: &'static str, layers: &'static [&'static str], expect: Expect) -> Case {
Case {
name,
layers,
strict: false,
shallow: &[""],
expect,
}
}
const fn shallow_at(
name: &'static str,
at: &'static [&'static str],
layers: &'static [&'static str],
expect: Expect,
) -> Case {
Case {
name,
layers,
strict: false,
shallow: at,
expect,
}
}
const fn strict_shallow(
name: &'static str,
layers: &'static [&'static str],
expect: Expect,
) -> Case {
Case {
name,
layers,
strict: true,
shallow: &[""],
expect,
}
}
const fn strict_shallow_at(
name: &'static str,
at: &'static [&'static str],
layers: &'static [&'static str],
expect: Expect,
) -> Case {
Case {
name,
layers,
strict: true,
shallow: at,
expect,
}
}
fn options(case: &Case) -> MergeOptions {
MergeOptions {
strict: case.strict,
shallow: case
.shallow
.iter()
.map(|path| match *path {
"" => Vec::new(),
path => path.split('.').map(String::from).collect(),
})
.collect(),
}
}
#[rustfmt::skip]
const CASES: &[Case] = &[
ok("no layers is an empty object", &[], "{}"),
ok("one layer is a no-op", &[r#"{"a":1,"b":{"c":[1,2]}}"#], r#"{"a":1,"b":{"c":[1,2]}}"#),
ok("object + object recurses per key", &[r#"{"a":{"x":1}}"#, r#"{"a":{"y":2}}"#], r#"{"a":{"x":1,"y":2}}"#),
ok("disjoint keys union", &[r#"{"a":1}"#, r#"{"b":2}"#], r#"{"a":1,"b":2}"#),
ok("scalar + scalar is last-wins", &[r#"{"a":1}"#, r#"{"a":2}"#], r#"{"a":2}"#),
ok("scalar shadows an object", &[r#"{"a":{"b":1}}"#, r#"{"a":5}"#], r#"{"a":5}"#),
ok("object shadows a scalar", &[r#"{"a":5}"#, r#"{"a":{"b":1}}"#], r#"{"a":{"b":1}}"#),
ok("array replaces, never index-merges", &[r#"{"a":["x","y","z"]}"#, r#"{"a":["a"]}"#], r#"{"a":["a"]}"#),
ok("array replaces with the empty array", &[r#"{"a":[1,2]}"#, r#"{"a":[]}"#], r#"{"a":[]}"#),
ok("array is not merged element-wise", &[r#"{"a":[{"x":1}]}"#, r#"{"a":[{"y":2}]}"#], r#"{"a":[{"y":2}]}"#),
ok("array replaces a scalar", &[r#"{"a":1}"#, r#"{"a":[1]}"#], r#"{"a":[1]}"#),
ok("null overwrites a scalar", &[r#"{"a":1}"#, r#"{"a":null}"#], r#"{"a":null}"#),
ok("null overwrites an object", &[r#"{"a":{"b":1}}"#, r#"{"a":null}"#], r#"{"a":null}"#),
ok("a value overwrites null", &[r#"{"a":null}"#, r#"{"a":1}"#], r#"{"a":1}"#),
ok("null survives a single layer", &[r#"{"a":null}"#], r#"{"a":null}"#),
ok("left fold, not right", &[r#"{"a":{"b":1}}"#, r#"{"a":5}"#, r#"{"a":{"c":2}}"#], r#"{"a":{"c":2}}"#),
ok("three-layer deep merge", &[r#"{"a":{"b":1}}"#, r#"{"a":{"c":2}}"#, r#"{"a":{"b":9}}"#], r#"{"a":{"b":9,"c":2}}"#),
ok("deep recursion", &[r#"{"a":{"b":{"c":{"d":1}}}}"#, r#"{"a":{"b":{"c":{"e":2}}}}"#], r#"{"a":{"b":{"c":{"d":1,"e":2}}}}"#),
ok("deep insert into a missing branch", &[r#"{"a":{"b":1}}"#, r#"{"x":{"y":{"z":2}}}"#], r#"{"a":{"b":1},"x":{"y":{"z":2}}}"#),
strict("strict allows new keys", &[r#"{"a":1}"#, r#"{"b":2}"#], r#"{"a":1,"b":2}"#),
strict("strict allows same-kind replacement", &[r#"{"a":1}"#, r#"{"a":2}"#], r#"{"a":2}"#),
strict("strict treats int and float as one kind", &[r#"{"a":1}"#, r#"{"a":1.5}"#], r#"{"a":1.5}"#),
strict("strict allows array replacement", &[r#"{"a":[1]}"#, r#"{"a":["x","y"]}"#], r#"{"a":["x","y"]}"#),
strict("strict allows null over null", &[r#"{"a":null}"#, r#"{"a":null}"#], r#"{"a":null}"#),
strict("strict recurses without conflict", &[r#"{"a":{"b":1}}"#, r#"{"a":{"b":2,"c":3}}"#], r#"{"a":{"b":2,"c":3}}"#),
conflict("scalar shadowing an object", &[r#"{"a":{"b":1}}"#, r#"{"a":5}"#], "a"),
conflict("object shadowing a scalar", &[r#"{"a":5}"#, r#"{"a":{"b":1}}"#], "a"),
conflict("array shadowing a scalar", &[r#"{"a":1}"#, r#"{"a":[1]}"#], "a"),
conflict("null shadowing a value", &[r#"{"a":1}"#, r#"{"a":null}"#], "a"),
conflict("value shadowing null", &[r#"{"a":null}"#, r#"{"a":1}"#], "a"),
conflict("string shadowing a number", &[r#"{"a":1}"#, r#"{"a":"1"}"#], "a"),
conflict("bool shadowing a number", &[r#"{"a":1}"#, r#"{"a":true}"#], "a"),
conflict("conflict reports a nested path", &[r#"{"a":{"b":{"c":1}}}"#, r#"{"a":{"b":{"c":[]}}}"#], "a.b.c"),
conflict("conflict from the third layer", &[r#"{"a":1}"#, r#"{"a":2}"#, r#"{"a":"three"}"#], "a"),
shallow("shallow takes a nested object whole", &[r#"{"a":{"x":1,"y":2}}"#, r#"{"a":{"y":9}}"#], Doc(r#"{"a":{"y":9}}"#)),
shallow("shallow keeps untouched top-level keys", &[r#"{"a":{"x":1},"b":1}"#, r#"{"a":{"y":2}}"#], Doc(r#"{"a":{"y":2},"b":1}"#)),
shallow("shallow still inserts new keys", &[r#"{"a":1}"#, r#"{"b":{"c":2}}"#], Doc(r#"{"a":1,"b":{"c":2}}"#)),
shallow("shallow replaces arrays too", &[r#"{"a":[1,2]}"#, r#"{"a":[3]}"#], Doc(r#"{"a":[3]}"#)),
shallow("shallow one layer is a no-op", &[r#"{"a":{"b":[1]}}"#], Doc(r#"{"a":{"b":[1]}}"#)),
shallow("shallow across three layers", &[r#"{"a":{"x":1}}"#, r#"{"a":5}"#, r#"{"a":{"y":2}}"#], Doc(r#"{"a":{"y":2}}"#)),
strict_shallow("strict kind-checks a shallow replace", &[r#"{"a":{"x":1}}"#, r#"{"a":5}"#], Error("a")),
strict_shallow("strict allows a same-kind shallow replace", &[r#"{"a":{"x":1}}"#, r#"{"a":{"y":"s"}}"#], Doc(r#"{"a":{"y":"s"}}"#)),
strict_shallow("strict shallow never looks below the top level", &[r#"{"a":{"x":1}}"#, r#"{"a":{"x":"s"}}"#], Doc(r#"{"a":{"x":"s"}}"#)),
shallow_at("shallow at a path takes its children whole", &["db"], &[r#"{"db":{"pool":{"min":1,"max":5},"host":"a"}}"#, r#"{"db":{"pool":{"max":9}}}"#], Doc(r#"{"db":{"pool":{"max":9},"host":"a"}}"#)),
shallow_at("shallow at a path leaves siblings deep", &["db"], &[r#"{"db":{"x":{"a":1}},"app":{"x":{"a":1}}}"#, r#"{"db":{"x":{"b":2}},"app":{"x":{"b":2}}}"#], Doc(r#"{"db":{"x":{"b":2}},"app":{"x":{"a":1,"b":2}}}"#)),
shallow_at("shallow at a nested path", &["a.b"], &[r#"{"a":{"b":{"c":{"x":1}},"d":{"x":1}}}"#, r#"{"a":{"b":{"c":{"y":2}},"d":{"y":2}}}"#], Doc(r#"{"a":{"b":{"c":{"y":2}},"d":{"x":1,"y":2}}}"#)),
shallow_at("several shallow paths", &["a", "b"], &[r#"{"a":{"x":{"k":1}},"b":{"x":{"k":1}},"c":{"x":{"k":1}}}"#, r#"{"a":{"x":{"j":2}},"b":{"x":{"j":2}},"c":{"x":{"j":2}}}"#], Doc(r#"{"a":{"x":{"j":2}},"b":{"x":{"j":2}},"c":{"x":{"k":1,"j":2}}}"#)),
shallow_at("an outer shallow path makes an inner one moot", &["a", "a.b"], &[r#"{"a":{"b":{"c":{"x":1}}}}"#, r#"{"a":{"b":{"d":2}}}"#], Doc(r#"{"a":{"b":{"d":2}}}"#)),
shallow_at("root and a path together are just root", &["", "a"], &[r#"{"a":{"x":1}}"#, r#"{"a":{"y":2}}"#], Doc(r#"{"a":{"y":2}}"#)),
shallow_at("a missing shallow path is a deep merge", &["nope"], &[r#"{"a":{"x":1}}"#, r#"{"a":{"y":2}}"#], Doc(r#"{"a":{"x":1,"y":2}}"#)),
shallow_at("a shallow path at a scalar just replaces it", &["a"], &[r#"{"a":1}"#, r#"{"a":2}"#], Doc(r#"{"a":2}"#)),
shallow_at("a shallow path at an array just replaces it", &["a"], &[r#"{"a":[{"x":1}]}"#, r#"{"a":[{"y":2}]}"#], Doc(r#"{"a":[{"y":2}]}"#)),
shallow_at("a shallow path shadowed by a scalar", &["a"], &[r#"{"a":{"x":1}}"#, r#"{"a":5}"#, r#"{"a":{"y":2}}"#], Doc(r#"{"a":{"y":2}}"#)),
strict_shallow_at("strict kind-checks a pathed shallow replace", &["a"], &[r#"{"a":{"b":{"x":1}}}"#, r#"{"a":{"b":5}}"#], Error("a.b")),
strict_shallow_at("strict pathed shallow never looks below the path's children", &["a"], &[r#"{"a":{"b":{"x":1}}}"#, r#"{"a":{"b":{"x":"s"}}}"#], Doc(r#"{"a":{"b":{"x":"s"}}}"#)),
];
#[test]
fn table() {
for case in CASES {
let layers = case.layers.iter().map(|s| ir(s));
let got = merge(layers, &options(case));
match (&case.expect, got) {
(Doc(want), Ok(got)) => {
assert_eq!(got, ir(want), "case `{}`", case.name);
}
(Doc(want), Err(e)) => {
panic!("case `{}`: expected {want}, got error: {e}", case.name);
}
(Error(want), Err(e)) => {
assert_eq!(
e.path().join("."),
*want,
"case `{}`: wrong path",
case.name
);
}
(Error(want), Ok(got)) => {
panic!(
"case `{}`: expected a type conflict at `{want}`, merged to {got:?}",
case.name
);
}
}
}
}
#[test]
fn merge_into_matches_merge() {
for case in CASES {
let Doc(want) = case.expect else {
continue;
};
let opts = options(case);
let mut acc = Value::Object(Default::default());
for layer in case.layers {
knf::merge_into(&mut acc, ir(layer), &opts).expect(case.name);
}
assert_eq!(acc, ir(want), "case `{}`", case.name);
}
}
#[test]
fn root_level_conflict_has_empty_path() {
let mut base = Value::Object(Default::default());
let err = knf::merge_into(&mut base, Value::Bool(true), &MergeOptions::STRICT).unwrap_err();
assert_eq!(err.path(), &[] as &[String]);
assert!(err.to_string().contains("<root>"), "{err}");
}
#[test]
fn conflict_message_names_both_kinds() {
let err = merge(
[ir(r#"{"a":{"b":1}}"#), ir(r#"{"a":5}"#)],
&MergeOptions::STRICT,
)
.unwrap_err();
assert_eq!(
err.to_string(),
"type conflict at `a`: object would be replaced by number"
);
}
#[test]
fn datetime_conflicts_with_string_under_strict() {
let mut base: toml::Value = toml::from_str("a = 1979-05-27T07:32:00Z").unwrap();
let over: toml::Value = toml::from_str("a = '1979-05-27T07:32:00Z'").unwrap();
let err = knf::merge_into(&mut base, over, &MergeOptions::STRICT).unwrap_err();
let MergeError::TypeConflict {
path,
expected,
found,
} = err;
assert_eq!(path, ["a"]);
assert_eq!(expected, "datetime");
assert_eq!(found, "string");
}
fn ir(s: &str) -> Value {
serde_json::from_str(s).unwrap_or_else(|e| panic!("bad JSON literal `{s}`: {e}"))
}
#[test]
fn toml_table_and_merge_into() {
for case in CASES {
let fixtures: Vec<Value> = case.layers.iter().map(|text| ir(text)).collect();
if fixtures.iter().any(has_null) {
continue;
}
let layers: Vec<toml::Value> = fixtures
.into_iter()
.map(|value| toml::Value::try_from(value).unwrap())
.collect();
let opts = options(case);
let got = merge(layers.clone(), &opts);
match (&case.expect, got) {
(Doc(want), Ok(got)) => {
let expected = toml::Value::try_from(ir(want)).unwrap();
assert_eq!(got, expected, "{}", case.name);
let mut acc = toml::Value::Table(Default::default());
for layer in layers {
knf::merge_into(&mut acc, layer, &opts).unwrap();
}
assert_eq!(acc, expected, "{}", case.name);
}
(Error(want), Err(error)) => assert_eq!(error.path().join("."), *want, "{}", case.name),
(_, got) => panic!("{}: unexpected result {got:?}", case.name),
}
}
}
fn has_null(value: &Value) -> bool {
match value {
Value::Null => true,
Value::Array(items) => items.iter().any(has_null),
Value::Object(map) => map.values().any(has_null),
_ => false,
}
}