#![allow(missing_docs, clippy::unwrap_used, clippy::expect_used)]
use noyalib::{
DuplicateKeyPolicy, Error, ErrorKind, MergeKeyPolicy, NonScalarKeyPolicy, ParserConfig,
Spanned, Value, from_str, from_str_with_config,
};
struct Case {
label: &'static str,
yaml: &'static str,
configure: fn(&mut ParserConfig),
kind: ErrorKind,
phrase: &'static str,
}
const CASES: &[Case] = &[
Case {
label: "max_nodes",
yaml: "a: 1\nb: 2\nc: 3\nd: 4\n",
configure: |c| c.max_nodes = 3,
kind: ErrorKind::Budget,
phrase: "max_nodes",
},
Case {
label: "max_events",
yaml: "a: 1\nb: 2\nc: 3\n",
configure: |c| c.max_events = 4,
kind: ErrorKind::Budget,
phrase: "max_events",
},
Case {
label: "max_mapping_keys",
yaml: "a: 1\nb: 2\nc: 3\n",
configure: |c| c.max_mapping_keys = 2,
kind: ErrorKind::Budget,
phrase: "max_mapping_keys",
},
Case {
label: "max_sequence_length",
yaml: "- 1\n- 2\n- 3\n",
configure: |c| c.max_sequence_length = 2,
kind: ErrorKind::Budget,
phrase: "max_sequence_length",
},
Case {
label: "max_total_scalar_bytes",
yaml: "a: abcdefghij\n",
configure: |c| c.max_total_scalar_bytes = 4,
kind: ErrorKind::Budget,
phrase: "max_total_scalar_bytes",
},
Case {
label: "max_merge_keys",
yaml: "x: &x\n a: 1\ny:\n <<: *x\nz:\n <<: *x\n",
configure: |c| c.max_merge_keys = 1,
kind: ErrorKind::Budget,
phrase: "max_merge_keys",
},
Case {
label: "alias_anchor_ratio",
yaml: "x: &x 1\na: *x\nb: *x\nc: *x\n",
configure: |c| c.alias_anchor_ratio = Some(1.0),
kind: ErrorKind::Budget,
phrase: "alias_anchor_ratio",
},
Case {
label: "MergeKeyPolicy::Error",
yaml: "x: &x\n a: 1\ny:\n <<: *x\n",
configure: |c| c.merge_key_policy = MergeKeyPolicy::Error,
kind: ErrorKind::Other,
phrase: "merge key",
},
Case {
label: "DuplicateKeyPolicy::Error",
yaml: "a: 1\na: 2\n",
configure: |c| c.duplicate_key_policy = DuplicateKeyPolicy::Error,
kind: ErrorKind::DuplicateKey,
phrase: "duplicate key",
},
Case {
label: "NonScalarKeyPolicy::Error",
yaml: "? [a, b]\n: 1\n",
configure: |c| c.non_scalar_key_policy = NonScalarKeyPolicy::Error,
kind: ErrorKind::NonScalarKey,
phrase: "sequence",
},
Case {
label: "integer_overflow_errors",
yaml: "a: 99999999999999999999999\n",
configure: |c| c.integer_overflow_errors = true,
kind: ErrorKind::IntegerOverflow,
phrase: "64 bits",
},
];
#[test]
fn every_limit_refuses_when_it_is_exceeded() {
for case in CASES {
let mut cfg = ParserConfig::new();
(case.configure)(&mut cfg);
let err = from_str_with_config::<Value>(case.yaml, &cfg)
.err()
.unwrap_or_else(|| panic!("{}: the limit did not refuse", case.label));
assert_eq!(
err.kind(),
case.kind,
"{}: wrong ErrorKind for `{}`",
case.label,
err
);
assert!(
err.to_string().contains(case.phrase),
"{}: message does not mention {:?}: {err}",
case.label,
case.phrase
);
}
}
#[test]
fn every_limit_case_is_accepted_at_the_default_settings() {
for case in CASES {
let _accepted: Value = from_str(case.yaml).unwrap_or_else(|e| {
panic!(
"{}: the input is refused even with the limit at its default, so the \
test above does not measure the limit: {e}",
case.label
)
});
}
}
#[test]
fn max_documents_refuses_in_the_multi_document_readers() {
let yaml = "a: 1\n---\nb: 2\n";
let mut cfg = ParserConfig::new();
cfg.max_documents = 1;
let err = noyalib::load_all_with_config(yaml, &cfg)
.and_then(|it| it.collect::<Result<Vec<Value>, _>>())
.expect_err("max_documents must refuse the second document");
assert_eq!(err.kind(), ErrorKind::Budget, "wrong kind for `{err}`");
assert!(
err.to_string().contains("max_documents"),
"message does not name the limit: {err}"
);
let docs: Vec<Value> = noyalib::load_all_with_config(yaml, &ParserConfig::new())
.expect("default limits")
.collect::<Result<_, _>>()
.expect("default limits accept two documents");
assert_eq!(
docs.len(),
2,
"the control case did not read both documents"
);
}
#[test]
fn every_refusal_renders_against_its_source() {
for case in CASES {
let mut cfg = ParserConfig::new();
(case.configure)(&mut cfg);
let err = from_str_with_config::<Value>(case.yaml, &cfg).expect_err(case.label);
for rendered in [
err.format_with_source(case.yaml),
err.format_with_source_radius(case.yaml, 0),
err.format_with_source_radius(case.yaml, 100),
err.format_with_source_truncated(case.yaml, 20),
err.format_with_source_radius_truncated(case.yaml, 1, 40),
err.render(case.yaml),
] {
assert!(!rendered.is_empty(), "{}: rendered to nothing", case.label);
}
}
}
#[test]
fn the_shared_error_round_trip_is_reversible() {
let err = from_str::<Value>("a: [unclosed").expect_err("parse error");
let text = err.to_string();
let kind = err.kind();
assert!(!err.is_shared(), "a fresh error is not shared");
assert!(err.as_inner().is_none(), "a fresh error has no inner");
let shared = err.into_shared();
let wrapped = Error::from_shared(shared);
assert!(
wrapped.is_shared(),
"from_shared did not produce a shared error"
);
assert_eq!(
wrapped.as_inner().map(ToString::to_string),
Some(text.clone()),
"the inner error did not survive sharing"
);
assert_eq!(wrapped.kind(), kind, "sharing changed the error kind");
assert_eq!(wrapped.to_string(), text, "sharing changed the message");
}
#[test]
fn the_scanner_refuses_these_structures_by_name() {
let cases: &[(&str, &str, &str)] = &[
(
"a block sequence on the `---` line",
"--- - a\n",
"not allowed in this context",
),
(
"an explicit key on the `---` line",
"--- ? a\n",
"not allowed in this context",
),
(
"a second `:` on one line",
"a: b: c\n",
"mapping values are not allowed",
),
(
"a tag suffix containing `!`",
"!e!foo!bar x\n",
"tag suffix must not contain",
),
(
"a flow terminator with nothing open",
"[a, b]]\n",
"outside of any flow sequence",
),
];
for (label, yaml, phrase) in cases {
let err = from_str::<Value>(yaml).expect_err(label);
assert!(
err.to_string().contains(phrase),
"{label}: message does not say why: {err}"
);
assert_eq!(err.kind(), ErrorKind::Syntax, "{label}: wrong kind");
}
}
#[test]
fn verbatim_tags_are_accepted_in_both_collection_positions() {
let v: Value = from_str("!<tag:example.com,2026:x> v\n").expect("verbatim tag at the root");
assert!(
matches!(v, Value::Tagged(_)) || v.as_str() == Some("v"),
"the verbatim tag produced neither a tagged value nor its content: {v:?}"
);
let v: Value = from_str("- !<tag:e,1:t> v\n- plain\n").expect("verbatim tag in a sequence");
assert_eq!(
v.as_sequence().map(noyalib::Sequence::len),
Some(2),
"verbatim tag disturbed the sequence: {v:?}"
);
}
#[test]
fn every_limit_also_refuses_through_the_span_aware_loader() {
for case in CASES {
let mut cfg = ParserConfig::new();
(case.configure)(&mut cfg);
let result = noyalib::load_all_with_config(case.yaml, &cfg)
.and_then(|it| it.collect::<Result<Vec<Value>, _>>());
let err = result
.err()
.unwrap_or_else(|| panic!("{}: the span-aware loader did not refuse", case.label));
assert_eq!(
err.kind(),
case.kind,
"{}: the two loaders disagree on ErrorKind for `{}`",
case.label,
err
);
assert!(
err.to_string().contains(case.phrase),
"{}: the span-aware loader's message does not mention {:?}: {err}",
case.label,
case.phrase
);
}
}
#[test]
fn the_span_aware_loader_accepts_every_case_at_the_default_settings() {
for case in CASES {
let _accepted: Vec<Value> = noyalib::load_all_with_config(case.yaml, &ParserConfig::new())
.and_then(|it| it.collect::<Result<Vec<Value>, _>>())
.unwrap_or_else(|e| {
panic!(
"{}: the span-aware loader refuses the input even at default \
limits, so the test above measures nothing: {e}",
case.label
)
});
}
}
#[test]
fn a_spanned_target_reports_the_same_refusals() {
use std::collections::BTreeMap;
let mut cfg = ParserConfig::new();
cfg.max_mapping_keys = 2;
let err = from_str_with_config::<BTreeMap<String, Spanned<i64>>>("a: 1\nb: 2\nc: 3\n", &cfg)
.expect_err("max_mapping_keys must refuse a spanned target too");
assert_eq!(err.kind(), ErrorKind::Budget, "wrong kind for `{err}`");
assert!(
err.to_string().contains("max_mapping_keys"),
"message does not name the limit: {err}"
);
}