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()) + ); + } +}