use assert_cmd::Command;
use tempfile::TempDir;
fn tree(files: &[(&str, &str)]) -> TempDir {
let dir = tempfile::tempdir().expect("tempdir");
for (path, contents) in files {
let full = dir.path().join(path);
std::fs::create_dir_all(full.parent().expect("has a parent")).expect("mkdir");
std::fs::write(&full, contents).expect("write");
}
dir
}
fn knf(dir: &TempDir) -> Command {
let mut cmd = Command::cargo_bin("knf").expect("binary built");
cmd.current_dir(dir.path());
cmd
}
fn run(dir: &TempDir, args: &[&str]) -> String {
ok_stdout(knf(dir).args(args), args)
}
fn run_err(dir: &TempDir, args: &[&str]) -> String {
err_stderr(knf(dir).args(args), args)
}
fn ok_stdout(cmd: &mut Command, args: &[&str]) -> String {
let out = cmd.output().expect("spawn");
assert!(
out.status.success(),
"`knf {}` failed:\n{}",
args.join(" "),
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8(out.stdout).expect("utf-8 stdout")
}
fn err_stderr(cmd: &mut Command, args: &[&str]) -> String {
let out = cmd.output().expect("spawn");
assert_eq!(
out.status.code(),
Some(1),
"`knf {}` should exit 1:\n{}",
args.join(" "),
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8(out.stderr).expect("utf-8 stderr")
}
fn with_env<'a>(cmd: &'a mut Command, vars: &[(&str, Option<&str>)]) -> &'a mut Command {
for (name, value) in vars {
match value {
Some(value) => cmd.env(name, value),
None => cmd.env_remove(name),
};
}
cmd
}
const DATED: &str = "\
name = \"svc\"
date = 1979-05-27T07:32:00Z
";
#[test]
fn toml_datetime_survives_a_toml_round_trip() {
let dir = tree(&[("f.toml", DATED)]);
let out = run(&dir, &["f.toml"]);
assert!(
out.contains("date = 1979-05-27T07:32:00Z"),
"datetime was not emitted unquoted:\n{out}"
);
assert!(
!out.contains("__toml_private"),
"sentinel leaked into output:\n{out}"
);
}
#[test]
fn toml_datetime_survives_set() {
let dir = tree(&[("f.toml", DATED)]);
let out = run(&dir, &["f.toml", "--set", "extra=1"]);
assert!(
out.contains("date = 1979-05-27T07:32:00Z"),
"datetime was not emitted unquoted:\n{out}"
);
assert!(
!out.contains("__toml_private"),
"sentinel leaked into output:\n{out}"
);
assert!(out.contains("extra = 1"), "set layer missing:\n{out}");
}
#[test]
fn mixed_toml_json_to_toml_preserves_datetime() {
let dir = tree(&[("f.toml", DATED), ("g.json", r#"{"extra":1}"#)]);
let out = run(&dir, &["f.toml", "g.json", "-f", "toml"]);
assert!(
out.contains("date = 1979-05-27T07:32:00Z"),
"datetime was not emitted unquoted:\n{out}"
);
assert!(
!out.contains("__toml_private"),
"sentinel leaked into output:\n{out}"
);
assert!(out.contains("extra = 1"), "json overlay missing:\n{out}");
}
#[test]
fn strict_catches_a_json_string_over_a_toml_datetime() {
let dir = tree(&[
("f.toml", DATED),
("g.json", r#"{"date":"1979-05-27T07:32:00Z"}"#),
]);
let err = run_err(&dir, &["f.toml", "g.json", "-f", "toml", "--strict"]);
assert!(
err.contains("type conflict at `date`: datetime would be replaced by string"),
"{err}"
);
}
#[test]
fn toml_datetime_becomes_a_json_string() {
let dir = tree(&[("f.toml", DATED)]);
let out = run(&dir, &["f.toml", "-f", "json", "--compact"]);
assert_eq!(
out,
"{\"name\":\"svc\",\"date\":\"1979-05-27T07:32:00Z\"}\n"
);
}
#[test]
fn key_order_is_preserved_in_both_formats() {
let dir = tree(&[
("a.json", r#"{"zebra":1,"apple":2,"middle":3}"#),
("a.toml", "zebra = 1\napple = 2\nmiddle = 3\n"),
]);
assert_eq!(
run(&dir, &["a.json", "--compact"]),
"{\"zebra\":1,\"apple\":2,\"middle\":3}\n"
);
assert_eq!(run(&dir, &["a.toml"]), "zebra = 1\napple = 2\nmiddle = 3\n");
}
#[test]
fn integers_above_i64_max_are_exact() {
let doc = r#"{"id":10000000000000000001,"max":18446744073709551615}"#;
let dir = tree(&[("a.json", doc)]);
assert_eq!(run(&dir, &["a.json", "--compact"]), format!("{doc}\n"));
}
#[test]
fn a_single_layer_is_a_no_op() {
let doc = r#"{"a":{"b":1},"n":null,"xs":[1,2]}"#;
let dir = tree(&[("a.json", doc)]);
assert_eq!(run(&dir, &["a.json", "--compact"]), format!("{doc}\n"));
}
#[test]
fn cross_format_layers_merge() {
let dir = tree(&[
("base.toml", "[server]\nport = 80\nhost = \"local\"\n"),
("over.json", r#"{"server":{"port":443}}"#),
]);
assert_eq!(
run(&dir, &["base.toml", "over.json", "-f", "json", "--compact"]),
"{\"server\":{\"port\":443,\"host\":\"local\"}}\n"
);
}
#[test]
fn stdin_is_a_layer() {
let dir = tree(&[("base.json", r#"{"a":1,"b":2}"#)]);
let out = knf(&dir)
.args(["base.json", "-", "--input-format", "json", "--compact"])
.write_stdin(r#"{"b":99}"#)
.output()
.expect("spawn");
assert!(out.status.success());
assert_eq!(
String::from_utf8(out.stdout).expect("utf-8"),
"{\"a\":1,\"b\":99}\n"
);
}
#[test]
fn inline_configs_apply_last() {
let dir = tree(&[("a.json", r#"{"server":{"port":80}}"#)]);
assert_eq!(
run(&dir, &["a.json", "--set", "server.port=8080", "--compact"]),
"{\"server\":{\"port\":8080}}\n"
);
assert_eq!(
run(&dir, &["--set", "a.b=1", "--compact"]),
"{\"a\":{\"b\":1}}\n"
);
}
const BASE: &str = "plugins = [\"auth\"]\n[db]\nhost = \"local\"\nport = 5432\n";
const PROD: &str = "plugins = [\"metrics\"]\n[db]\nhost = \"prod\"\n";
#[test]
fn without_rules_arrays_replace_and_tables_merge() {
let dir = tree(&[("base.toml", BASE), ("prod.toml", PROD)]);
let out = run(&dir, &["base.toml", "prod.toml"]);
assert!(out.contains("plugins = [\"metrics\"]"), "{out}");
assert!(out.contains("port = 5432"), "{out}");
}
#[test]
fn append_concatenates_a_toml_array() {
let dir = tree(&[("base.toml", BASE), ("prod.toml", PROD)]);
let out = run(
&dir,
&[
"base.toml",
"prod.toml",
"--append",
"plugins",
"-f",
"json",
"--compact",
],
);
assert_eq!(
out,
"{\"plugins\":[\"auth\",\"metrics\"],\"db\":{\"host\":\"prod\",\"port\":5432}}\n"
);
}
#[test]
fn append_does_not_double_a_single_layer() {
let dir = tree(&[("base.toml", BASE)]);
assert_eq!(
run(&dir, &["base.toml", "--append", "plugins"]),
run(&dir, &["base.toml"])
);
}
#[test]
fn replace_takes_a_table_wholesale() {
let dir = tree(&[("base.toml", BASE), ("prod.toml", PROD)]);
let out = run(&dir, &["base.toml", "prod.toml", "--replace", "db"]);
assert!(out.contains("host = \"prod\""), "{out}");
assert!(!out.contains("port"), "replace recursed into db:\n{out}");
}
#[test]
fn rules_apply_to_set_layers_too() {
let dir = tree(&[("base.toml", BASE)]);
let out = run(
&dir,
&["base.toml", "--replace", "db", "--set", "db.host=x"],
);
assert!(out.contains("host = \"x\""), "{out}");
assert!(
!out.contains("port"),
"--set layer did not replace db:\n{out}"
);
}
#[test]
fn rule_order_does_not_affect_the_output() {
let dir = tree(&[("base.toml", BASE), ("prod.toml", PROD)]);
let forward = run(
&dir,
&[
"base.toml",
"prod.toml",
"--append",
"plugins",
"--replace",
"db",
],
);
let backward = run(
&dir,
&[
"base.toml",
"prod.toml",
"--replace",
"db",
"--append",
"plugins",
],
);
assert_eq!(forward, backward);
}
#[test]
fn fail_allows_a_path_no_layer_sets_twice() {
let dir = tree(&[("base.toml", BASE), ("prod.toml", PROD)]);
let out = run(&dir, &["base.toml", "prod.toml", "--fail", "db.port"]);
assert!(out.contains("port = 5432"), "{out}");
}
#[test]
fn set_null_overwritten_before_toml_emit() {
let dir = tree(&[("f.toml", "a = 0\n")]);
assert_eq!(
run(&dir, &["f.toml", "--set", "a=null", "--set", "a=1"]),
"a = 1\n"
);
}
#[test]
fn set_null_on_toml_is_an_error() {
let dir = tree(&[("f.toml", "a = 0\n")]);
insta::assert_snapshot!(run_err(&dir, &["f.toml", "--set", "proxy=null"]));
}
#[test]
fn null_as_substitutes_on_toml_output() {
let dir = tree(&[("f.toml", "a = 0\n")]);
assert_eq!(
run(
&dir,
&["f.toml", "--set", "proxy=null", "--null-as", "none"]
),
"a = 0\nproxy = \"none\"\n"
);
}
#[test]
fn null_as_substitutes_inside_arrays() {
let dir = tree(&[("f.json", r#"{"xs":[1,null,3]}"#)]);
assert_eq!(
run(&dir, &["f.json", "-f", "toml", "--null-as", "none"]),
"xs = [\n 1,\n \"none\",\n 3,\n]\n"
);
}
#[test]
fn null_as_leaves_json_output_alone() {
let dir = tree(&[("f.json", r#"{"proxy":null}"#)]);
assert_eq!(
run(&dir, &["f.json", "--null-as", "none"]),
"{\n \"proxy\": null\n}\n"
);
}
const REFS: &str = "\
root = \"/srv\"
data_dir = \"${root}/data\"
port = \"${env:KNF_TEST_PORT}\"
url = \"http://localhost:${env:KNF_TEST_PORT}/health\"
literal = \"$${NOT_A_REF}\"
";
#[test]
fn references_pass_through_untouched_without_the_flag() {
let dir = tree(&[("f.toml", REFS)]);
let out = ok_stdout(
with_env(&mut knf(&dir), &[("KNF_TEST_PORT", Some("8080"))]).args(["f.toml"]),
&["f.toml"],
);
assert_eq!(out, REFS);
}
#[test]
fn interpolate_resolves_documents_and_the_environment() {
let dir = tree(&[("f.toml", REFS)]);
let args = ["f.toml", "--interpolate"];
let out = ok_stdout(
with_env(&mut knf(&dir), &[("KNF_TEST_PORT", Some("8080"))]).args(args),
&args,
);
assert_eq!(
out,
"root = \"/srv\"\n\
data_dir = \"/srv/data\"\n\
port = 8080\n\
url = \"http://localhost:8080/health\"\n\
literal = \"${NOT_A_REF}\"\n"
);
}
#[test]
fn whole_string_references_keep_the_referents_type() {
let dir = tree(&[(
"f.json",
r#"{"p":8080,"db":{"host":"local"},"port":"${p}","alias":"${db}","label":"port ${p}"}"#,
)]);
assert_eq!(
run(&dir, &["f.json", "--interpolate", "--compact"]),
"{\"p\":8080,\"db\":{\"host\":\"local\"},\"port\":8080,\
\"alias\":{\"host\":\"local\"},\"label\":\"port 8080\"}\n"
);
}
#[test]
fn references_read_array_elements() {
let dir = tree(&[(
"f.json",
r#"{"servers":[{"host":"a","port":5432}],"url":"http://${servers[0].host}:${servers[0].port}","primary":"${servers[0]}"}"#,
)]);
assert_eq!(
run(&dir, &["f.json", "--interpolate", "--compact"]),
"{\"servers\":[{\"host\":\"a\",\"port\":5432}],\
\"url\":\"http://a:5432\",\"primary\":{\"host\":\"a\",\"port\":5432}}\n"
);
}
#[test]
fn references_read_the_merged_document() {
let dir = tree(&[
("base.json", r#"{"host":"local","url":"http://${host}/"}"#),
("prod.json", r#"{"host":"prod"}"#),
]);
assert_eq!(
run(
&dir,
&["base.json", "prod.json", "--interpolate", "--compact"]
),
"{\"host\":\"prod\",\"url\":\"http://prod/\"}\n"
);
}
#[test]
fn set_layers_interpolate_too() {
let dir = tree(&[("f.json", r#"{"root":"/srv"}"#)]);
assert_eq!(
run(
&dir,
&[
"f.json",
"--set",
"data=${root}/data",
"--interpolate",
"--compact"
]
),
"{\"root\":\"/srv\",\"data\":\"/srv/data\"}\n"
);
}
#[test]
fn strict_sees_types_as_written_not_as_resolved() {
let dir = tree(&[
("a.json", r#"{"p":8080,"port":80}"#),
("b.json", r#"{"port":"${p}"}"#),
]);
let err = run_err(&dir, &["a.json", "b.json", "--strict", "--interpolate"]);
assert!(
err.contains("type conflict at `port`: number would be replaced by string"),
"{err}"
);
}
#[test]
fn a_null_referent_meets_the_existing_toml_null_error() {
let dir = tree(&[("f.json", r#"{"n":null,"copy":"${n}"}"#)]);
let err = run_err(&dir, &["f.json", "-f", "toml", "--interpolate"]);
assert!(err.contains("cannot serialize null to TOML"), "{err}");
assert!(err.contains("--> copy"), "{err}");
assert_eq!(
run(
&dir,
&["f.json", "-f", "toml", "--interpolate", "--null-as", "none"]
),
"n = \"none\"\ncopy = \"none\"\n"
);
}
#[test]
fn unresolved_reference_error() {
let dir = tree(&[(
"f.json",
r#"{"server":{"url":"${db.hostname}"},"tags":["${env:KNF_TEST_REGION}"]}"#,
)]);
let args = ["f.json", "--interpolate"];
insta::assert_snapshot!(err_stderr(
with_env(&mut knf(&dir), &[("KNF_TEST_REGION", None)]).args(args),
&args,
));
}
#[test]
fn embedded_container_reference_error() {
let dir = tree(&[(
"f.json",
r#"{"db":{"host":"x"},"xs":[1],"url":"http://${db}/","tag":"<${xs}>"}"#,
)]);
insta::assert_snapshot!(run_err(&dir, &["f.json", "--interpolate"]));
}
#[test]
fn malformed_reference_error() {
let dir = tree(&[(
"f.json",
r#"{"a":"${b","c":"${missing} ${}","d":"${env:}","e":"${x..y}"}"#,
)]);
insta::assert_snapshot!(run_err(&dir, &["f.json", "--interpolate"]));
}
#[test]
fn malformed_index_error() {
let dir = tree(&[("f.json", r#"{"tags":["a"],"t":"${tags[x]}"}"#)]);
insta::assert_snapshot!(run_err(&dir, &["f.json", "--interpolate"]));
}
#[test]
fn reference_cycle_error() {
let dir = tree(&[("f.json", r#"{"a":"${b}","b":"${c}","c":"${a}"}"#)]);
insta::assert_snapshot!(run_err(&dir, &["f.json", "--interpolate"]));
}
#[test]
fn usage_errors_exit_two() {
let dir = tree(&[]);
let out = knf(&dir).arg("--no-such-flag").output().expect("spawn");
assert_eq!(out.status.code(), Some(2));
}
#[test]
fn malformed_set_exits_two() {
let dir = tree(&[]);
let out = knf(&dir)
.args(["--set", "noequals"])
.output()
.expect("spawn");
assert_eq!(out.status.code(), Some(2));
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("invalid value"), "{err}");
assert!(err.contains("expected KEY.PATH=VALUE"), "{err}");
}
#[test]
fn a_missing_file_exits_one() {
let dir = tree(&[]);
let err = run_err(&dir, &["nope.json"]);
assert!(err.contains("nope.json"), "{err}");
}
#[test]
fn a_non_object_root_is_rejected_by_name() {
let dir = tree(&[("a.json", "[1,2]")]);
let err = run_err(&dir, &["a.json"]);
assert!(err.contains("a.json"), "{err}");
assert!(err.contains("found array"), "{err}");
}
#[test]
fn null_in_toml_error() {
let dir = tree(&[
("base.toml", "[servers.primary]\nhost = \"a\"\n"),
(
"override.json",
r#"{"servers":{"primary":{"proxy":null}},"logging":{"sink":null}}"#,
),
]);
insta::assert_snapshot!(run_err(&dir, &["base.toml", "override.json", "-f", "toml"]));
}
#[test]
fn directory_in_the_default_command_error() {
let dir = tree(&[("config/base.toml", "a = 1\n")]);
insta::assert_snapshot!(run_err(&dir, &["config"]));
}
#[test]
fn mixed_input_formats_error() {
let dir = tree(&[("a.toml", "a = 1\n"), ("b.json", "{}")]);
insta::assert_snapshot!(run_err(&dir, &["a.toml", "b.json"]));
}
#[test]
fn fail_locked_path_error() {
let dir = tree(&[("base.toml", BASE), ("prod.toml", PROD)]);
insta::assert_snapshot!(run_err(
&dir,
&["base.toml", "prod.toml", "--fail", "db.host"]
));
}
#[test]
fn fail_catches_an_ancestor_replacing_it_wholesale() {
let dir = tree(&[
("a.json", r#"{"db":{"host":"local"}}"#),
("b.json", r#"{"db":"oops"}"#),
]);
insta::assert_snapshot!(run_err(&dir, &["a.json", "b.json", "--fail", "db.host"]));
}
#[test]
fn fail_locks_a_set_layer_too() {
let dir = tree(&[("base.toml", BASE)]);
insta::assert_snapshot!(run_err(
&dir,
&["base.toml", "--fail", "db.host", "--set", "db.host=x"]
));
}
#[test]
fn append_over_a_non_array_error() {
let dir = tree(&[
("a.json", r#"{"plugins":"auth"}"#),
("b.json", r#"{"plugins":["metrics"]}"#),
]);
insta::assert_snapshot!(run_err(&dir, &["a.json", "b.json", "--append", "plugins"]));
}
#[test]
fn conflicting_rules_error() {
let dir = tree(&[("a.json", "{}")]);
let forward = run_err(&dir, &["a.json", "--append", "db", "--replace", "db"]);
assert_eq!(
forward,
run_err(&dir, &["a.json", "--replace", "db", "--append", "db"])
);
insta::assert_snapshot!(forward);
}
#[test]
fn a_three_way_conflict_names_every_flag() {
let dir = tree(&[("a.json", "{}")]);
insta::assert_snapshot!(run_err(
&dir,
&["a.json", "--append", "x", "--replace", "x", "--fail", "x"]
));
}
#[test]
fn unreachable_rule_error_precedes_file_io() {
let dir = tree(&[]);
let err = run_err(
&dir,
&["missing.toml", "--replace", "db", "--append", "db.plugins"],
);
assert!(
!err.contains("missing.toml"),
"the rule set should be rejected before the file is read:\n{err}"
);
insta::assert_snapshot!(err);
}
#[test]
fn set_with_an_array_index_errors_before_file_io() {
let dir = tree(&[]);
let err = run_err(&dir, &["missing.toml", "--set", "servers[0]=1"]);
assert!(
!err.contains("missing.toml"),
"the --set path should be rejected before the file is read:\n{err}"
);
insta::assert_snapshot!(err);
}
#[test]
fn rule_flags_reject_array_indices() {
let dir = tree(&[("base.toml", "a = 1\n")]);
for flag in ["--append", "--replace", "--fail"] {
let err = run_err(&dir, &["base.toml", flag, "xs[0]"]);
assert!(
err.contains("merge paths take keys only")
&& err.contains(&format!("help: {flag} takes a key path"))
&& !err.contains("base.toml"),
"{flag} should reject xs[0] before any I/O:\n{err}"
);
}
}
#[test]
fn strict_type_conflict_error() {
let dir = tree(&[
("a.json", r#"{"server":{"port":80}}"#),
("b.json", r#"{"server":5}"#),
]);
insta::assert_snapshot!(run_err(&dir, &["a.json", "b.json", "--strict"]));
}