use std::path::Path;
use knf::{Map, MergeOptions, Value, format::Format};
use serde_json::json;
use tempfile::TempDir;
fn tree(files: &[(&str, &str)]) -> TempDir {
let dir = tempfile::tempdir().expect("tempdir");
for (path, contents) in files {
std::fs::write(dir.path().join(path), contents).expect("write fixture");
}
dir
}
fn as_json(value: Value) -> serde_json::Value {
knf::value::to_json(value).expect("no non-finite floats in these fixtures")
}
fn overlay(json: serde_json::Value) -> Value {
let serde_json::Value::Object(map) = json else {
panic!("an overlay fixture must be an object")
};
Value::Object(knf::value::object_from_json(map))
}
fn merge_files<P: AsRef<Path>>(paths: &[P], opts: &MergeOptions) -> anyhow::Result<Value> {
let (layers, _formats) = knf::load_layers(paths, None)?;
Ok(knf::merge(layers, opts)?)
}
#[test]
fn load_and_merge_paths_without_a_cli() {
let dir = tree(&[
("base.toml", "[server]\nhost = \"local\"\nport = 80\n"),
("prod.json", r#"{"server":{"port":443},"debug":true}"#),
]);
let paths = [dir.path().join("base.toml"), dir.path().join("prod.json")];
let merged = merge_files(&paths, &MergeOptions::default()).expect("merge succeeds");
assert_eq!(
as_json(merged),
json!({"server": {"host": "local", "port": 443}, "debug": true})
);
}
#[test]
fn shallow_terminal_overlays_and_interpolation_compose() {
let dir = tree(&[
(
"base.json",
r#"{"db":{"host":"a","port":1},"port":80,"root":"/srv"}"#,
),
("prod.json", r#"{"db":{"host":"b"}}"#),
]);
let paths = [dir.path().join("base.json"), dir.path().join("prod.json")];
let overlay = overlay(json!({"port": 443, "data": "${root}/data"}));
let (mut layers, _) = knf::load_layers(&paths, None).expect("layers load");
layers.push(overlay);
let merged = knf::merge(layers, &MergeOptions::SHALLOW).expect("shallow merge succeeds");
let merged = knf::interpolate(merged, &knf::ProcessEnv).expect("document references resolve");
assert_eq!(
as_json(merged),
json!({
"db": {"host": "b"},
"port": 443,
"root": "/srv",
"data": "/srv/data"
})
);
}
#[test]
fn strict_overlay_errors_name_the_key_path() {
let dir = tree(&[("base.json", r#"{"port":80}"#)]);
let paths = [dir.path().join("base.json")];
let overlay = overlay(json!({"port": "wrong kind"}));
let (mut layers, _) = knf::load_layers(&paths, None).expect("layers load");
layers.push(overlay);
let err = knf::merge(layers, &MergeOptions::STRICT).expect_err("strict overlay must fail");
assert_eq!(err.path(), ["port"]);
}
#[test]
fn input_format_can_override_paths_without_extensions() {
let dir = tree(&[("base", r#"{"a":1}"#), ("over", r#"{"b":2}"#)]);
let paths = [dir.path().join("base"), dir.path().join("over")];
let (layers, formats) = knf::load_layers(&paths, Some(Format::Json)).expect("explicit format");
assert_eq!(formats, [Format::Json, Format::Json]);
let merged = knf::merge(layers, &MergeOptions::default()).expect("merge succeeds");
assert_eq!(as_json(merged), json!({"a": 1, "b": 2}));
}
#[test]
fn load_layers_accepts_borrowed_paths() {
let dir = tree(&[("base.json", r#"{"a":1}"#)]);
let path = dir.path().join("base.json");
let merged = merge_files(&[Path::new(&path)], &MergeOptions::default()).expect("borrowed path");
assert_eq!(as_json(merged), json!({"a": 1}));
}
#[test]
fn load_errors_are_typed_and_flag_free() {
let dir = tree(&[("layer", "{}")]);
let err = knf::load_layers(&[dir.path().join("layer")], None)
.expect_err("an extensionless path cannot be typed");
let load = err
.downcast_ref::<knf::LoadError>()
.expect("preserves the load error");
assert!(matches!(load, knf::LoadError::UnknownExtension { .. }));
assert_eq!(load.path(), Some(dir.path().join("layer").as_path()));
assert!(
!load.to_string().contains("--"),
"a library error must not name a flag: {load}"
);
let err = knf::load_layers(&[dir.path()], None).expect_err("a directory is no layer");
assert!(matches!(
err.downcast_ref::<knf::LoadError>(),
Some(knf::LoadError::Directory { .. })
));
}
#[test]
fn interpolate_resolves_against_a_supplied_environment() {
struct StubEnv;
impl knf::Env for StubEnv {
fn lookup(&self, name: &str) -> Option<knf::EnvValue> {
(name == "PORT").then(|| knf::EnvValue {
raw: "8080".to_string(),
typed: knf::value::from_json(knf::json_or_string("8080".to_string())),
})
}
}
let dir = tree(&[(
"base.json",
r#"{"port":"${env:PORT}","url":"x:${env:PORT}"}"#,
)]);
let merged = merge_files(&[dir.path().join("base.json")], &MergeOptions::default())
.expect("merge succeeds");
let merged = knf::interpolate(merged, &StubEnv).expect("the stub supplies PORT");
assert_eq!(as_json(merged), json!({"port": 8080, "url": "x:8080"}));
}
#[test]
fn merge_does_not_interpolate() {
let dir = tree(&[("base.json", r#"{"a":"${b}","b":"literal"}"#)]);
let merged = merge_files(&[dir.path().join("base.json")], &MergeOptions::default())
.expect("no references are resolved");
assert_eq!(as_json(merged), json!({"a": "${b}", "b": "literal"}));
}
#[test]
fn the_null_in_toml_report_locates_the_nulls_and_names_no_flag() {
let dir = tree(&[("base.json", r#"{"a":{"b":null},"c":[1,null]}"#)]);
let merged = merge_files(&[dir.path().join("base.json")], &MergeOptions::default())
.expect("a null is an ordinary value up to the emit");
let err = knf::format::emit(merged, Format::Toml, true, None)
.expect_err("a null cannot be serialized to TOML");
let Some(knf::TomlError::Null(report)) = err.downcast_ref::<knf::TomlError>() else {
panic!("preserves the typed error, got {err}")
};
let text = report.to_string();
assert_eq!(text, "cannot serialize null to TOML\n --> a.b\n --> c[1]");
for flag in ["--null-as", "-f json"] {
assert!(
!text.contains(flag),
"a library error must not name a flag: {text}"
);
}
}
#[test]
fn a_hand_built_datetime_that_does_not_reparse_is_an_error_not_a_panic() {
let dir = tree(&[("base.toml", "name = \"svc\"\n")]);
let mut overlay = Map::new();
overlay.insert("created".to_string(), Value::Datetime("nope".to_string()));
let (mut layers, _) = knf::load_layers(&[dir.path().join("base.toml")], None).expect("toml");
layers.push(Value::Object(overlay));
let merged = knf::merge(layers, &MergeOptions::default())
.expect("a datetime is an ordinary value right up to the emit");
let err = knf::format::emit(merged, Format::Toml, true, None)
.expect_err("`nope` is not a TOML datetime");
let Some(knf::TomlError::Datetime(report)) = err.downcast_ref::<knf::TomlError>() else {
panic!("preserves the typed error, got {err}")
};
let text = report.to_string();
assert_eq!(
text,
"cannot serialize datetime to TOML\n --> created: `nope`"
);
for flag in ["--null-as", "-f json", "--set"] {
assert!(
!text.contains(flag),
"a library error must not name a flag: {text}"
);
}
}
#[test]
fn the_non_finite_report_locates_the_floats_and_names_no_flag() {
let dir = tree(&[("base.toml", "timeout = inf\nbackoff = [1.0, nan]\n")]);
let merged = merge_files(&[dir.path().join("base.toml")], &MergeOptions::default())
.expect("inf is an ordinary value up to the emit");
let err = knf::format::emit(merged, Format::Json, true, None)
.expect_err("JSON has no spelling for inf");
let Some(report) = err.downcast_ref::<knf::NonFiniteFloat>() else {
panic!("preserves the typed error, got {err}")
};
let text = report.to_string();
assert_eq!(
text,
"cannot serialize non-finite number to JSON\n --> timeout: `inf`\n --> backoff[1]: `nan`"
);
for flag in ["-f toml", "--input-format"] {
assert!(
!text.contains(flag),
"a library error must not name a flag: {text}"
);
}
}
#[test]
fn the_integer_out_of_range_report_locates_the_integers_and_names_no_flag() {
let dir = tree(&[("base.json", r#"{"id":10000000000000000001,"ok":42}"#)]);
let merged = merge_files(&[dir.path().join("base.json")], &MergeOptions::default())
.expect("a large integer is an ordinary value up to the emit");
let err = knf::format::emit(merged, Format::Toml, true, None)
.expect_err("TOML integers are signed 64-bit");
let Some(knf::TomlError::Integer(report)) = err.downcast_ref::<knf::TomlError>() else {
panic!("preserves the typed error, got {err}")
};
let text = report.to_string();
assert_eq!(
text,
"cannot serialize integer to TOML\n --> id: `10000000000000000001`"
);
for flag in ["-f json", "--input-format"] {
assert!(
!text.contains(flag),
"a library error must not name a flag: {text}"
);
}
}