From 3ee35d99095bafc1fb89e1a1bbcdf99b43d52742 Mon Sep 17 00:00:00 2001 From: Chris Guidry Date: Thu, 30 Jul 2026 08:08:12 -0400 Subject: [PATCH] Add the schema layer for storied srd verify Every corpus file under rules/srd-5.2/ must parse as markdown with YAML frontmatter, carry the universal name/type/source fields, satisfy its kind's extra fields (spell, monster, magic-item; core/class/feat add nothing), and have a filename that matches the deterministic slug of its name with no collisions elsewhere in the layer. This is the schema layer of storied srd verify, the deterministic gate described in plans/0002-srd.md. It does not yet check fidelity against the vendored source, coverage of the vendored tree, or corpus shape (entry counts, README, meta.yaml); those layers land in follow-up commits and this one is not wired into pre-commit or the CLI yet. Adds serde, serde_yaml_ng (a maintained continuation of the now-archived serde_yaml), and regex as dependencies. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017jBcx24HfGr66ZAJnYf1fR --- Cargo.lock | 92 +++++++ Cargo.toml | 3 + src/srd/mod.rs | 1 + src/srd/verify/fixtures.rs | 27 ++ src/srd/verify/kind.rs | 66 +++++ src/srd/verify/layer.rs | 191 ++++++++++++++ src/srd/verify/magic_item.rs | 229 +++++++++++++++++ src/srd/verify/mod.rs | 255 +++++++++++++++++++ src/srd/verify/monster.rs | 435 ++++++++++++++++++++++++++++++++ src/srd/verify/schema.rs | 469 +++++++++++++++++++++++++++++++++++ src/srd/verify/slug.rs | 67 +++++ src/srd/verify/spell.rs | 283 +++++++++++++++++++++ src/srd/verify/values.rs | 229 +++++++++++++++++ 13 files changed, 2347 insertions(+) create mode 100644 src/srd/verify/fixtures.rs create mode 100644 src/srd/verify/kind.rs create mode 100644 src/srd/verify/layer.rs create mode 100644 src/srd/verify/magic_item.rs create mode 100644 src/srd/verify/mod.rs create mode 100644 src/srd/verify/monster.rs create mode 100644 src/srd/verify/schema.rs create mode 100644 src/srd/verify/slug.rs create mode 100644 src/srd/verify/spell.rs create mode 100644 src/srd/verify/values.rs diff --git a/Cargo.lock b/Cargo.lock index d7d1d28..9142f1e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "anstream" version = "1.0.0" @@ -217,6 +226,12 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "filetime" version = "0.2.29" @@ -254,6 +269,12 @@ dependencies = [ "wasi", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "heck" version = "0.5.0" @@ -285,6 +306,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -388,11 +419,34 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "ring" @@ -443,6 +497,22 @@ dependencies = [ "untrusted", ] +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + [[package]] name = "serde_core" version = "1.0.229" @@ -463,6 +533,19 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha2" version = "0.11.0" @@ -493,6 +576,9 @@ dependencies = [ "assert_cmd", "clap", "flate2", + "regex", + "serde", + "serde_yaml_ng", "sha2", "tar", "ureq", @@ -549,6 +635,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 0dd2ff9..b0f6cfb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,9 @@ publish = false [dependencies] clap = { version = "4.6.4", features = ["derive"] } flate2 = "1.1.9" +regex = "1.13.1" +serde = { version = "1.0.229", features = ["derive"] } +serde_yaml_ng = "0.10.0" sha2 = "0.11.0" tar = { version = "0.4.46", default-features = false } ureq = "3.3.0" diff --git a/src/srd/mod.rs b/src/srd/mod.rs index 0e40308..f366cae 100644 --- a/src/srd/mod.rs +++ b/src/srd/mod.rs @@ -1 +1,2 @@ pub mod fetch; +pub mod verify; diff --git a/src/srd/verify/fixtures.rs b/src/srd/verify/fixtures.rs new file mode 100644 index 0000000..d5d47c2 --- /dev/null +++ b/src/srd/verify/fixtures.rs @@ -0,0 +1,27 @@ +//! Shared test fixtures for the verify module: a fresh temp directory and +//! a small file-writing helper for synthetic corpus layers. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +static TEMP_DIR_COUNTER: AtomicU64 = AtomicU64::new(0); + +/// A fresh, empty directory that no other test is using. +pub fn unique_temp_dir() -> PathBuf { + let id = TEMP_DIR_COUNTER.fetch_add(1, Ordering::Relaxed); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!("storied-verify-test-{nanos}-{id}")); + fs::create_dir_all(&dir).unwrap(); + dir +} + +/// Writes `contents` to `path`, creating its parent directories first. +pub fn write_file(path: &Path, contents: &str) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, contents).unwrap(); +} diff --git a/src/srd/verify/kind.rs b/src/srd/verify/kind.rs new file mode 100644 index 0000000..409ef1a --- /dev/null +++ b/src/srd/verify/kind.rs @@ -0,0 +1,66 @@ +/// The six entry kinds a corpus layer holds, one directory each under the +/// layer root. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Kind { + Core, + Class, + Spell, + Monster, + MagicItem, + Feat, +} + +impl Kind { + /// Every kind, in the layer's directory order. + pub const ALL: [Kind; 6] = [ + Kind::Core, + Kind::Class, + Kind::Spell, + Kind::Monster, + Kind::MagicItem, + Kind::Feat, + ]; + + /// The layer directory this kind's entries live in. + pub fn dir_name(self) -> &'static str { + match self { + Kind::Core => "core", + Kind::Class => "classes", + Kind::Spell => "spells", + Kind::Monster => "monsters", + Kind::MagicItem => "magic-items", + Kind::Feat => "feats", + } + } + + /// The value the frontmatter `type` field must hold for this kind. + pub fn type_name(self) -> &'static str { + match self { + Kind::Core => "core", + Kind::Class => "class", + Kind::Spell => "spell", + Kind::Monster => "monster", + Kind::MagicItem => "magic-item", + Kind::Feat => "feat", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dir_name_and_type_name_are_distinct_per_kind() { + for kind in Kind::ALL { + assert!(!kind.dir_name().is_empty()); + assert!(!kind.type_name().is_empty()); + } + } + + #[test] + fn magic_item_dir_and_type_names_differ() { + assert_eq!(Kind::MagicItem.dir_name(), "magic-items"); + assert_eq!(Kind::MagicItem.type_name(), "magic-item"); + } +} diff --git a/src/srd/verify/layer.rs b/src/srd/verify/layer.rs new file mode 100644 index 0000000..f845b54 --- /dev/null +++ b/src/srd/verify/layer.rs @@ -0,0 +1,191 @@ +//! Walks a corpus layer's kind directories and parses each entry file into +//! its frontmatter and body. + +use std::fs; +use std::path::{Path, PathBuf}; + +use serde_yaml_ng::Value; + +use super::Failure; +use super::kind::Kind; + +/// One parsed corpus entry: its frontmatter, still as a generic YAML value +/// so each kind's schema can validate it, and its body markdown. `body` +/// is not read yet in the schema layer; the fidelity layer compares it +/// to the vendored source. +#[allow(dead_code)] +pub struct CorpusFile { + pub path: PathBuf, + pub kind: Kind, + pub frontmatter: Value, + pub body: String, +} + +/// Reads every `.md` file directly inside each kind's directory under +/// `layer_root`, parsing its frontmatter and body. A directory that is +/// missing or empty yields no files for that kind; a file that fails to +/// read, split, or parse yields a `Failure` instead of a `CorpusFile`. +pub fn discover(layer_root: &Path) -> (Vec, Vec) { + let mut files = Vec::new(); + let mut failures = Vec::new(); + for kind in Kind::ALL { + let dir = layer_root.join(kind.dir_name()); + let Ok(entries) = fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("md") { + continue; + } + match parse_file(&path, kind) { + Ok(file) => files.push(file), + Err(message) => failures.push(Failure::new(path, message)), + } + } + } + (files, failures) +} + +fn parse_file(path: &Path, kind: Kind) -> Result { + let contents = + fs::read_to_string(path).map_err(|error| format!("cannot read file: {error}"))?; + let (frontmatter_text, body) = split_frontmatter(&contents)?; + let frontmatter: Value = serde_yaml_ng::from_str(&frontmatter_text) + .map_err(|error| format!("invalid frontmatter YAML: {error}"))?; + Ok(CorpusFile { + path: path.to_path_buf(), + kind, + frontmatter, + body, + }) +} + +/// Splits `contents` into its frontmatter YAML and body markdown. A corpus +/// file opens with a `---` delimiter line, holds YAML until the next `---` +/// delimiter line, and treats everything after that as the body. +fn split_frontmatter(contents: &str) -> Result<(String, String), String> { + let mut lines = contents.lines(); + if lines.next() != Some("---") { + return Err("file must start with a `---` frontmatter delimiter".to_string()); + } + let mut frontmatter_lines = Vec::new(); + for line in lines.by_ref() { + if line == "---" { + let body: String = lines.collect::>().join("\n"); + return Ok((frontmatter_lines.join("\n"), body)); + } + frontmatter_lines.push(line); + } + Err("frontmatter is missing its closing `---` delimiter".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::srd::verify::fixtures::unique_temp_dir; + + #[test] + fn splits_frontmatter_and_body() { + let contents = "---\nname: Fireball\ntype: spell\n---\n# Fireball\n\nBody text.\n"; + + let (frontmatter, body) = split_frontmatter(contents).unwrap(); + + assert_eq!(frontmatter, "name: Fireball\ntype: spell"); + assert_eq!(body, "# Fireball\n\nBody text."); + } + + #[test] + fn rejects_a_file_missing_the_opening_delimiter() { + let error = split_frontmatter("name: Fireball\n---\nbody").unwrap_err(); + assert!(error.contains("must start with")); + } + + #[test] + fn rejects_a_file_missing_the_closing_delimiter() { + let error = split_frontmatter("---\nname: Fireball\nbody").unwrap_err(); + assert!(error.contains("closing")); + } + + #[test] + fn discover_finds_md_files_in_each_kind_directory() { + let layer_root = unique_temp_dir(); + let spells_dir = layer_root.join("spells"); + fs::create_dir_all(&spells_dir).unwrap(); + fs::write( + spells_dir.join("fireball.md"), + "---\nname: Fireball\ntype: spell\n---\nbody\n", + ) + .unwrap(); + fs::write(spells_dir.join("notes.txt"), "ignored").unwrap(); + + let (files, failures) = discover(&layer_root); + + assert_eq!(failures, vec![]); + assert_eq!(files.len(), 1); + assert_eq!(files[0].kind, Kind::Spell); + } + + #[test] + fn discover_reports_a_parse_failure_without_stopping() { + let layer_root = unique_temp_dir(); + let spells_dir = layer_root.join("spells"); + fs::create_dir_all(&spells_dir).unwrap(); + fs::write(spells_dir.join("broken.md"), "not frontmatter at all").unwrap(); + fs::write( + spells_dir.join("fireball.md"), + "---\nname: Fireball\ntype: spell\n---\nbody\n", + ) + .unwrap(); + + let (files, failures) = discover(&layer_root); + + assert_eq!(files.len(), 1); + assert_eq!(failures.len(), 1); + assert!(failures[0].message.contains("must start with")); + } + + #[test] + fn discover_reports_a_file_it_cannot_read() { + let layer_root = unique_temp_dir(); + let spells_dir = layer_root.join("spells"); + // A directory named `*.md` fails `fs::read_to_string` with an IO + // error, standing in for a permissions problem without needing + // to change any real file's mode. + fs::create_dir_all(spells_dir.join("directory.md")).unwrap(); + + let (files, failures) = discover(&layer_root); + + assert!(files.is_empty()); + assert_eq!(failures.len(), 1); + assert!(failures[0].message.contains("cannot read file")); + } + + #[test] + fn discover_reports_invalid_frontmatter_yaml() { + let layer_root = unique_temp_dir(); + let spells_dir = layer_root.join("spells"); + fs::create_dir_all(&spells_dir).unwrap(); + fs::write( + spells_dir.join("bad-yaml.md"), + "---\nname: [unterminated\n---\nbody\n", + ) + .unwrap(); + + let (files, failures) = discover(&layer_root); + + assert!(files.is_empty()); + assert_eq!(failures.len(), 1); + assert!(failures[0].message.contains("invalid frontmatter YAML")); + } + + #[test] + fn discover_tolerates_a_missing_kind_directory() { + let layer_root = unique_temp_dir(); + + let (files, failures) = discover(&layer_root); + + assert!(files.is_empty()); + assert_eq!(failures, vec![]); + } +} diff --git a/src/srd/verify/magic_item.rs b/src/srd/verify/magic_item.rs new file mode 100644 index 0000000..a4c22ea --- /dev/null +++ b/src/srd/verify/magic_item.rs @@ -0,0 +1,229 @@ +//! The magic item frontmatter schema, beyond the universal keys. +//! +//! The rendering rules that turn `rarity` and `attunement` into the +//! corpus body's `**Rarity:**` and `**Attunement:**` field line text live +//! in `field_lines.rs`, next to the rest of the field-line drift check +//! that uses them. + +use serde_yaml_ng::Mapping; + +use super::Failure; +use super::layer::CorpusFile; +use super::values; + +const RARITIES: [&str; 7] = [ + "common", + "uncommon", + "rare", + "very-rare", + "legendary", + "artifact", + "varies", +]; + +/// A magic item's mechanical frontmatter, validated. Not read again yet +/// in the schema layer; the fidelity layer's field-line drift check +/// reads these values. +#[allow(dead_code)] +pub struct MagicItemFields { + pub category: String, + pub rarity: String, + pub attunement: bool, + pub attunement_note: Option, +} + +/// Validates the magic-item-specific fields, appending any problems to +/// `failures`. Returns the validated fields only when every one of them +/// is usable. +pub fn validate(file: &CorpusFile, failures: &mut Vec) -> Option { + let mapping = file.frontmatter.as_mapping()?; + + let category = check_category(file, mapping, failures); + let rarity = check_rarity(file, mapping, failures); + let attunement = check_attunement(file, mapping, failures); + let attunement_note = check_attunement_note(file, mapping, failures); + + Some(MagicItemFields { + category: category?, + rarity: rarity?, + attunement: attunement?, + attunement_note: attunement_note?, + }) +} + +fn check_category( + file: &CorpusFile, + mapping: &Mapping, + failures: &mut Vec, +) -> Option { + match values::nonempty_string(mapping, "category") { + Ok(category) => Some(category), + Err(message) => { + failures.push(Failure::new(&file.path, message)); + None + } + } +} + +fn check_rarity( + file: &CorpusFile, + mapping: &Mapping, + failures: &mut Vec, +) -> Option { + match values::nonempty_string(mapping, "rarity") { + Ok(rarity) if RARITIES.contains(&rarity.as_str()) => Some(rarity), + Ok(rarity) => { + failures.push(Failure::new( + &file.path, + format!("'rarity' must be one of {RARITIES:?}, got '{rarity}'"), + )); + None + } + Err(message) => { + failures.push(Failure::new(&file.path, message)); + None + } + } +} + +fn check_attunement( + file: &CorpusFile, + mapping: &Mapping, + failures: &mut Vec, +) -> Option { + match values::boolean(mapping, "attunement") { + Ok(attunement) => Some(attunement), + Err(message) => { + failures.push(Failure::new(&file.path, message)); + None + } + } +} + +fn check_attunement_note( + file: &CorpusFile, + mapping: &Mapping, + failures: &mut Vec, +) -> Option> { + match values::optional_nonempty_string(mapping, "attunement_note") { + Ok(note) => Some(note), + Err(message) => { + failures.push(Failure::new(&file.path, message)); + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::srd::verify::kind::Kind; + use std::path::PathBuf; + + fn item_file(frontmatter_yaml: &str) -> CorpusFile { + CorpusFile { + path: PathBuf::from("magic-items/amulet-of-health.md"), + kind: Kind::MagicItem, + frontmatter: serde_yaml_ng::from_str(frontmatter_yaml).unwrap(), + body: String::new(), + } + } + + #[test] + fn validate_accepts_well_formed_fields() { + let file = item_file( + "category: Wondrous Item\nrarity: rare\nattunement: true\nattunement_note: null", + ); + let mut failures = Vec::new(); + + let fields = validate(&file, &mut failures).unwrap(); + + assert_eq!(failures, vec![]); + assert_eq!(fields.rarity, "rare"); + assert!(fields.attunement); + assert_eq!(fields.attunement_note, None); + } + + #[test] + fn validate_accepts_a_present_attunement_note() { + let file = item_file( + "category: Staff\nrarity: rare\nattunement: true\nattunement_note: by a Druid", + ); + let mut failures = Vec::new(); + + let fields = validate(&file, &mut failures).unwrap(); + + assert_eq!(fields.attunement_note, Some("by a Druid".to_string())); + } + + #[test] + fn validate_rejects_a_missing_category() { + let file = item_file("rarity: rare\nattunement: false"); + let mut failures = Vec::new(); + + validate(&file, &mut failures); + + assert!(failures.iter().any(|f| f.message.contains("'category'"))); + } + + #[test] + fn validate_rejects_a_missing_rarity() { + let file = item_file("category: Ring\nattunement: false"); + let mut failures = Vec::new(); + + validate(&file, &mut failures); + + assert!(failures.iter().any(|f| f.message.contains("'rarity'"))); + } + + #[test] + fn validate_rejects_a_non_string_attunement_note() { + let file = item_file("category: Ring\nrarity: rare\nattunement: true\nattunement_note: 5"); + let mut failures = Vec::new(); + + validate(&file, &mut failures); + + assert!( + failures + .iter() + .any(|f| f.message.contains("'attunement_note'")) + ); + } + + #[test] + fn validate_rejects_a_bad_rarity() { + let file = item_file("category: Ring\nrarity: mythical\nattunement: false"); + let mut failures = Vec::new(); + + validate(&file, &mut failures); + + assert!( + failures + .iter() + .any(|f| f.message.contains("must be one of")) + ); + } + + #[test] + fn validate_rejects_a_non_boolean_attunement() { + let file = item_file("category: Ring\nrarity: rare\nattunement: maybe"); + let mut failures = Vec::new(); + + validate(&file, &mut failures); + + assert!( + failures + .iter() + .any(|f| f.message.contains("must be a boolean")) + ); + } + + #[test] + fn validate_returns_none_for_non_mapping_frontmatter() { + let file = item_file("just a string"); + let mut failures = Vec::new(); + + assert!(validate(&file, &mut failures).is_none()); + assert_eq!(failures, vec![]); + } +} diff --git a/src/srd/verify/mod.rs b/src/srd/verify/mod.rs new file mode 100644 index 0000000..dd3dc30 --- /dev/null +++ b/src/srd/verify/mod.rs @@ -0,0 +1,255 @@ +//! Deterministically gates the SRD corpus against its vendored source and +//! its kind's schema. This module is the normative definition of the +//! corpus format contract: whatever it accepts is legal, whatever it +//! rejects is not. +//! +//! This is the schema layer: every corpus file parses, its universal and +//! per-kind frontmatter fields are well-formed, and its slug is +//! deterministic and collision-free. Fidelity against the vendored +//! source, coverage of the vendored tree, and shape (entry-count floors, +//! README, meta.yaml) are separate layers built on top of this one. + +#[cfg(test)] +mod fixtures; +mod kind; +mod layer; +mod magic_item; +mod monster; +mod schema; +mod slug; +mod spell; +mod values; + +use std::collections::HashMap; +use std::fmt; +use std::path::{Path, PathBuf}; + +pub use kind::Kind; + +/// One thing wrong with the corpus, tied to the file that has the problem. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Failure { + pub path: PathBuf, + pub message: String, +} + +impl Failure { + pub fn new(path: impl Into, message: impl Into) -> Self { + Self { + path: path.into(), + message: message.into(), + } + } +} + +impl fmt::Display for Failure { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}: {}", self.path.display(), self.message) + } +} + +/// Every problem `verify` found, and how many entries it counted per +/// kind (counted regardless of whether that entry also failed some +/// other check, so the summary reflects what is actually on disk). +pub struct Report { + pub failures: Vec, + pub counts: HashMap, +} + +impl Report { + /// A one-line, human-readable count of entries per kind. + pub fn summary(&self) -> String { + let parts: Vec = Kind::ALL + .iter() + .map(|kind| { + format!( + "{} {}", + self.counts.get(kind).copied().unwrap_or(0), + kind.dir_name() + ) + }) + .collect(); + format!("verified layer: {}", parts.join(", ")) + } +} + +/// Verifies the corpus layer rooted at `layer_root`'s schema: every file +/// parses, its frontmatter matches its kind, and its slug is well-formed +/// and collision-free. Returns every failure found rather than stopping +/// at the first. +pub fn verify(layer_root: &Path) -> Report { + let (files, mut failures) = layer::discover(layer_root); + + let mut counts: HashMap = HashMap::new(); + let mut names: Vec<(PathBuf, String)> = Vec::new(); + + for file in &files { + *counts.entry(file.kind).or_insert(0) += 1; + + let Some(universal) = schema::validate_universal(file, layer_root, &mut failures) else { + continue; + }; + names.push((file.path.clone(), universal.name.clone())); + + match file.kind { + Kind::Spell => { + spell::validate(file, &mut failures); + } + Kind::MagicItem => { + magic_item::validate(file, &mut failures); + } + Kind::Monster => { + monster::validate(file, &mut failures); + } + Kind::Core | Kind::Class | Kind::Feat => {} + } + } + + failures.extend(schema::check_slug_collisions(&names)); + + Report { failures, counts } +} + +#[cfg(test)] +mod tests { + use super::*; + use fixtures::{unique_temp_dir, write_file}; + + #[test] + fn failure_displays_as_path_colon_message() { + let failure = Failure::new("spells/fireball.md", "missing name"); + assert_eq!(failure.to_string(), "spells/fireball.md: missing name"); + } + + #[test] + fn report_summary_lists_every_kind_in_order() { + let report = Report { + failures: vec![], + counts: HashMap::from([(Kind::Spell, 3), (Kind::Core, 1)]), + }; + + assert_eq!( + report.summary(), + "verified layer: 1 core, 0 classes, 3 spells, 0 monsters, 0 magic-items, 0 feats" + ); + } + + #[test] + fn verify_on_an_empty_layer_finds_nothing() { + let layer_root = unique_temp_dir(); + + let report = verify(&layer_root); + + assert_eq!(report.failures, vec![]); + assert!(report.counts.is_empty()); + } + + #[test] + fn verify_counts_files_per_kind_even_when_universal_validation_fails() { + let layer_root = unique_temp_dir(); + write_file( + &layer_root.join("spells/fireball.md"), + "---\ntype: spell\n---\nbody\n", + ); + + let report = verify(&layer_root); + + assert_eq!(report.counts.get(&Kind::Spell), Some(&1)); + assert!(report.failures.iter().any(|f| f.message.contains("'name'"))); + } + + #[test] + fn verify_dispatches_spell_schema_validation() { + let layer_root = unique_temp_dir(); + let source = "sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md"; + write_file(&layer_root.join(source), "content"); + write_file( + &layer_root.join("spells/fireball.md"), + &format!( + "---\nname: Fireball\ntype: spell\nsource: {source}\nschool: astrology\n---\nbody\n" + ), + ); + + let report = verify(&layer_root); + + assert!(report.failures.iter().any(|f| f.message.contains("school"))); + } + + #[test] + fn verify_dispatches_magic_item_schema_validation() { + let layer_root = unique_temp_dir(); + let source = "sources/dnd.srd.5.2.1/10_Magic_Items/Magic_Items_Each/Amulet.md"; + write_file(&layer_root.join(source), "content"); + write_file( + &layer_root.join("magic-items/amulet.md"), + &format!( + "---\nname: Amulet\ntype: magic-item\nsource: {source}\nrarity: mythical\n---\nbody\n" + ), + ); + + let report = verify(&layer_root); + + assert!(report.failures.iter().any(|f| f.message.contains("rarity"))); + } + + #[test] + fn verify_dispatches_monster_schema_validation() { + let layer_root = unique_temp_dir(); + let source = "sources/dnd.srd.5.2.1/11_Monsters/Monsters_Each/Goblin.md"; + write_file(&layer_root.join(source), "content"); + write_file( + &layer_root.join("monsters/goblin.md"), + &format!("---\nname: Goblin\ntype: monster\nsource: {source}\n---\nbody\n"), + ); + + let report = verify(&layer_root); + + assert!( + report + .failures + .iter() + .any(|f| f.message.contains("abilities")) + ); + } + + #[test] + fn verify_requires_nothing_extra_for_core_class_and_feat() { + let layer_root = unique_temp_dir(); + let source = "sources/dnd.srd.5.2.1/05_Feats/Feats_Each/Alert.md"; + write_file(&layer_root.join(source), "content"); + write_file( + &layer_root.join("feats/alert.md"), + &format!("---\nname: Alert\ntype: feat\nsource: {source}\n---\nbody\n"), + ); + + let report = verify(&layer_root); + + assert_eq!(report.failures, vec![]); + } + + #[test] + fn verify_reports_a_slug_collision_across_files() { + let layer_root = unique_temp_dir(); + let feat_source = "sources/dnd.srd.5.2.1/05_Feats/Feats_Each/Light.md"; + let core_source = "sources/dnd.srd.5.2.1/01_Playing_The_Game/Light.md"; + write_file(&layer_root.join(feat_source), "content"); + write_file(&layer_root.join(core_source), "content"); + write_file( + &layer_root.join("feats/light.md"), + &format!("---\nname: Light\ntype: feat\nsource: {feat_source}\n---\nbody\n"), + ); + write_file( + &layer_root.join("core/light.md"), + &format!("---\nname: Light\ntype: core\nsource: {core_source}\n---\nbody\n"), + ); + + let report = verify(&layer_root); + + assert!( + report + .failures + .iter() + .any(|f| f.message.contains("collides with")) + ); + } +} diff --git a/src/srd/verify/monster.rs b/src/srd/verify/monster.rs new file mode 100644 index 0000000..9aec096 --- /dev/null +++ b/src/srd/verify/monster.rs @@ -0,0 +1,435 @@ +//! The monster frontmatter schema, beyond the universal keys. +//! +//! A monster keeps its mechanical stats only in frontmatter; the body +//! keeps the flavor line and the Traits/Actions prose. `stat_block.rs` +//! extracts the vendored source's matching stat-block region and checks +//! it against these fields word for word, using `MonsterFields`' public +//! fields directly. + +use serde_yaml_ng::Mapping; + +use super::Failure; +use super::layer::CorpusFile; +use super::values; + +const ABILITY_KEYS: [&str; 6] = ["str", "dex", "con", "int", "wis", "cha"]; + +/// One ability's score, modifier, and save. Not read again yet in the +/// schema layer; the fidelity layer's stat-block accounting check reads +/// these values. +#[allow(dead_code)] +pub struct Ability { + pub score: i64, + pub modifier: i64, + pub save: i64, +} + +/// A monster's mechanical frontmatter, validated. `gear` accepts either a +/// single string or a list of strings in the frontmatter YAML (a monster's +/// gear is usually a short comma-separated list, and a list reads better +/// than one long string when there is more than one item); either shape +/// renders to the same comma-joined text for the stat-block word count. +/// +/// `size`, `creature_type`, and `alignment` are validated here (present, +/// non-empty) but not read again afterward: their words live in the +/// source's italic type line, outside the stat-block region, so the +/// ordinary body word-stream check is what holds them to the source, not +/// this struct. +#[allow(dead_code)] +pub struct MonsterFields { + pub size: String, + pub creature_type: String, + pub alignment: String, + pub ac: String, + pub hp: String, + pub speed: String, + pub abilities: [(&'static str, Ability); 6], + pub cr: String, + pub skills: Option, + pub senses: Option, + pub languages: Option, + pub resistances: Option, + pub immunities: Option, + pub vulnerabilities: Option, + pub gear: Option, +} + +/// Validates the monster-specific fields, appending any problems to +/// `failures`. Returns the validated fields only when every one of them +/// is usable. +pub fn validate(file: &CorpusFile, failures: &mut Vec) -> Option { + let mapping = file.frontmatter.as_mapping()?; + + let size = required(file, mapping, "size", failures); + let creature_type = required(file, mapping, "creature_type", failures); + let alignment = required(file, mapping, "alignment", failures); + let ac = required(file, mapping, "ac", failures); + let hp = required(file, mapping, "hp", failures); + let speed = required(file, mapping, "speed", failures); + let abilities = check_abilities(file, mapping, failures); + let cr = required(file, mapping, "cr", failures); + let skills = optional(file, mapping, "skills", failures); + let senses = optional(file, mapping, "senses", failures); + let languages = optional(file, mapping, "languages", failures); + let resistances = optional(file, mapping, "resistances", failures); + let immunities = optional(file, mapping, "immunities", failures); + let vulnerabilities = optional(file, mapping, "vulnerabilities", failures); + let gear = check_gear(file, mapping, failures); + + Some(MonsterFields { + size: size?, + creature_type: creature_type?, + alignment: alignment?, + ac: ac?, + hp: hp?, + speed: speed?, + abilities: abilities?, + cr: cr?, + skills: skills?, + senses: senses?, + languages: languages?, + resistances: resistances?, + immunities: immunities?, + vulnerabilities: vulnerabilities?, + gear: gear?, + }) +} + +fn required( + file: &CorpusFile, + mapping: &Mapping, + key: &str, + failures: &mut Vec, +) -> Option { + match values::nonempty_string(mapping, key) { + Ok(value) => Some(value), + Err(message) => { + failures.push(Failure::new(&file.path, message)); + None + } + } +} + +fn optional( + file: &CorpusFile, + mapping: &Mapping, + key: &str, + failures: &mut Vec, +) -> Option> { + match values::optional_nonempty_string(mapping, key) { + Ok(value) => Some(value), + Err(message) => { + failures.push(Failure::new(&file.path, message)); + None + } + } +} + +/// `gear` accepts a string or a non-empty list of strings; a list joins +/// with `, ` to match the source's comma-separated gear line. +fn check_gear( + file: &CorpusFile, + mapping: &Mapping, + failures: &mut Vec, +) -> Option> { + match mapping.get("gear") { + None | Some(serde_yaml_ng::Value::Null) => Some(None), + Some(serde_yaml_ng::Value::String(value)) if !value.trim().is_empty() => { + Some(Some(value.clone())) + } + Some(serde_yaml_ng::Value::String(_)) => { + failures.push(Failure::new(&file.path, "'gear' must not be empty")); + None + } + Some(serde_yaml_ng::Value::Sequence(_)) => { + match values::nonempty_string_list(mapping, "gear") { + Ok(items) => Some(Some(items.join(", "))), + Err(message) => { + failures.push(Failure::new(&file.path, message)); + None + } + } + } + Some(_) => { + failures.push(Failure::new( + &file.path, + "'gear' must be a string or a list of strings", + )); + None + } + } +} + +fn check_abilities( + file: &CorpusFile, + mapping: &Mapping, + failures: &mut Vec, +) -> Option<[(&'static str, Ability); 6]> { + let Some(serde_yaml_ng::Value::Mapping(abilities)) = mapping.get("abilities") else { + failures.push(Failure::new( + &file.path, + "missing required field 'abilities'", + )); + return None; + }; + + let extra_keys: Vec = abilities + .keys() + .filter_map(|key| key.as_str()) + .filter(|key| !ABILITY_KEYS.contains(key)) + .map(str::to_string) + .collect(); + for key in &extra_keys { + failures.push(Failure::new( + &file.path, + format!("'abilities' has an unknown key '{key}'"), + )); + } + + let mut ok = extra_keys.is_empty(); + let mut result = Vec::with_capacity(6); + for key in ABILITY_KEYS { + match check_ability(file, abilities, key, failures) { + Some(ability) => result.push((key, ability)), + None => ok = false, + } + } + + if ok { result.try_into().ok() } else { None } +} + +fn check_ability( + file: &CorpusFile, + abilities: &Mapping, + key: &str, + failures: &mut Vec, +) -> Option { + let Some(serde_yaml_ng::Value::Mapping(entry)) = abilities.get(key) else { + failures.push(Failure::new( + &file.path, + format!("'abilities.{key}' must be a mapping with score, mod, and save"), + )); + return None; + }; + let score = ability_component(file, entry, key, "score", failures); + let modifier = ability_component(file, entry, key, "mod", failures); + let save = ability_component(file, entry, key, "save", failures); + Some(Ability { + score: score?, + modifier: modifier?, + save: save?, + }) +} + +fn ability_component( + file: &CorpusFile, + entry: &Mapping, + ability: &str, + key: &str, + failures: &mut Vec, +) -> Option { + match values::integer(entry, key) { + Ok(value) => Some(value), + Err(message) => { + failures.push(Failure::new( + &file.path, + format!("'abilities.{ability}.{key}': {message}"), + )); + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::srd::verify::kind::Kind; + use std::path::PathBuf; + + fn monster_file(frontmatter_yaml: &str) -> CorpusFile { + CorpusFile { + path: PathBuf::from("monsters/goblin-warrior.md"), + kind: Kind::Monster, + frontmatter: serde_yaml_ng::from_str(frontmatter_yaml).unwrap(), + body: String::new(), + } + } + + fn valid_yaml() -> &'static str { + "size: Small\n\ + creature_type: Fey (Goblinoid)\n\ + alignment: Chaotic Neutral\n\ + ac: '15'\n\ + hp: 10 (3d6)\n\ + speed: 30 ft.\n\ + cr: 1/4 (XP 50; PB +2)\n\ + skills: Stealth +6\n\ + gear: [Leather Armor, Scimitar]\n\ + abilities:\n\ + \x20 str: {score: 8, mod: -1, save: -1}\n\ + \x20 dex: {score: 15, mod: 2, save: 2}\n\ + \x20 con: {score: 10, mod: 0, save: 0}\n\ + \x20 int: {score: 10, mod: 0, save: 0}\n\ + \x20 wis: {score: 8, mod: -1, save: -1}\n\ + \x20 cha: {score: 8, mod: -1, save: -1}" + } + + #[test] + fn validate_accepts_well_formed_fields() { + let file = monster_file(valid_yaml()); + let mut failures = Vec::new(); + + let fields = validate(&file, &mut failures).unwrap(); + + assert_eq!(failures, vec![]); + assert_eq!(fields.ac, "15"); + assert_eq!(fields.gear, Some("Leather Armor, Scimitar".to_string())); + assert_eq!(fields.senses, None); + } + + #[test] + fn validate_rejects_a_missing_required_field() { + let yaml = valid_yaml().replace("speed: 30 ft.\n", ""); + let file = monster_file(&yaml); + let mut failures = Vec::new(); + + validate(&file, &mut failures); + + assert!(failures.iter().any(|f| f.message.contains("'speed'"))); + } + + #[test] + fn validate_rejects_a_blank_optional_field() { + let yaml = format!("{}\nsenses: ' '", valid_yaml()); + let file = monster_file(&yaml); + let mut failures = Vec::new(); + + validate(&file, &mut failures); + + assert!(failures.iter().any(|f| f.message.contains("'senses'"))); + } + + #[test] + fn validate_accepts_gear_as_a_plain_string() { + let yaml = + valid_yaml().replace("gear: [Leather Armor, Scimitar]\n", "gear: Leather Armor\n"); + let file = monster_file(&yaml); + let mut failures = Vec::new(); + + let fields = validate(&file, &mut failures).unwrap(); + + assert_eq!(fields.gear, Some("Leather Armor".to_string())); + } + + #[test] + fn validate_rejects_a_missing_abilities_map() { + let file = monster_file( + "size: Small\ncreature_type: Fey\nalignment: Chaotic Neutral\nac: '15'\nhp: '10'\nspeed: 30 ft.\ncr: '1/4'", + ); + let mut failures = Vec::new(); + + validate(&file, &mut failures); + + assert!(failures.iter().any(|f| f.message.contains("abilities"))); + } + + #[test] + fn validate_rejects_an_ability_missing_a_component() { + let yaml = "size: Small\ncreature_type: Fey\nalignment: Chaotic Neutral\nac: '15'\nhp: '10'\nspeed: 30 ft.\ncr: '1/4'\nabilities:\n str: {score: 8, mod: -1, save: -1}\n dex: {score: 15, mod: 2, save: 2}\n con: {score: 10, mod: 0, save: 0}\n int: {score: 10, mod: 0, save: 0}\n wis: {score: 8, mod: -1, save: -1}\n cha: {score: 8, mod: -1}"; + let file = monster_file(yaml); + let mut failures = Vec::new(); + + validate(&file, &mut failures); + + assert!( + failures + .iter() + .any(|f| f.message.contains("abilities.cha.save")) + ); + } + + #[test] + fn validate_rejects_an_unknown_ability_key() { + let yaml = "size: Small\ncreature_type: Fey\nalignment: Chaotic Neutral\nac: '15'\nhp: '10'\nspeed: 30 ft.\ncr: '1/4'\nabilities:\n str: {score: 8, mod: -1, save: -1}\n dex: {score: 15, mod: 2, save: 2}\n con: {score: 10, mod: 0, save: 0}\n int: {score: 10, mod: 0, save: 0}\n wis: {score: 8, mod: -1, save: -1}\n cha: {score: 8, mod: -1, save: -1}\n luck: {score: 8, mod: -1, save: -1}"; + let file = monster_file(yaml); + let mut failures = Vec::new(); + + validate(&file, &mut failures); + + assert!( + failures + .iter() + .any(|f| f.message.contains("unknown key 'luck'")) + ); + } + + #[test] + fn validate_rejects_an_ability_entry_that_is_not_a_mapping() { + let yaml = "size: Small\ncreature_type: Fey\nalignment: Chaotic Neutral\nac: '15'\nhp: '10'\nspeed: 30 ft.\ncr: '1/4'\nabilities:\n str: 8\n dex: {score: 15, mod: 2, save: 2}\n con: {score: 10, mod: 0, save: 0}\n int: {score: 10, mod: 0, save: 0}\n wis: {score: 8, mod: -1, save: -1}\n cha: {score: 8, mod: -1, save: -1}"; + let file = monster_file(yaml); + let mut failures = Vec::new(); + + validate(&file, &mut failures); + + assert!(failures.iter().any(|f| f.message.contains("abilities.str"))); + } + + #[test] + fn validate_rejects_gear_that_is_neither_a_string_nor_a_list() { + let yaml = format!( + "{}\ngear: 5", + valid_yaml().replace("gear: [Leather Armor, Scimitar]\n", "") + ); + let file = monster_file(&yaml); + let mut failures = Vec::new(); + + validate(&file, &mut failures); + + assert!( + failures + .iter() + .any(|f| f.message.contains("string or a list")) + ); + } + + #[test] + fn validate_rejects_an_empty_gear_list() { + let yaml = format!( + "{}\ngear: []", + valid_yaml().replace("gear: [Leather Armor, Scimitar]\n", "") + ); + let file = monster_file(&yaml); + let mut failures = Vec::new(); + + validate(&file, &mut failures); + + assert!(failures.iter().any(|f| f.message.contains("gear"))); + } + + #[test] + fn validate_rejects_a_blank_gear_string() { + let yaml = format!( + "{}\ngear: ' '", + valid_yaml().replace("gear: [Leather Armor, Scimitar]\n", "") + ); + let file = monster_file(&yaml); + let mut failures = Vec::new(); + + validate(&file, &mut failures); + + assert!( + failures + .iter() + .any(|f| f.message.contains("'gear' must not be empty")) + ); + } + + #[test] + fn validate_returns_none_for_non_mapping_frontmatter() { + let file = monster_file("just a string"); + let mut failures = Vec::new(); + + assert!(validate(&file, &mut failures).is_none()); + assert_eq!(failures, vec![]); + } +} diff --git a/src/srd/verify/schema.rs b/src/srd/verify/schema.rs new file mode 100644 index 0000000..889aee4 --- /dev/null +++ b/src/srd/verify/schema.rs @@ -0,0 +1,469 @@ +//! Frontmatter validation shared by every kind: unknown keys, `name`, +//! `type`, `source`, and the filename-matches-slug rule. Also the +//! per-layer slug collision check, since it needs every file's name at +//! once. Per-kind schemas beyond the universal keys live in +//! `spell.rs`, `magic_item.rs`, and `monster.rs`. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use super::Failure; +use super::kind::Kind; +use super::layer::CorpusFile; +use super::slug; +use super::values; + +const VENDORED_PREFIX: &str = "sources/dnd.srd.5.2.1/"; + +const UNIVERSAL_KEYS: [&str; 3] = ["name", "type", "source"]; + +const SPELL_KEYS: [&str; 7] = [ + "level", + "school", + "classes", + "casting_time", + "range", + "components", + "duration", +]; + +const MAGIC_ITEM_KEYS: [&str; 4] = ["category", "rarity", "attunement", "attunement_note"]; + +const MONSTER_KEYS: [&str; 15] = [ + "size", + "creature_type", + "alignment", + "ac", + "hp", + "speed", + "abilities", + "cr", + "skills", + "senses", + "languages", + "resistances", + "immunities", + "vulnerabilities", + "gear", +]; + +/// Every frontmatter key `kind` allows, universal keys included. +pub fn allowed_keys(kind: Kind) -> Vec<&'static str> { + let extra: &[&str] = match kind { + Kind::Core | Kind::Class | Kind::Feat => &[], + Kind::Spell => &SPELL_KEYS, + Kind::MagicItem => &MAGIC_ITEM_KEYS, + Kind::Monster => &MONSTER_KEYS, + }; + UNIVERSAL_KEYS.iter().chain(extra).copied().collect() +} + +/// The universal fields every corpus file must have, validated. `source` +/// is not read yet in the schema layer; the fidelity and coverage layers +/// use it to find the vendored file a corpus entry claims. +#[allow(dead_code)] +pub struct Universal { + pub name: String, + pub source: PathBuf, +} + +/// Validates the keys, `name`, `type`, `source`, and slug every corpus +/// file must satisfy regardless of kind, appending any problems to +/// `failures`. Returns the validated fields when `name` and `source` are +/// both usable, even if other problems were also reported. +pub fn validate_universal( + file: &CorpusFile, + layer_root: &Path, + failures: &mut Vec, +) -> Option { + let Some(mapping) = file.frontmatter.as_mapping() else { + failures.push(Failure::new( + &file.path, + "frontmatter must be a YAML mapping", + )); + return None; + }; + + check_unknown_keys(file, mapping, failures); + + let name = match values::nonempty_string(mapping, "name") { + Ok(name) => Some(name), + Err(message) => { + failures.push(Failure::new(&file.path, message)); + None + } + }; + + check_type(file, mapping, failures); + let source = check_source(file, mapping, layer_root, failures); + + if let Some(name) = &name { + check_slug(file, name, failures); + } + + match (name, source) { + (Some(name), Some(source)) => Some(Universal { name, source }), + _ => None, + } +} + +fn check_unknown_keys( + file: &CorpusFile, + mapping: &serde_yaml_ng::Mapping, + failures: &mut Vec, +) { + let allowed = allowed_keys(file.kind); + for key in mapping.keys() { + match key.as_str() { + Some(key_str) if allowed.contains(&key_str) => {} + Some(key_str) => failures.push(Failure::new( + &file.path, + format!("unknown frontmatter key '{key_str}'"), + )), + None => failures.push(Failure::new(&file.path, "frontmatter keys must be strings")), + } + } +} + +fn check_type(file: &CorpusFile, mapping: &serde_yaml_ng::Mapping, failures: &mut Vec) { + match values::nonempty_string(mapping, "type") { + Ok(type_value) if type_value == file.kind.type_name() => {} + Ok(type_value) => failures.push(Failure::new( + &file.path, + format!( + "type '{type_value}' does not match its directory (expected '{}')", + file.kind.type_name() + ), + )), + Err(message) => failures.push(Failure::new(&file.path, message)), + } +} + +fn check_source( + file: &CorpusFile, + mapping: &serde_yaml_ng::Mapping, + layer_root: &Path, + failures: &mut Vec, +) -> Option { + let source_value = match values::nonempty_string(mapping, "source") { + Ok(value) => value, + Err(message) => { + failures.push(Failure::new(&file.path, message)); + return None; + } + }; + if !source_value.starts_with(VENDORED_PREFIX) { + failures.push(Failure::new( + &file.path, + format!("source '{source_value}' is not under '{VENDORED_PREFIX}'"), + )); + return None; + } + let source_path = PathBuf::from(&source_value); + if !layer_root.join(&source_path).is_file() { + failures.push(Failure::new( + &file.path, + format!("source '{source_value}' does not exist"), + )); + return None; + } + Some(source_path) +} + +fn check_slug(file: &CorpusFile, name: &str, failures: &mut Vec) { + let expected = slug::slugify(name); + let actual = file + .path + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or_default(); + if actual != expected { + failures.push(Failure::new( + &file.path, + format!("filename '{actual}' does not match the slug of its name ('{expected}')"), + )); + } +} + +/// Reports every name whose slug is used by more than one corpus file in +/// the layer. +pub fn check_slug_collisions(names: &[(PathBuf, String)]) -> Vec { + let mut by_slug: HashMap> = HashMap::new(); + for (path, name) in names { + by_slug.entry(slug::slugify(name)).or_default().push(path); + } + let mut failures: Vec = by_slug + .into_iter() + .filter(|(_, paths)| paths.len() > 1) + .flat_map(|(slug, paths)| { + let others = paths + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(", "); + paths.into_iter().map(move |path| { + Failure::new( + path.clone(), + format!("slug '{slug}' collides with: {others}"), + ) + }) + }) + .collect(); + failures.sort_by(|a, b| a.path.cmp(&b.path)); + failures +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::srd::verify::fixtures::{unique_temp_dir, write_file}; + + fn corpus_file(kind: Kind, path: PathBuf, frontmatter_yaml: &str) -> CorpusFile { + CorpusFile { + path, + kind, + frontmatter: serde_yaml_ng::from_str(frontmatter_yaml).unwrap(), + body: String::new(), + } + } + + fn layer_with_source(source_relative: &str) -> PathBuf { + let layer_root = unique_temp_dir(); + write_file(&layer_root.join(source_relative), "content"); + layer_root + } + + #[test] + fn allowed_keys_for_core_is_universal_only() { + assert_eq!(allowed_keys(Kind::Core), vec!["name", "type", "source"]); + } + + #[test] + fn allowed_keys_for_spell_includes_spell_fields() { + assert!(allowed_keys(Kind::Spell).contains(&"school")); + } + + #[test] + fn validate_universal_accepts_a_well_formed_file() { + let layer_root = + layer_with_source("sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md"); + let file = corpus_file( + Kind::Spell, + layer_root.join("spells/fireball.md"), + "name: Fireball\ntype: spell\nsource: sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md", + ); + let mut failures = Vec::new(); + + let universal = validate_universal(&file, &layer_root, &mut failures); + + assert_eq!(failures, vec![]); + assert_eq!(universal.unwrap().name, "Fireball"); + } + + #[test] + fn validate_universal_rejects_a_missing_name() { + let layer_root = + layer_with_source("sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md"); + let file = corpus_file( + Kind::Spell, + layer_root.join("spells/fireball.md"), + "type: spell\nsource: sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md", + ); + let mut failures = Vec::new(); + + let universal = validate_universal(&file, &layer_root, &mut failures); + + assert!(universal.is_none()); + assert!(failures.iter().any(|f| f.message.contains("'name'"))); + } + + #[test] + fn validate_universal_rejects_a_missing_type() { + let layer_root = + layer_with_source("sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md"); + let file = corpus_file( + Kind::Spell, + layer_root.join("spells/fireball.md"), + "name: Fireball\nsource: sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md", + ); + let mut failures = Vec::new(); + + validate_universal(&file, &layer_root, &mut failures); + + assert!(failures.iter().any(|f| f.message.contains("'type'"))); + } + + #[test] + fn validate_universal_rejects_a_missing_source_key() { + let layer_root = unique_temp_dir(); + let file = corpus_file( + Kind::Spell, + layer_root.join("spells/fireball.md"), + "name: Fireball\ntype: spell", + ); + let mut failures = Vec::new(); + + let universal = validate_universal(&file, &layer_root, &mut failures); + + assert!(universal.is_none()); + assert!(failures.iter().any(|f| f.message.contains("'source'"))); + } + + #[test] + fn validate_universal_rejects_an_unknown_key() { + let layer_root = + layer_with_source("sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md"); + let file = corpus_file( + Kind::Spell, + layer_root.join("spells/fireball.md"), + "name: Fireball\ntype: spell\nsource: sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md\nfoo: bar", + ); + let mut failures = Vec::new(); + + validate_universal(&file, &layer_root, &mut failures); + + assert!( + failures + .iter() + .any(|f| f.message.contains("unknown frontmatter key 'foo'")) + ); + } + + #[test] + fn validate_universal_rejects_a_non_string_key() { + let layer_root = + layer_with_source("sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md"); + let file = corpus_file( + Kind::Spell, + layer_root.join("spells/fireball.md"), + "name: Fireball\ntype: spell\nsource: sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md\n5: bar", + ); + let mut failures = Vec::new(); + + validate_universal(&file, &layer_root, &mut failures); + + assert!( + failures + .iter() + .any(|f| f.message.contains("keys must be strings")) + ); + } + + #[test] + fn validate_universal_rejects_a_non_mapping_frontmatter() { + let layer_root = unique_temp_dir(); + let file = corpus_file( + Kind::Spell, + layer_root.join("spells/fireball.md"), + "just a string", + ); + let mut failures = Vec::new(); + + let universal = validate_universal(&file, &layer_root, &mut failures); + + assert!(universal.is_none()); + assert!( + failures + .iter() + .any(|f| f.message.contains("must be a YAML mapping")) + ); + } + + #[test] + fn validate_universal_rejects_a_type_directory_mismatch() { + let layer_root = + layer_with_source("sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md"); + let file = corpus_file( + Kind::Spell, + layer_root.join("spells/fireball.md"), + "name: Fireball\ntype: monster\nsource: sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md", + ); + let mut failures = Vec::new(); + + validate_universal(&file, &layer_root, &mut failures); + + assert!( + failures + .iter() + .any(|f| f.message.contains("does not match its directory")) + ); + } + + #[test] + fn validate_universal_rejects_a_missing_source_file() { + let layer_root = unique_temp_dir(); + let file = corpus_file( + Kind::Spell, + layer_root.join("spells/fireball.md"), + "name: Fireball\ntype: spell\nsource: sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md", + ); + let mut failures = Vec::new(); + + validate_universal(&file, &layer_root, &mut failures); + + assert!( + failures + .iter() + .any(|f| f.message.contains("does not exist")) + ); + } + + #[test] + fn validate_universal_rejects_a_source_outside_the_vendored_tree() { + let layer_root = unique_temp_dir(); + write_file(&layer_root.join("elsewhere.md"), "content"); + let file = corpus_file( + Kind::Spell, + layer_root.join("spells/fireball.md"), + "name: Fireball\ntype: spell\nsource: elsewhere.md", + ); + let mut failures = Vec::new(); + + validate_universal(&file, &layer_root, &mut failures); + + assert!(failures.iter().any(|f| f.message.contains("is not under"))); + } + + #[test] + fn validate_universal_rejects_a_slug_mismatch() { + let layer_root = + layer_with_source("sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md"); + let file = corpus_file( + Kind::Spell, + layer_root.join("spells/not-fireball.md"), + "name: Fireball\ntype: spell\nsource: sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md", + ); + let mut failures = Vec::new(); + + validate_universal(&file, &layer_root, &mut failures); + + assert!( + failures + .iter() + .any(|f| f.message.contains("does not match the slug")) + ); + } + + #[test] + fn check_slug_collisions_is_empty_for_unique_names() { + let names = vec![ + (PathBuf::from("spells/fireball.md"), "Fireball".to_string()), + (PathBuf::from("spells/wish.md"), "Wish".to_string()), + ]; + + assert_eq!(check_slug_collisions(&names), vec![]); + } + + #[test] + fn check_slug_collisions_reports_every_colliding_file() { + let names = vec![ + (PathBuf::from("spells/light.md"), "Light".to_string()), + (PathBuf::from("magic-items/light.md"), "Light".to_string()), + ]; + + let failures = check_slug_collisions(&names); + + assert_eq!(failures.len(), 2); + assert!(failures.iter().all(|f| f.message.contains("collides with"))); + } +} diff --git a/src/srd/verify/slug.rs b/src/srd/verify/slug.rs new file mode 100644 index 0000000..a1b322e --- /dev/null +++ b/src/srd/verify/slug.rs @@ -0,0 +1,67 @@ +//! The deterministic address every corpus file's name must produce. + +/// Lowercases `name`, turns `+` into `plus`, and collapses every other run +/// of non-alphanumeric characters into a single `-`, with no leading or +/// trailing `-`. +pub fn slugify(name: &str) -> String { + let mut slug = String::with_capacity(name.len()); + let mut pending_dash = false; + for ch in name.chars() { + if ch == '+' { + if pending_dash && !slug.is_empty() { + slug.push('-'); + } + pending_dash = false; + slug.push_str("plus"); + } else if ch.is_ascii_alphanumeric() { + if pending_dash && !slug.is_empty() { + slug.push('-'); + } + pending_dash = false; + slug.push(ch.to_ascii_lowercase()); + } else { + pending_dash = true; + } + } + slug +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lowercases_a_plain_name() { + assert_eq!(slugify("Fireball"), "fireball"); + } + + #[test] + fn collapses_spaces_to_a_single_dash() { + assert_eq!(slugify("Goblin Warrior"), "goblin-warrior"); + } + + #[test] + fn collapses_a_run_of_punctuation_to_one_dash() { + assert_eq!(slugify("Amulet of Health!!!"), "amulet-of-health"); + } + + #[test] + fn turns_plus_into_plus() { + assert_eq!(slugify("+1 Armor"), "plus1-armor"); + } + + #[test] + fn dashes_before_a_mid_string_plus() { + assert_eq!(slugify("Ammunition, +1"), "ammunition-plus1"); + } + + #[test] + fn has_no_leading_or_trailing_dash() { + assert_eq!(slugify(" Wish "), "wish"); + } + + #[test] + fn substitutes_plus_in_place_without_forcing_a_word_boundary() { + assert_eq!(slugify("Boots+Cloak"), "bootspluscloak"); + } +} diff --git a/src/srd/verify/spell.rs b/src/srd/verify/spell.rs new file mode 100644 index 0000000..a2d1684 --- /dev/null +++ b/src/srd/verify/spell.rs @@ -0,0 +1,283 @@ +//! The spell frontmatter schema, beyond the universal keys. + +use serde_yaml_ng::Mapping; + +use super::Failure; +use super::layer::CorpusFile; +use super::values; + +const SCHOOLS: [&str; 8] = [ + "abjuration", + "conjuration", + "divination", + "enchantment", + "evocation", + "illusion", + "necromancy", + "transmutation", +]; + +/// A spell's mechanical frontmatter, validated. `level`, `school`, and +/// `classes` are validated here (range, enum, lowercase) but not read +/// again afterward; the field-line drift check only re-checks the fields +/// that duplicate a bold line in the body (`casting_time`, `range`, +/// `components`, `duration`). +#[allow(dead_code)] +pub struct SpellFields { + pub level: i64, + pub school: String, + pub classes: Vec, + pub casting_time: String, + pub range: String, + pub components: String, + pub duration: String, +} + +/// Validates the spell-specific fields, appending any problems to +/// `failures`. Returns the validated fields only when every one of them +/// is usable. +pub fn validate(file: &CorpusFile, failures: &mut Vec) -> Option { + let mapping = file.frontmatter.as_mapping()?; + + let level = check_level(file, mapping, failures); + let school = check_school(file, mapping, failures); + let classes = check_classes(file, mapping, failures); + let casting_time = check_required(file, mapping, "casting_time", failures); + let range = check_required(file, mapping, "range", failures); + let components = check_required(file, mapping, "components", failures); + let duration = check_required(file, mapping, "duration", failures); + + Some(SpellFields { + level: level?, + school: school?, + classes: classes?, + casting_time: casting_time?, + range: range?, + components: components?, + duration: duration?, + }) +} + +fn check_level(file: &CorpusFile, mapping: &Mapping, failures: &mut Vec) -> Option { + match values::integer(mapping, "level") { + Ok(level) if (0..=9).contains(&level) => Some(level), + Ok(level) => { + failures.push(Failure::new( + &file.path, + format!("'level' must be between 0 and 9, got {level}"), + )); + None + } + Err(message) => { + failures.push(Failure::new(&file.path, message)); + None + } + } +} + +fn check_school( + file: &CorpusFile, + mapping: &Mapping, + failures: &mut Vec, +) -> Option { + match values::nonempty_string(mapping, "school") { + Ok(school) if SCHOOLS.contains(&school.as_str()) => Some(school), + Ok(school) => { + failures.push(Failure::new( + &file.path, + format!("'school' must be one of {SCHOOLS:?}, got '{school}'"), + )); + None + } + Err(message) => { + failures.push(Failure::new(&file.path, message)); + None + } + } +} + +fn check_classes( + file: &CorpusFile, + mapping: &Mapping, + failures: &mut Vec, +) -> Option> { + match values::nonempty_string_list(mapping, "classes") { + Ok(classes) => { + let mut ok = true; + for class in &classes { + if class.to_lowercase() != *class { + failures.push(Failure::new( + &file.path, + format!("'classes' entry '{class}' must be lowercase"), + )); + ok = false; + } + } + ok.then_some(classes) + } + Err(message) => { + failures.push(Failure::new(&file.path, message)); + None + } + } +} + +fn check_required( + file: &CorpusFile, + mapping: &Mapping, + key: &str, + failures: &mut Vec, +) -> Option { + match values::nonempty_string(mapping, key) { + Ok(value) => Some(value), + Err(message) => { + failures.push(Failure::new(&file.path, message)); + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::srd::verify::kind::Kind; + use std::path::PathBuf; + + fn spell_file(frontmatter_yaml: &str) -> CorpusFile { + CorpusFile { + path: PathBuf::from("spells/fireball.md"), + kind: Kind::Spell, + frontmatter: serde_yaml_ng::from_str(frontmatter_yaml).unwrap(), + body: String::new(), + } + } + + fn valid_yaml() -> &'static str { + "level: 3\n\ + school: evocation\n\ + classes: [sorcerer, wizard]\n\ + casting_time: Action\n\ + range: 150 feet\n\ + components: V, S, M\n\ + duration: Instantaneous" + } + + #[test] + fn validate_accepts_well_formed_fields() { + let file = spell_file(valid_yaml()); + let mut failures = Vec::new(); + + let fields = validate(&file, &mut failures).unwrap(); + + assert_eq!(failures, vec![]); + assert_eq!(fields.level, 3); + assert_eq!(fields.school, "evocation"); + } + + #[test] + fn validate_rejects_a_level_out_of_range() { + let file = spell_file( + "level: 10\nschool: evocation\nclasses: [wizard]\ncasting_time: Action\nrange: Self\ncomponents: V\nduration: Instantaneous", + ); + let mut failures = Vec::new(); + + validate(&file, &mut failures); + + assert!( + failures + .iter() + .any(|f| f.message.contains("must be between 0 and 9")) + ); + } + + #[test] + fn validate_rejects_a_bad_school() { + let file = spell_file( + "level: 1\nschool: astrology\nclasses: [wizard]\ncasting_time: Action\nrange: Self\ncomponents: V\nduration: Instantaneous", + ); + let mut failures = Vec::new(); + + validate(&file, &mut failures); + + assert!( + failures + .iter() + .any(|f| f.message.contains("must be one of")) + ); + } + + #[test] + fn validate_rejects_an_uppercase_class() { + let file = spell_file( + "level: 1\nschool: evocation\nclasses: [Wizard]\ncasting_time: Action\nrange: Self\ncomponents: V\nduration: Instantaneous", + ); + let mut failures = Vec::new(); + + validate(&file, &mut failures); + + assert!( + failures + .iter() + .any(|f| f.message.contains("must be lowercase")) + ); + } + + #[test] + fn validate_reports_every_missing_required_field() { + let file = spell_file("level: 1\nschool: evocation\nclasses: [wizard]"); + let mut failures = Vec::new(); + + let fields = validate(&file, &mut failures); + + assert!(fields.is_none()); + assert!(failures.iter().any(|f| f.message.contains("casting_time"))); + assert!(failures.iter().any(|f| f.message.contains("range"))); + assert!(failures.iter().any(|f| f.message.contains("components"))); + assert!(failures.iter().any(|f| f.message.contains("duration"))); + } + + #[test] + fn validate_rejects_a_missing_level() { + let file = spell_file( + "school: evocation\nclasses: [wizard]\ncasting_time: Action\nrange: Self\ncomponents: V\nduration: Instantaneous", + ); + let mut failures = Vec::new(); + + validate(&file, &mut failures); + + assert!(failures.iter().any(|f| f.message.contains("'level'"))); + } + + #[test] + fn validate_rejects_a_missing_school() { + let file = spell_file( + "level: 1\nclasses: [wizard]\ncasting_time: Action\nrange: Self\ncomponents: V\nduration: Instantaneous", + ); + let mut failures = Vec::new(); + + validate(&file, &mut failures); + + assert!(failures.iter().any(|f| f.message.contains("'school'"))); + } + + #[test] + fn validate_rejects_missing_classes() { + let file = spell_file( + "level: 1\nschool: evocation\ncasting_time: Action\nrange: Self\ncomponents: V\nduration: Instantaneous", + ); + let mut failures = Vec::new(); + + validate(&file, &mut failures); + + assert!(failures.iter().any(|f| f.message.contains("'classes'"))); + } + + #[test] + fn validate_returns_none_for_non_mapping_frontmatter() { + let file = spell_file("just a string"); + let mut failures = Vec::new(); + + assert!(validate(&file, &mut failures).is_none()); + assert_eq!(failures, vec![]); + } +} diff --git a/src/srd/verify/values.rs b/src/srd/verify/values.rs new file mode 100644 index 0000000..ce4009d --- /dev/null +++ b/src/srd/verify/values.rs @@ -0,0 +1,229 @@ +//! Typed field access over a frontmatter YAML mapping, returning a plain +//! error message a caller can attach a file path to. + +use serde_yaml_ng::{Mapping, Value}; + +pub fn string(map: &Mapping, key: &str) -> Result { + match map.get(key) { + Some(Value::String(value)) => Ok(value.clone()), + Some(_) => Err(format!("'{key}' must be a string")), + None => Err(format!("missing required field '{key}'")), + } +} + +pub fn nonempty_string(map: &Mapping, key: &str) -> Result { + let value = string(map, key)?; + if value.trim().is_empty() { + Err(format!("'{key}' must not be empty")) + } else { + Ok(value) + } +} + +pub fn optional_nonempty_string(map: &Mapping, key: &str) -> Result, String> { + match map.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(value)) if !value.trim().is_empty() => Ok(Some(value.clone())), + Some(Value::String(_)) => Err(format!("'{key}' must not be empty")), + Some(_) => Err(format!("'{key}' must be a string")), + } +} + +pub fn boolean(map: &Mapping, key: &str) -> Result { + match map.get(key) { + Some(Value::Bool(value)) => Ok(*value), + Some(_) => Err(format!("'{key}' must be a boolean")), + None => Err(format!("missing required field '{key}'")), + } +} + +pub fn integer(map: &Mapping, key: &str) -> Result { + match map.get(key) { + Some(Value::Number(value)) if value.is_i64() => Ok(value.as_i64().unwrap()), + Some(_) => Err(format!("'{key}' must be an integer")), + None => Err(format!("missing required field '{key}'")), + } +} + +pub fn nonempty_string_list(map: &Mapping, key: &str) -> Result, String> { + match map.get(key) { + Some(Value::Sequence(items)) if items.is_empty() => { + Err(format!("'{key}' must not be empty")) + } + Some(Value::Sequence(items)) => items + .iter() + .map(|item| match item { + Value::String(value) if !value.trim().is_empty() => Ok(value.clone()), + _ => Err(format!("'{key}' items must be non-empty strings")), + }) + .collect(), + Some(_) => Err(format!("'{key}' must be a list")), + None => Err(format!("missing required field '{key}'")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mapping(yaml: &str) -> Mapping { + serde_yaml_ng::from_str(yaml).unwrap() + } + + #[test] + fn string_reads_a_present_field() { + assert_eq!( + string(&mapping("name: Fireball"), "name"), + Ok("Fireball".to_string()) + ); + } + + #[test] + fn string_rejects_a_missing_field() { + assert_eq!( + string(&mapping("name: Fireball"), "type"), + Err("missing required field 'type'".to_string()) + ); + } + + #[test] + fn string_rejects_a_non_string_value() { + assert_eq!( + string(&mapping("name: 5"), "name"), + Err("'name' must be a string".to_string()) + ); + } + + #[test] + fn nonempty_string_rejects_a_blank_value() { + assert_eq!( + nonempty_string(&mapping("name: ' '"), "name"), + Err("'name' must not be empty".to_string()) + ); + } + + #[test] + fn optional_nonempty_string_returns_none_when_absent() { + assert_eq!( + optional_nonempty_string(&mapping("name: Fireball"), "note"), + Ok(None) + ); + } + + #[test] + fn optional_nonempty_string_returns_none_when_explicitly_null() { + assert_eq!( + optional_nonempty_string(&mapping("note: null"), "note"), + Ok(None) + ); + } + + #[test] + fn optional_nonempty_string_returns_some_when_present() { + assert_eq!( + optional_nonempty_string(&mapping("note: by a Bard"), "note"), + Ok(Some("by a Bard".to_string())) + ); + } + + #[test] + fn optional_nonempty_string_rejects_a_blank_value() { + assert_eq!( + optional_nonempty_string(&mapping("note: ' '"), "note"), + Err("'note' must not be empty".to_string()) + ); + } + + #[test] + fn optional_nonempty_string_rejects_a_non_string_value() { + assert_eq!( + optional_nonempty_string(&mapping("note: 5"), "note"), + Err("'note' must be a string".to_string()) + ); + } + + #[test] + fn boolean_reads_a_present_field() { + assert_eq!( + boolean(&mapping("attunement: true"), "attunement"), + Ok(true) + ); + } + + #[test] + fn boolean_rejects_a_missing_field() { + assert_eq!( + boolean(&mapping("name: Fireball"), "attunement"), + Err("missing required field 'attunement'".to_string()) + ); + } + + #[test] + fn boolean_rejects_a_non_boolean_value() { + assert_eq!( + boolean(&mapping("attunement: yes please"), "attunement"), + Err("'attunement' must be a boolean".to_string()) + ); + } + + #[test] + fn integer_reads_a_present_field() { + assert_eq!(integer(&mapping("level: 3"), "level"), Ok(3)); + } + + #[test] + fn integer_rejects_a_missing_field() { + assert_eq!( + integer(&mapping("name: Fireball"), "level"), + Err("missing required field 'level'".to_string()) + ); + } + + #[test] + fn integer_rejects_a_non_integer_value() { + assert_eq!( + integer(&mapping("level: three"), "level"), + Err("'level' must be an integer".to_string()) + ); + } + + #[test] + fn nonempty_string_list_reads_a_present_field() { + assert_eq!( + nonempty_string_list(&mapping("classes: [wizard, sorcerer]"), "classes"), + Ok(vec!["wizard".to_string(), "sorcerer".to_string()]) + ); + } + + #[test] + fn nonempty_string_list_rejects_a_missing_field() { + assert_eq!( + nonempty_string_list(&mapping("name: Fireball"), "classes"), + Err("missing required field 'classes'".to_string()) + ); + } + + #[test] + fn nonempty_string_list_rejects_an_empty_list() { + assert_eq!( + nonempty_string_list(&mapping("classes: []"), "classes"), + Err("'classes' must not be empty".to_string()) + ); + } + + #[test] + fn nonempty_string_list_rejects_a_non_list_value() { + assert_eq!( + nonempty_string_list(&mapping("classes: wizard"), "classes"), + Err("'classes' must be a list".to_string()) + ); + } + + #[test] + fn nonempty_string_list_rejects_a_non_string_item() { + assert_eq!( + nonempty_string_list(&mapping("classes: [wizard, 5]"), "classes"), + Err("'classes' items must be non-empty strings".to_string()) + ); + } +} -- 2.51.2