use serde::Deserialize;
use ktav::{emit_canonical, parse_events, ObjectMap, Value};
const BOM: &str = "\u{FEFF}";
fn s(v: &str) -> Value {
Value::String(v.parse().unwrap_or_else(|_| panic!("scalar: {v:?}")))
}
fn obj(pairs: &[(&str, Value)]) -> Value {
let mut m = ObjectMap::default();
for (k, v) in pairs {
m.insert((*k).into(), v.clone());
}
Value::Object(m)
}
fn pair_value<'a>(value: &'a Value, key: &str) -> &'a Value {
match value {
Value::Object(map) => map
.get(key)
.unwrap_or_else(|| panic!("missing key {key:?}")),
_ => panic!("expected Object root, got {value:?}"),
}
}
fn pair_string<'a>(value: &'a Value, key: &str) -> &'a str {
pair_value(value, key)
.as_str()
.unwrap_or_else(|| panic!("{key} is not a String: {:?}", pair_value(value, key)))
}
fn assert_no_bom(value: &Value) {
match value {
Value::Object(map) => {
for (k, v) in map {
assert!(!k.contains(BOM), "BOM leaked into key {k:?}");
assert_no_bom(v);
}
}
Value::Array(items) => {
for item in items {
assert_no_bom(item);
}
}
Value::String(body) => {
assert!(!body.contains(BOM), "BOM leaked into string {body:?}")
}
_ => {}
}
}
#[test]
fn leading_bom_parses_identically_to_bom_less_document() {
let plain = "port: 8080\nhost: example.com\n";
let with_bom = format!("{BOM}{plain}");
assert_eq!(&with_bom.as_bytes()[..3], &[0xEF, 0xBB, 0xBF]);
let bare = ktav::parse(plain).unwrap();
let bommed = ktav::parse(&with_bom).unwrap();
assert_eq!(bare, bommed);
assert_no_bom(&bommed);
assert_eq!(ktav::parse_strict(&with_bom).unwrap(), bare);
}
#[derive(Debug, Deserialize, PartialEq)]
struct Config {
port: u16,
host: String,
}
#[test]
fn leading_bom_is_skipped_on_serde_and_event_paths() {
let plain = "port: 8080\nhost: example.com\n";
let with_bom = format!("{BOM}{plain}");
let bare: Config = ktav::from_str(plain).unwrap();
let bommed: Config = ktav::from_str(&with_bom).unwrap();
assert_eq!(bare, bommed);
let mut with = Vec::new();
let mut without = Vec::new();
parse_events(&with_bom, |ev| with.push(format!("{ev:?}"))).unwrap();
parse_events(plain, |ev| without.push(format!("{ev:?}"))).unwrap();
assert_eq!(with, without);
assert!(!with.iter().any(|e| e.contains(BOM)));
}
#[test]
fn bom_elsewhere_is_ordinary_content() {
let v = ktav::parse("note: a\u{FEFF}b\n").unwrap();
assert_eq!(pair_string(&v, "note"), "a\u{FEFF}b");
let v = ktav::parse(&format!("note: {BOM}keep\n")).unwrap();
assert_eq!(pair_string(&v, "note"), format!("{BOM}keep"));
let v = ktav::parse(&format!("items: [\n{BOM}alpha\n]\n")).unwrap();
match pair_value(&v, "items") {
Value::Array(items) => {
assert_eq!(items.len(), 1);
assert_eq!(items[0], s(&format!("{BOM}alpha")));
}
other => panic!("expected Array under items, got {other:?}"),
}
let cfg: Config = ktav::from_str(&format!("port: 1\nhost: {BOM}proxy\n")).unwrap();
assert_eq!(cfg.host, format!("{BOM}proxy"));
}
#[test]
fn canonical_writer_never_emits_leading_bom() {
let value = obj(&[
("host", s(&format!("{BOM}proxy.example"))),
("port", Value::Integer("1080".parse().unwrap())),
]);
let text = emit_canonical(&value).unwrap();
assert!(!text.as_bytes().starts_with(&[0xEF, 0xBB, 0xBF]));
assert!(text.starts_with("host: "));
assert!(text.contains(BOM));
let back = ktav::parse(&text).unwrap();
assert_eq!(pair_string(&back, "host"), format!("{BOM}proxy.example"));
}
#[test]
fn leading_bom_is_not_re_emitted_by_canonical_writer() {
let parsed = ktav::parse(&format!("{BOM}host: value\n")).unwrap();
assert_eq!(emit_canonical(&parsed).unwrap(), "host: value\n");
}