use serde_yaml::Value;
use super::errors::DeclarativeError;
use super::schema_v2::compile_schema_v2;
use super::text::{compile_section_text_rules, has_text_keys};
use super::yaml::{self, boolean, num, scalar_string, string, strings};
use crate::camel::to_camel_key;
use crate::contract::{
CodeConfig, EveryItem, ExtraColumns, GapSpec, LeafSpec, LevelOpts, ListConfig, OneOfSpec,
Order, SectionContent, SectionOpts, SectionSeq, SectionSpec, Spec, TableConfig,
};
const LEAF_KEYS: &[&str] = &["table", "list", "code", "maxWords"];
const ROOT_LEVEL_KEYS: &[&str] = &[
"order",
"additionalSections",
"sections",
"description",
"requires",
"forbids",
];
const SECTION_KEYS: &[&str] = &[
"section",
"aliases",
"anchor",
"content",
"requires",
"forbids",
"description",
"minContains",
"maxContains",
"sections",
"order",
"additionalSections",
];
const ONE_OF_KEYS: &[&str] = &[
"oneOf",
"anchor",
"content",
"requires",
"forbids",
"description",
"minContains",
"maxContains",
"sections",
"order",
"additionalSections",
];
const GAP_KEYS: &[&str] = &["gap"];
const V1_SPELLINGS: &[(&str, &str)] = &[
("optional", "'minContains: 0'"),
("repeatable", "'minContains' / 'maxContains'"),
("min", "'minContains'"),
("max", "'maxContains'"),
("children", "a hoisted 'sections' list on the node"),
("allowUnknown", "'additionalSections'"),
("everyItem", "'items'"),
];
fn err(msg: String) -> DeclarativeError {
DeclarativeError::InvalidBody(msg)
}
fn check_keys(
map: &serde_yaml::Mapping,
allowed: &[&str],
path: &str,
) -> Result<(), DeclarativeError> {
yaml::check_keys(map, allowed, path, V1_SPELLINGS, &[], err)
}
fn description_of(
map: &serde_yaml::Mapping,
path: &str,
) -> Result<Option<String>, DeclarativeError> {
yaml::description_of(map, path, err)
}
pub(super) fn compile_body_v2(node: &Value, path: &str) -> Result<SectionSeq, DeclarativeError> {
let Value::Mapping(map) = node else {
return Err(err(format!(
"{path}: must be a mapping with a 'sections' list"
)));
};
check_keys(map, ROOT_LEVEL_KEYS, path)?;
let opts = level_opts(map, path)?;
let specs = compile_sections(map, path)?;
Ok(SectionSeq {
opts,
specs,
description: description_of(map, path)?,
})
}
fn level_opts(map: &serde_yaml::Mapping, path: &str) -> Result<LevelOpts, DeclarativeError> {
let mut opts = LevelOpts::default();
if let Some(order) = map.get("order") {
opts.order = match order.as_str() {
Some("none") => Order::None,
Some("recognized-relative") => Order::RecognizedRelative,
Some("strict") => Order::Strict,
_ => {
return Err(err(format!(
"{path}.order must be none | recognized-relative | strict (got {})",
yaml::render(Some(order))
)));
}
};
}
if let Some(v) = boolean(map, "additionalSections", path, err)? {
opts.allow_unknown = v;
}
Ok(opts)
}
fn assert_no_key_collision(specs: &[Spec], path: &str) -> Result<(), DeclarativeError> {
let mut key_to_name: Vec<(String, String)> = Vec::new();
for spec in specs {
let inner = unwrap_inner(spec);
let primary = match inner {
Spec::Section(s) => s.names.first(),
Spec::OneOf(o) => o.names.first(),
_ => None,
};
let Some(primary) = primary else { continue };
let key = to_camel_key(primary);
if key.is_empty() {
continue;
}
if let Some((_, prior)) = key_to_name.iter().find(|(k, _)| *k == key) {
if prior != primary {
return Err(err(format!(
"{path}: contract/key-collision — section names \u{2018}{prior}\u{2019} and \u{2018}{primary}\u{2019} both generate the camelCase key \u{2018}{key}\u{2019}; generated OOM keys must be unique"
)));
}
} else {
key_to_name.push((key, primary.clone()));
}
}
Ok(())
}
fn unwrap_inner(spec: &Spec) -> &Spec {
match spec {
Spec::Optional(inner) => unwrap_inner(inner),
other => other,
}
}
fn compile_sections(map: &serde_yaml::Mapping, path: &str) -> Result<Vec<Spec>, DeclarativeError> {
let Some(Value::Sequence(nodes)) = map.get("sections") else {
return Err(err(format!("{path}.sections must be a list of nodes")));
};
let specs: Vec<Spec> = nodes
.iter()
.enumerate()
.map(|(i, n)| compile_node(n, &format!("{path}.sections[{i}]")))
.collect::<Result<_, _>>()?;
assert_no_key_collision(&specs, path)?;
Ok(specs)
}
fn compile_node(node: &Value, path: &str) -> Result<Spec, DeclarativeError> {
let Value::Mapping(map) = node else {
return Err(err(format!(
"{path}: a body node must be a mapping (section / oneOf / gap)"
)));
};
let selectors: Vec<&str> = ["section", "oneOf", "gap"]
.into_iter()
.filter(|k| map.get(k).is_some())
.collect();
match selectors.as_slice() {
["gap"] => {
check_keys(map, GAP_KEYS, path)?;
compile_gap(map, path)
}
["oneOf"] => {
check_keys(map, ONE_OF_KEYS, path)?;
compile_one_of(map, path)
}
["section"] => {
check_keys(map, SECTION_KEYS, path)?;
compile_section_node(map, path)
}
[] => Err(err(format!(
"{path}: a body node needs exactly one of section / oneOf / gap"
))),
many => Err(err(format!(
"{path}: a body node needs exactly one of section / oneOf / gap (got {})",
many.join(" + ")
))),
}
}
fn compile_gap(map: &serde_yaml::Mapping, path: &str) -> Result<Spec, DeclarativeError> {
let g = match map.get("gap") {
None | Some(Value::Null) => return Ok(Spec::Gap(GapSpec::default())),
Some(Value::Mapping(g)) => g,
Some(_) => {
return Err(err(format!(
"{path}.gap must be a mapping ({{ min?, max? }}) or empty"
)));
}
};
let gap_path = format!("{path}.gap");
for key in g.keys() {
if !matches!(key.as_str(), Some("min" | "max")) {
return Err(err(format!(
"{gap_path}: unknown key '{}'",
yaml::scalar_string(key)
)));
}
}
let bound = |key: &str| -> Result<Option<usize>, DeclarativeError> {
let Some(n) = num(g, key, &gap_path, err)? else {
return Ok(None);
};
if n.fract() != 0.0 || n < 0.0 {
return Err(err(format!(
"{gap_path}.{key} must be a non-negative integer (got {})",
crate::js::num(n)
)));
}
Ok(Some(n as usize))
};
Ok(Spec::Gap(GapSpec {
min: bound("min")?,
max: bound("max")?,
}))
}
fn compile_one_of(map: &serde_yaml::Mapping, path: &str) -> Result<Spec, DeclarativeError> {
let names = match strings(map, "oneOf") {
Some(n) if !n.is_empty() => n,
_ => {
return Err(err(format!(
"{path}.oneOf must be a non-empty list of section names"
)));
}
};
let opts = section_opts(map, path, &names[0])?;
finish_slot(
map,
path,
|opts| Spec::OneOf(OneOfSpec { names, opts }),
opts,
)
}
fn compile_section_node(map: &serde_yaml::Mapping, path: &str) -> Result<Spec, DeclarativeError> {
let Some(name) = map.get("section").and_then(Value::as_str) else {
return Err(err(format!(
"{path}.section must be a heading name (string)"
)));
};
let mut names = vec![name.to_string()];
if map.get("aliases").is_some() {
match strings(map, "aliases") {
Some(mut l) => names.append(&mut l),
None => {
return Err(err(format!(
"{path}.aliases must be a list of alias spellings"
)));
}
}
}
let opts = section_opts(map, path, name)?;
finish_slot(
map,
path,
|opts| Spec::Section(SectionSpec { names, opts }),
opts,
)
}
fn finish_slot(
map: &serde_yaml::Mapping,
path: &str,
build: impl FnOnce(Option<SectionOpts>) -> Spec,
mut opts: Option<SectionOpts>,
) -> Result<Spec, DeclarativeError> {
let mut optional = false;
if let Some((lo, hi)) = occurrence(map, path)? {
optional = lo == 0;
let plain = matches!((lo, hi), (0, Some(1)) | (1, Some(1)));
if !plain {
let o = opts.get_or_insert_with(SectionOpts::default);
o.repeatable = true;
if lo >= 2 {
o.min = Some(lo);
}
o.max = hi;
}
}
let spec = build(opts);
Ok(if optional {
Spec::Optional(Box::new(spec))
} else {
spec
})
}
fn occurrence(
map: &serde_yaml::Mapping,
path: &str,
) -> Result<Option<(usize, Option<usize>)>, DeclarativeError> {
let min_v = map.get("minContains");
let max_v = map.get("maxContains");
if min_v.is_none() && max_v.is_none() {
return Ok(None);
}
let lo = match min_v {
Some(v) => occurrence_bound(v, "minContains", path)?,
None => 1,
};
let hi = match max_v {
Some(v) => Some(occurrence_bound(v, "maxContains", path)?),
None => None,
};
if let Some(hi) = hi {
if hi < 1 {
return Err(err(format!(
"{path}: contract/repeat-bounds — maxContains must be at least 1"
)));
}
if hi < lo {
return Err(err(format!(
"{path}: contract/repeat-bounds — minContains {lo} is greater than maxContains {hi}"
)));
}
}
Ok(Some((lo, hi)))
}
fn occurrence_bound(v: &Value, key: &str, path: &str) -> Result<usize, DeclarativeError> {
let n = v
.as_f64()
.ok_or_else(|| err(format!("{path}.{key} must be a number")))?;
if n.fract() != 0.0 || n < 0.0 {
return Err(err(format!(
"{path}: contract/repeat-bounds — {key} must be a non-negative integer (got {n})"
)));
}
Ok(n as usize)
}
fn section_opts(
map: &serde_yaml::Mapping,
path: &str,
label: &str,
) -> Result<Option<SectionOpts>, DeclarativeError> {
let mut opts = SectionOpts::default();
let mut any = false;
if let Some(anchor) = map.get("anchor") {
opts.anchor = Some(scalar_string(anchor));
any = true;
}
if let Some(content) = map.get("content") {
opts.content = Some(compile_content(content, &format!("{path}.content"))?);
any = true;
}
if map.get("sections").is_some() {
opts.children = Some(nested_level(map, path)?);
any = true;
} else if let Some(key) = ["order", "additionalSections"]
.into_iter()
.find(|k| map.get(k).is_some())
{
return Err(err(format!(
"{path}: '{key}' requires a nested 'sections' list on the node"
)));
}
if let Some(description) = description_of(map, path)? {
opts.description = Some(description);
any = true;
}
if has_text_keys(map) {
let rules = compile_section_text_rules(map, path, label)?;
if !rules.is_empty() {
opts.rules = rules;
any = true;
}
}
Ok(any.then_some(opts))
}
fn nested_level(map: &serde_yaml::Mapping, path: &str) -> Result<SectionSeq, DeclarativeError> {
Ok(SectionSeq {
opts: level_opts(map, path)?,
specs: compile_sections(map, path)?,
description: None,
})
}
fn is_leaf_map(map: &serde_yaml::Mapping) -> bool {
map.len() == 1
&& map
.keys()
.next()
.and_then(Value::as_str)
.is_some_and(|k| LEAF_KEYS.contains(&k))
}
fn compile_content(content: &Value, path: &str) -> Result<SectionContent, DeclarativeError> {
let Value::Mapping(map) = content else {
return Err(err(format!(
"{path}: must be a leaf (table/list/code/maxWords) or a named-leaf map"
)));
};
if is_leaf_map(map) {
return Ok(SectionContent::Single(compile_leaf(map, path)?));
}
let mut entries = Vec::with_capacity(map.len());
for (name, leaf) in map {
let Some(name) = name.as_str() else {
return Err(err(format!("{path}: named-leaf keys must be strings")));
};
let Value::Mapping(leaf_map) = leaf else {
return Err(err(format!(
"{path}.{name}: a leaf must be a single-key mapping (table | list | code | maxWords)"
)));
};
entries.push((
name.to_string(),
compile_leaf(leaf_map, &format!("{path}.{name}"))?,
));
}
Ok(SectionContent::Anchored(entries))
}
fn compile_leaf(map: &serde_yaml::Mapping, path: &str) -> Result<LeafSpec, DeclarativeError> {
if map.len() != 1 {
return Err(err(format!(
"{path}: a leaf must be a single-key mapping (table | list | code | maxWords)"
)));
}
let (key, cfg) = map.iter().next().expect("len == 1");
match key.as_str() {
Some("maxWords") => {
let n = num(map, "maxWords", path, err)?.expect("the leaf's only key");
Ok(LeafSpec::max_words(n))
}
Some("code") => code_leaf(cfg, path),
Some("table") => table_leaf(cfg, path),
Some("list") => list_leaf(cfg, path),
other => Err(err(format!(
"{path}: unknown leaf '{}'",
other.unwrap_or("?")
))),
}
}
fn code_leaf(cfg: &Value, path: &str) -> Result<LeafSpec, DeclarativeError> {
let map = match cfg {
Value::Null => return Ok(LeafSpec::code_with(CodeConfig::default())),
Value::Mapping(map) => map,
_ => return Err(err(format!("{path}.code must be a mapping"))),
};
let code_path = format!("{path}.code");
check_keys(map, &["lang", "description"], &code_path)?;
let mut leaf = LeafSpec::code_with(CodeConfig {
lang: string(map, "lang", &code_path, err)?.map(str::to_string),
});
leaf.description = description_of(map, &code_path)?;
Ok(leaf)
}
fn table_leaf(cfg: &Value, path: &str) -> Result<LeafSpec, DeclarativeError> {
let Value::Mapping(map) = cfg else {
return Err(err(format!("{path}.table must be a mapping")));
};
check_keys(
map,
&[
"columns",
"anchor",
"minRows",
"extraColumns",
"cells",
"description",
],
&format!("{path}.table"),
)?;
let Some(columns) = strings(map, "columns") else {
return Err(err(format!(
"{path}.table.columns must be a list of column names"
)));
};
let mut out = TableConfig {
columns,
..Default::default()
};
if let Some(anchor) = map.get("anchor") {
out.anchor = Some(scalar_string(anchor));
}
let table_path = format!("{path}.table");
out.min_rows = num(map, "minRows", &table_path, err)?;
out.extra_columns = match map.get("extraColumns").map(Value::as_str) {
None | Some(Some("ignore")) => ExtraColumns::Ignore,
Some(Some("error")) => ExtraColumns::Error,
Some(_) => {
return Err(err(format!(
"{table_path}.extraColumns must be \"ignore\" or \"error\""
)));
}
};
if let Some(cells) = map.get("cells") {
let Value::Mapping(cells) = cells else {
return Err(err(format!(
"{path}.table.cells must be a mapping of column → schema"
)));
};
for (col, schema) in cells {
let Some(col) = col.as_str() else {
return Err(err(format!("{path}.table.cells keys must be column names")));
};
out.cells.push((
col.to_string(),
compile_schema_v2(schema, &format!("{path}.table.cells.{col}"))?,
));
}
}
let mut leaf = LeafSpec::table_with(out);
leaf.description = description_of(map, &table_path)?;
Ok(leaf)
}
fn list_leaf(cfg: &Value, path: &str) -> Result<LeafSpec, DeclarativeError> {
let Value::Mapping(map) = cfg else {
return Err(err(format!("{path}.list must be a mapping")));
};
check_keys(
map,
&["items", "minItems", "ordered", "description"],
&format!("{path}.list"),
)?;
let list_path = format!("{path}.list");
let mut out = ListConfig {
ordered: boolean(map, "ordered", &list_path, err)?,
min_items: num(map, "minItems", &list_path, err)?,
every_item: None,
};
if let Some(items) = map.get("items") {
out.every_item = Some(if items.as_str() == Some("checkbox") {
EveryItem::Checkbox
} else {
EveryItem::Schema(compile_schema_v2(items, &format!("{path}.list.items"))?)
});
}
let mut leaf = LeafSpec::list_with(out);
leaf.description = description_of(map, &format!("{path}.list"))?;
Ok(leaf)
}
#[cfg(test)]
mod tests {
use super::*;
fn body(yaml: &str) -> Result<SectionSeq, DeclarativeError> {
let v: Value = serde_yaml::from_str(yaml).unwrap();
compile_body_v2(&v, "body")
}
fn msg(r: Result<SectionSeq, DeclarativeError>) -> String {
match r {
Err(e) => e.to_string(),
Ok(_) => panic!("expected a DeclarativeError"),
}
}
fn slot_shape(spec: &Spec) -> (bool, bool, Option<usize>, Option<usize>) {
let (inner, optional) = match spec {
Spec::Optional(inner) => (&**inner, true),
other => (other, false),
};
let opts = match inner {
Spec::Section(s) => s.opts.as_ref(),
Spec::OneOf(o) => o.opts.as_ref(),
_ => panic!("expected a section/oneOf slot"),
};
(
optional,
opts.is_some_and(|o| o.repeatable),
opts.and_then(|o| o.min),
opts.and_then(|o| o.max),
)
}
#[test]
fn sections_one_of_gap_and_additional_sections_compile() {
let seq = body(
"order: strict\nadditionalSections: false\nsections:\n - section: Title\n - oneOf: [Goal, 'Goal / Problem statement']\n - gap:\n min: 1\n max: 2\n - section: Appendix\n minContains: 0\n maxContains: 1\n",
)
.unwrap();
assert_eq!(seq.opts.order, Order::Strict);
assert!(!seq.opts.allow_unknown);
assert_eq!(seq.specs.len(), 4);
assert!(matches!(&seq.specs[0], Spec::Section(s) if s.names == ["Title"]));
assert!(matches!(&seq.specs[1], Spec::OneOf(o) if o.names.len() == 2));
assert!(matches!(&seq.specs[2], Spec::Gap(g) if g.min == Some(1) && g.max == Some(2)));
assert!(matches!(&seq.specs[3], Spec::Optional(_)));
}
#[test]
fn occurrence_recipe_compiles_each_window() {
let compiled = |occ: &str| {
let seq = body(&format!("sections:\n - section: Entry\n{occ}")).unwrap();
slot_shape(&seq.specs[0])
};
assert_eq!(compiled(""), (false, false, None, None));
assert_eq!(
compiled(" minContains: 0\n maxContains: 1\n"),
(true, false, None, None)
);
assert_eq!(
compiled(" minContains: 1\n maxContains: 1\n"),
(false, false, None, None)
);
assert_eq!(compiled(" minContains: 0\n"), (true, true, None, None));
assert_eq!(
compiled(" minContains: 0\n maxContains: 3\n"),
(true, true, None, Some(3))
);
assert_eq!(compiled(" minContains: 1\n"), (false, true, None, None));
assert_eq!(
compiled(" maxContains: 5\n"),
(false, true, None, Some(5))
);
assert_eq!(
compiled(" minContains: 2\n"),
(false, true, Some(2), None)
);
assert_eq!(
compiled(" minContains: 2\n maxContains: 4\n"),
(false, true, Some(2), Some(4))
);
}
#[test]
fn occurrence_bounds_are_validated() {
assert!(
msg(body("sections:\n - section: E\n minContains: -1\n"))
.contains("non-negative integer")
);
assert!(
msg(body("sections:\n - section: E\n minContains: 1.5\n"))
.contains("non-negative integer")
);
assert!(
msg(body("sections:\n - section: E\n maxContains: 0\n"))
.contains("maxContains must be at least 1")
);
assert!(
msg(body(
"sections:\n - section: E\n minContains: 3\n maxContains: 1\n"
))
.contains("minContains 3 is greater than maxContains 1")
);
}
#[test]
fn hoisted_sections_compile_to_children() {
let seq = body(
"sections:\n - section: Decision\n order: strict\n additionalSections: false\n sections:\n - section: Components\n - section: Resolution\n",
)
.unwrap();
let Spec::Section(s) = &seq.specs[0] else {
panic!()
};
let children = s.opts.as_ref().unwrap().children.as_ref().unwrap();
assert_eq!(children.opts.order, Order::Strict);
assert!(!children.opts.allow_unknown);
assert_eq!(children.specs.len(), 2);
}
#[test]
fn content_leaves_compile_with_v2_cells_and_items() {
let seq = body(
"sections:\n - section: Files\n anchor: files\n content:\n table:\n columns: [Location, Kind]\n minRows: 1\n extraColumns: error\n cells:\n Kind:\n enum: [add, modify]\n - section: Criteria\n content:\n list:\n items: checkbox\n minItems: 2\n - section: Steps\n content:\n list:\n items:\n type: string\n minLength: 5\n",
)
.unwrap();
let Spec::Section(s) = &seq.specs[0] else {
panic!()
};
let opts = s.opts.as_ref().unwrap();
assert_eq!(opts.anchor.as_deref(), Some("files"));
let Some(SectionContent::Single(leaf)) = &opts.content else {
panic!()
};
let Some(crate::contract::LeafConfig::Table(t)) = &leaf.config else {
panic!()
};
assert_eq!(t.columns, ["Location", "Kind"]);
assert_eq!(t.min_rows, Some(1.0));
assert_eq!(t.extra_columns, ExtraColumns::Error);
assert_eq!(t.cells.len(), 1);
let Spec::Section(s) = &seq.specs[1] else {
panic!()
};
let Some(SectionContent::Single(leaf)) = &s.opts.as_ref().unwrap().content else {
panic!()
};
let Some(crate::contract::LeafConfig::List(l)) = &leaf.config else {
panic!()
};
assert!(matches!(l.every_item, Some(EveryItem::Checkbox)));
assert_eq!(l.min_items, Some(2.0));
let Spec::Section(s) = &seq.specs[2] else {
panic!()
};
let Some(SectionContent::Single(leaf)) = &s.opts.as_ref().unwrap().content else {
panic!()
};
let Some(crate::contract::LeafConfig::List(l)) = &leaf.config else {
panic!()
};
assert!(matches!(l.every_item, Some(EveryItem::Schema(_))));
}
#[test]
fn descriptions_are_stored_on_levels_nodes_and_leaves() {
let seq = body(
"description: the body\nsections:\n - section: Summary\n description: one paragraph\n content:\n list:\n minItems: 1\n description: the checklist\n - gap: {}\n",
)
.unwrap();
assert_eq!(seq.description.as_deref(), Some("the body"));
let Spec::Section(s) = &seq.specs[0] else {
panic!()
};
let opts = s.opts.as_ref().unwrap();
assert_eq!(opts.description.as_deref(), Some("one paragraph"));
let Some(SectionContent::Single(leaf)) = &opts.content else {
panic!()
};
assert_eq!(leaf.description.as_deref(), Some("the checklist"));
assert!(matches!(seq.specs[1], Spec::Gap(_)));
}
#[test]
fn gap_rejects_description_like_the_ts_compiler() {
let err = msg(body("sections:\n - gap: {}\n description: extras\n"));
assert!(err.contains("description"), "got: {err}");
}
#[test]
fn section_text_keys_become_node_rules() {
let seq =
body("sections:\n - section: Summary\n requires:\n - pattern: outcome\n")
.unwrap();
let Spec::Section(s) = &seq.specs[0] else {
panic!()
};
assert_eq!(s.opts.as_ref().unwrap().rules.len(), 1);
}
#[test]
fn v1_keys_get_migration_hints() {
assert_eq!(
msg(body("sections:\n - section: A\n optional: true\n")),
"body.sections[0]: 'optional' is the v1 spelling — v2 uses 'minContains: 0' (markdown-contract migrate rewrites v1 files)"
);
assert_eq!(
msg(body("sections:\n - section: A\n repeatable: true\n")),
"body.sections[0]: 'repeatable' is the v1 spelling — v2 uses 'minContains' / 'maxContains' (markdown-contract migrate rewrites v1 files)"
);
assert_eq!(
msg(body(
"sections:\n - section: A\n repeatable: true\n min: 1\n"
))
.split(" — ")
.count(),
2
);
assert_eq!(
msg(body(
"sections:\n - section: A\n children:\n sections: []\n"
)),
"body.sections[0]: 'children' is the v1 spelling — v2 uses a hoisted 'sections' list on the node (markdown-contract migrate rewrites v1 files)"
);
assert_eq!(
msg(body("allowUnknown: true\nsections: []\n")),
"body: 'allowUnknown' is the v1 spelling — v2 uses 'additionalSections' (markdown-contract migrate rewrites v1 files)"
);
assert_eq!(
msg(body(
"sections:\n - section: A\n content:\n list:\n everyItem: checkbox\n"
)),
"body.sections[0].content.list: 'everyItem' is the v1 spelling — v2 uses 'items' (markdown-contract migrate rewrites v1 files)"
);
}
#[test]
fn unknown_keys_and_grammar_guards_reject() {
assert_eq!(
msg(body("sections:\n - section: A\n wibble: 1\n")),
"body.sections[0]: unknown key 'wibble'"
);
assert_eq!(
msg(body("sections:\n - section: A\n order: strict\n")),
"body.sections[0]: 'order' requires a nested 'sections' list on the node"
);
assert!(
body("sections:\n - section: Files to touch\n - section: Files To Touch\n").is_err()
);
assert!(body("order: alphabetical\nsections: []\n").is_err());
assert!(body("order: none\n").is_err());
assert!(body("sections:\n - wibble: 1\n").is_err());
assert_eq!(
msg(body("sections:\n - section: A\n gap: {}\n")),
"body.sections[0]: a body node needs exactly one of section / oneOf / gap (got section + gap)"
);
}
#[test]
fn leaf_and_gap_values_are_type_checked() {
let leaf = |content: &str| {
msg(body(&format!(
"sections:\n - section: A\n content:\n {content}\n"
)))
};
assert_eq!(
leaf("table: {columns: [A], minRows: two}"),
"body.sections[0].content.table.minRows must be a number"
);
assert_eq!(
leaf("table: {columns: [A], extraColumns: warn}"),
"body.sections[0].content.table.extraColumns must be \"ignore\" or \"error\""
);
assert_eq!(
leaf("list: {ordered: yes}"),
"body.sections[0].content.list.ordered must be a boolean"
);
assert_eq!(
leaf("list: {minItems: '3'}"),
"body.sections[0].content.list.minItems must be a number"
);
assert_eq!(
leaf("code: x"),
"body.sections[0].content.code must be a mapping"
);
assert_eq!(
leaf("code: {lang: 3}"),
"body.sections[0].content.code.lang must be a string"
);
assert!(body("sections:\n - section: A\n content:\n code:\n").is_ok());
let gap = |g: &str| msg(body(&format!("sections:\n - gap: {g}\n")));
assert_eq!(gap("{min: x}"), "body.sections[0].gap.min must be a number");
assert_eq!(
gap("{least: 1}"),
"body.sections[0].gap: unknown key 'least'"
);
assert_eq!(
gap("3"),
"body.sections[0].gap must be a mapping ({ min?, max? }) or empty"
);
assert_eq!(
gap("{max: 1.5}"),
"body.sections[0].gap.max must be a non-negative integer (got 1.5)"
);
}
#[test]
fn one_of_text_rules_are_labelled_by_the_first_name() {
let r = body(
"sections:\n - oneOf: [Plan, Approach]\n requires:\n - pattern: x\n forbids:\n - pattern: x\n",
);
assert!(msg(r).contains("contradiction in Plan"));
}
}