diff --git a/src/srd/verify/coverage.rs b/src/srd/verify/coverage.rs new file mode 100644 index 0000000..ef9a76d --- /dev/null +++ b/src/srd/verify/coverage.rs @@ -0,0 +1,271 @@ +//! Checks that every vendored file the corpus should draw from is used +//! exactly once, and that each kind has enough entries to be a real +//! corpus rather than a stub. + +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use super::Failure; +use super::kind::Kind; + +const VENDORED_ROOT: &str = "sources/dnd.srd.5.2.1"; + +/// The minimum number of entries each kind's directory must hold, so an +/// empty or half-built corpus fails loudly instead of quietly passing. +fn floor(kind: Kind) -> usize { + match kind { + Kind::Spell => 300, + Kind::Monster => 200, + Kind::MagicItem => 200, + Kind::Class => 12, + Kind::Feat => 15, + Kind::Core => 5, + } +} + +/// Walks `layer_root`'s vendored source tree and checks its claims: +/// every file under a `*_Each/` directory must be claimed by exactly one +/// corpus source, and no other vendored file may be claimed more than +/// once. `claims` is every corpus file's `source` field, layer-relative. +pub fn check_claims(layer_root: &Path, claims: &[PathBuf]) -> Vec { + let mut claim_counts: HashMap = HashMap::new(); + for claim in claims { + *claim_counts.entry(claim.clone()).or_insert(0) += 1; + } + + let mut failures = Vec::new(); + let vendored_root = layer_root.join(VENDORED_ROOT); + for vendored_path in walk_files(&vendored_root) { + let layer_relative = PathBuf::from(VENDORED_ROOT).join( + vendored_path + .strip_prefix(&vendored_root) + .unwrap_or(&vendored_path), + ); + let count = claim_counts.get(&layer_relative).copied().unwrap_or(0); + if is_under_each_dir(&vendored_path, &vendored_root) { + match count { + 1 => {} + 0 => failures.push(Failure::new( + &layer_relative, + "vendored file is not claimed by any corpus entry", + )), + n => failures.push(Failure::new( + &layer_relative, + format!( + "vendored file is claimed by {n} corpus entries, exactly one is allowed" + ), + )), + } + } else if count > 1 { + failures.push(Failure::new( + &layer_relative, + format!( + "vendored file is claimed by {count} corpus entries, at most one is allowed" + ), + )); + } + } + failures.sort_by(|a, b| a.path.cmp(&b.path)); + failures +} + +/// Reports whether `path` (inside `vendored_root`) has an ancestor +/// directory, up to `vendored_root`, whose name ends in `_Each`. +fn is_under_each_dir(path: &Path, vendored_root: &Path) -> bool { + path.strip_prefix(vendored_root) + .into_iter() + .flat_map(|relative| relative.parent()) + .flat_map(|parent| parent.components()) + .any(|component| { + component + .as_os_str() + .to_str() + .is_some_and(|name| name.ends_with("_Each")) + }) +} + +fn walk_files(dir: &Path) -> Vec { + let mut files = Vec::new(); + let Ok(entries) = fs::read_dir(dir) else { + return files; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + files.extend(walk_files(&path)); + } else { + files.push(path); + } + } + files +} + +/// Checks every kind's entry count against its floor, appending one +/// failure per kind that falls short. +pub fn check_counts(counts: &HashMap, failures: &mut Vec) { + for kind in Kind::ALL { + let count = counts.get(&kind).copied().unwrap_or(0); + let required = floor(kind); + let meets_floor = if kind == Kind::Class { + count == required + } else { + count >= required + }; + if !meets_floor { + let comparison = if kind == Kind::Class { + "exactly" + } else { + "at least" + }; + failures.push(Failure::new( + kind.dir_name(), + format!("has {count} entries, needs {comparison} {required}"), + )); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::srd::verify::fixtures::{unique_temp_dir, write_file}; + + fn each_file(layer_root: &Path, relative: &str) -> PathBuf { + let path = layer_root.join(VENDORED_ROOT).join(relative); + write_file(&path, "content"); + PathBuf::from(VENDORED_ROOT).join(relative) + } + + #[test] + fn check_claims_passes_when_every_each_file_is_claimed_once() { + let layer_root = unique_temp_dir(); + let source = each_file(&layer_root, "07_Spells/Spells_Each/Fireball.md"); + + let failures = check_claims(&layer_root, &[source]); + + assert_eq!(failures, vec![]); + } + + #[test] + fn check_claims_reports_an_unclaimed_each_file() { + let layer_root = unique_temp_dir(); + each_file(&layer_root, "07_Spells/Spells_Each/Fireball.md"); + + let failures = check_claims(&layer_root, &[]); + + assert_eq!(failures.len(), 1); + assert!(failures[0].message.contains("not claimed")); + } + + #[test] + fn check_claims_reports_a_double_claimed_each_file() { + let layer_root = unique_temp_dir(); + let source = each_file(&layer_root, "07_Spells/Spells_Each/Fireball.md"); + + let failures = check_claims(&layer_root, &[source.clone(), source]); + + assert_eq!(failures.len(), 1); + assert!(failures[0].message.contains("2 corpus entries")); + assert!(failures[0].message.contains("exactly one")); + } + + #[test] + fn check_claims_sorts_multiple_failures_by_path() { + let layer_root = unique_temp_dir(); + each_file(&layer_root, "07_Spells/Spells_Each/Wish.md"); + each_file(&layer_root, "07_Spells/Spells_Each/Fireball.md"); + + let failures = check_claims(&layer_root, &[]); + + let paths: Vec<_> = failures.iter().map(|f| f.path.clone()).collect(); + let mut sorted_paths = paths.clone(); + sorted_paths.sort(); + assert_eq!(paths, sorted_paths); + assert_eq!(failures.len(), 2); + } + + #[test] + fn check_claims_tolerates_a_missing_vendored_tree() { + let layer_root = unique_temp_dir(); + + let failures = check_claims(&layer_root, &[]); + + assert_eq!(failures, vec![]); + } + + #[test] + fn check_claims_allows_an_unclaimed_non_each_file() { + let layer_root = unique_temp_dir(); + write_file(&layer_root.join(VENDORED_ROOT).join("Legal.md"), "content"); + + let failures = check_claims(&layer_root, &[]); + + assert_eq!(failures, vec![]); + } + + #[test] + fn check_claims_allows_a_non_each_file_claimed_once() { + let layer_root = unique_temp_dir(); + write_file(&layer_root.join(VENDORED_ROOT).join("Legal.md"), "content"); + let source = PathBuf::from(VENDORED_ROOT).join("Legal.md"); + + let failures = check_claims(&layer_root, &[source]); + + assert_eq!(failures, vec![]); + } + + #[test] + fn check_claims_rejects_a_non_each_file_claimed_twice() { + let layer_root = unique_temp_dir(); + write_file(&layer_root.join(VENDORED_ROOT).join("Legal.md"), "content"); + let source = PathBuf::from(VENDORED_ROOT).join("Legal.md"); + + let failures = check_claims(&layer_root, &[source.clone(), source]); + + assert_eq!(failures.len(), 1); + assert!(failures[0].message.contains("at most one")); + } + + #[test] + fn check_counts_passes_when_every_floor_is_met() { + let counts = HashMap::from([ + (Kind::Spell, 300), + (Kind::Monster, 200), + (Kind::MagicItem, 200), + (Kind::Class, 12), + (Kind::Feat, 15), + (Kind::Core, 5), + ]); + let mut failures = Vec::new(); + + check_counts(&counts, &mut failures); + + assert_eq!(failures, vec![]); + } + + #[test] + fn check_counts_reports_a_floor_breach() { + let counts = HashMap::from([(Kind::Spell, 2)]); + let mut failures = Vec::new(); + + check_counts(&counts, &mut failures); + + assert!(failures.iter().any(|f| f.path == Path::new("spells") + && f.message.contains("has 2 entries, needs at least 300"))); + } + + #[test] + fn check_counts_requires_exactly_twelve_classes() { + let counts = HashMap::from([(Kind::Class, 13)]); + let mut failures = Vec::new(); + + check_counts(&counts, &mut failures); + + assert!( + failures + .iter() + .any(|f| f.message.contains("needs exactly 12")) + ); + } +} diff --git a/src/srd/verify/fixtures.rs b/src/srd/verify/fixtures.rs index d5d47c2..f83ef86 100644 --- a/src/srd/verify/fixtures.rs +++ b/src/srd/verify/fixtures.rs @@ -1,5 +1,6 @@ -//! Shared test fixtures for the verify module: a fresh temp directory and -//! a small file-writing helper for synthetic corpus layers. +//! Shared test fixtures for the verify module: a fresh temp directory, a +//! small file-writing helper, and a builder for a small synthetic corpus +//! layer with matching fake vendored sources. use std::fs; use std::path::{Path, PathBuf}; @@ -25,3 +26,133 @@ pub fn write_file(path: &Path, contents: &str) { fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write(path, contents).unwrap(); } + +const ATTRIBUTION: &str = "This work includes material from the System Reference Document 5.2.1 (\"SRD 5.2.1\") by Wizards of the Coast LLC, available at https://www.dndbeyond.com/srd."; +const SOURCE_REPO_URL: &str = "https://github.com/oldmanumby/dnd.srd.5.2.1"; + +pub const FIREBALL_SOURCE: &str = "sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md"; +pub const GOBLIN_SOURCE: &str = "sources/dnd.srd.5.2.1/11_Monsters/Monsters_Each/Goblin_Warrior.md"; +pub const AMULET_SOURCE: &str = + "sources/dnd.srd.5.2.1/10_Magic_Items/Magic_Items_Each/Amulet_of_Health.md"; +pub const BARD_SOURCE: &str = + "sources/dnd.srd.5.2.1/03_Character_Classes/Character_Classes_Each/Bard.md"; +pub const ALERT_SOURCE: &str = "sources/dnd.srd.5.2.1/05_Feats/Feats_Each/Alert.md"; +pub const CORE_SOURCE: &str = "sources/dnd.srd.5.2.1/01_Playing_The_Game/Playing_The_Game.md"; + +const FIREBALL_BODY: &str = "# Fireball\n\n*Level 3 Evocation (Sorcerer, Wizard)*\n\n**Casting Time:** Action\n\n**Range:** 150 feet\n\n**Components:** V, S, M\n\n**Duration:** Instantaneous\n\nA bright streak flashes from you.\n"; + +const GOBLIN_SOURCE_TEXT: &str = "# Goblin Warrior\n\n*Small Fey (Goblinoid), Chaotic Neutral*\n\n**AC** 15\n\n**HP** 10 (3d6)\n\n**Speed** 30 ft.\n\n| | MOD | SAVE | | MOD | SAVE | | MOD | SAVE |\n| :- | :- | :- | :- | :- | :- | :- | :- | :- |\n| **Str 8** | -1 | -1 | **Dex 15** | +2 | +2 | **Con 10** | +0 | +0 |\n| **Int 10** | +0 | +0 | **Wis 8** | -1 | -1 | **Cha 8** | -1 | -1 |\n\n**CR** 1/4\n\n## Actions\n\n***Scimitar.*** *Melee Attack Roll:* +4, reach 5 ft.\n"; + +const AMULET_SOURCE_TEXT: &str = "# Amulet of Health\n\n*Wondrous Item, Rare (Requires Attunement)*\n\nYour Constitution is 19 while you wear this amulet.\n"; +const AMULET_FIELD_LINES: &str = + "\n**Category:** Wondrous Item\n\n**Rarity:** Rare\n\n**Attunement:** Requires Attunement\n"; + +const BARD_BODY: &str = "# Bard\n\nBards are charming performers.\n"; +const ALERT_BODY: &str = "# Alert\n\nYou gain the following benefits.\n"; +const CORE_BODY: &str = "# Playing the Game\n\nThe three pillars of play are exploration, social interaction, and combat.\n"; + +/// A small valid corpus layer: one entry per kind, its matching fake +/// vendored source, and a README and meta.yaml that satisfy the +/// attribution and pin checks. Every kind's entry count is under its real +/// floor (the corpus is tiny by design), so `verify()` on an unmodified +/// layer reports one count-floor failure per kind and nothing else; +/// tests that care about a different, single failure should filter those +/// out rather than assert an empty failure list. +pub struct Layer { + pub root: PathBuf, +} + +impl Layer { + pub fn valid() -> Self { + let layer = Self { + root: unique_temp_dir(), + }; + + layer.write(FIREBALL_SOURCE, FIREBALL_BODY); + layer.write( + "spells/fireball.md", + &format!( + "---\nname: Fireball\ntype: spell\nsource: {FIREBALL_SOURCE}\nlevel: 3\nschool: evocation\nclasses: [sorcerer, wizard]\ncasting_time: Action\nrange: 150 feet\ncomponents: V, S, M\nduration: Instantaneous\n---\n{FIREBALL_BODY}" + ), + ); + + layer.write(GOBLIN_SOURCE, GOBLIN_SOURCE_TEXT); + let goblin_body = super::stat_block::source_outside_region(GOBLIN_SOURCE_TEXT); + layer.write( + "monsters/goblin-warrior.md", + &format!( + "---\nname: Goblin Warrior\ntype: monster\nsource: {GOBLIN_SOURCE}\nsize: Small\ncreature_type: Fey (Goblinoid)\nalignment: Chaotic Neutral\nac: '15'\nhp: 10 (3d6)\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---\n{goblin_body}" + ), + ); + + layer.write(AMULET_SOURCE, AMULET_SOURCE_TEXT); + layer.write( + "magic-items/amulet-of-health.md", + &format!( + "---\nname: Amulet of Health\ntype: magic-item\nsource: {AMULET_SOURCE}\ncategory: Wondrous Item\nrarity: rare\nattunement: true\n---\n{AMULET_SOURCE_TEXT}{AMULET_FIELD_LINES}" + ), + ); + + layer.write(BARD_SOURCE, BARD_BODY); + layer.write( + "classes/bard.md", + &format!("---\nname: Bard\ntype: class\nsource: {BARD_SOURCE}\n---\n{BARD_BODY}"), + ); + + layer.write(ALERT_SOURCE, ALERT_BODY); + layer.write( + "feats/alert.md", + &format!("---\nname: Alert\ntype: feat\nsource: {ALERT_SOURCE}\n---\n{ALERT_BODY}"), + ); + + layer.write(CORE_SOURCE, CORE_BODY); + layer.write( + "core/playing-the-game.md", + &format!( + "---\nname: Playing the Game\ntype: core\nsource: {CORE_SOURCE}\n---\n{CORE_BODY}" + ), + ); + + layer.write( + "sources/dnd.srd.5.2.1/Legal.md", + &format!("# Legal Information\n\n{ATTRIBUTION}\n\nOther legal text.\n"), + ); + layer.write( + "README.md", + &format!("# SRD 5.2\n\n{ATTRIBUTION}\n\nVendored from {SOURCE_REPO_URL}.\n"), + ); + layer.write( + "meta.yaml", + "version: 5.2.1\nsources:\n - path: sources/SRD_CC_v5.2.1.pdf\n sha256: abc\n - path: sources/dnd.srd.5.2.1/\n commit: deadbeef\n", + ); + + layer + } + + pub fn path(&self, relative: &str) -> PathBuf { + self.root.join(relative) + } + + pub fn write(&self, relative: &str, contents: &str) { + write_file(&self.path(relative), contents); + } + + pub fn read(&self, relative: &str) -> String { + fs::read_to_string(self.path(relative)).unwrap() + } + + pub fn remove(&self, relative: &str) { + fs::remove_file(self.path(relative)).unwrap(); + } + + /// Replaces the first occurrence of `from` with `to` in the file at + /// `relative`, for tests that mutate one known detail of a fixture. + pub fn replace(&self, relative: &str, from: &str, to: &str) { + let contents = self.read(relative); + assert!( + contents.contains(from), + "fixture {relative} does not contain {from:?} to replace" + ); + self.write(relative, &contents.replacen(from, to, 1)); + } +} diff --git a/src/srd/verify/integration.rs b/src/srd/verify/integration.rs new file mode 100644 index 0000000..dfadf4b --- /dev/null +++ b/src/srd/verify/integration.rs @@ -0,0 +1,229 @@ +//! End-to-end tests of `verify()` against the synthetic fixture layer: +//! one test per kind of mistake the brief calls out, each applied as a +//! single, targeted mutation to an otherwise-valid layer. +//! +//! The fixture layer is intentionally tiny (one entry per kind), so it +//! never meets the real count floors (300 spells, and so on); every test +//! here sees those floor-breach failures alongside whatever the test +//! itself is checking. `extra_failures` filters them out so each test can +//! assert on the failure it actually cares about. + +#![cfg(test)] + +use super::fixtures::{FIREBALL_SOURCE, Layer}; +use super::{Failure, verify}; + +fn extra_failures(layer: &Layer) -> Vec { + verify(&layer.root) + .failures + .into_iter() + .filter(|failure| { + !failure.message.contains("needs at least") + && !failure.message.contains("needs exactly") + }) + .collect() +} + +#[test] +fn a_valid_layer_only_reports_count_floor_breaches() { + let layer = Layer::valid(); + + let report = verify(&layer.root); + + assert_eq!(extra_failures(&layer), vec![]); + assert_eq!(report.failures.len(), 6); + assert!(report.failures.iter().all(|f| f.message.contains("needs"))); +} + +#[test] +fn a_dropped_body_word_is_reported() { + let layer = Layer::valid(); + layer.replace( + "spells/fireball.md", + "A bright streak flashes from you.\n", + "A bright flashes from you.\n", + ); + + let failures = extra_failures(&layer); + + assert_eq!(failures.len(), 1); + assert!(failures[0].message.contains("streak")); +} + +#[test] +fn an_added_body_word_is_reported() { + let layer = Layer::valid(); + layer.replace( + "spells/fireball.md", + "A bright streak flashes", + "A bright red streak flashes", + ); + + let failures = extra_failures(&layer); + + assert_eq!(failures.len(), 1); + assert!(failures[0].message.contains("red")); +} + +#[test] +fn a_changed_body_number_is_reported() { + let layer = Layer::valid(); + layer.replace( + "spells/fireball.md", + "**Range:** 150 feet", + "**Range:** 200 feet", + ); + + let failures = extra_failures(&layer); + + assert!( + failures + .iter() + .any(|f| f.message.contains("150") && f.message.contains("200")) + ); +} + +#[test] +fn a_bad_school_is_reported() { + let layer = Layer::valid(); + layer.replace( + "spells/fireball.md", + "school: evocation", + "school: astrology", + ); + + let failures = extra_failures(&layer); + + assert_eq!(failures.len(), 1); + assert!(failures[0].message.contains("school")); +} + +#[test] +fn an_unknown_frontmatter_key_is_reported() { + let layer = Layer::valid(); + layer.replace( + "spells/fireball.md", + "level: 3", + "level: 3\ncomponent_pouch: true", + ); + + let failures = extra_failures(&layer); + + assert_eq!(failures.len(), 1); + assert!( + failures[0] + .message + .contains("unknown frontmatter key 'component_pouch'") + ); +} + +#[test] +fn a_slug_mismatch_is_reported() { + let layer = Layer::valid(); + let contents = layer.read("spells/fireball.md"); + layer.remove("spells/fireball.md"); + layer.write("spells/not-fireball.md", &contents); + + let failures = extra_failures(&layer); + + assert_eq!(failures.len(), 1); + assert!(failures[0].message.contains("does not match the slug")); +} + +#[test] +fn a_slug_collision_is_reported() { + let layer = Layer::valid(); + let light_feat_source = "sources/dnd.srd.5.2.1/05_Feats/Feats_Each/Light.md"; + let light_core_source = "sources/dnd.srd.5.2.1/01_Playing_The_Game/Light.md"; + layer.write(light_feat_source, "# Light\n\nA cheap source of light.\n"); + layer.write( + "feats/light.md", + &format!("---\nname: Light\ntype: feat\nsource: {light_feat_source}\n---\n# Light\n\nA cheap source of light.\n"), + ); + layer.write( + light_core_source, + "# Light\n\nRules for light and vision.\n", + ); + layer.write( + "core/light.md", + &format!("---\nname: Light\ntype: core\nsource: {light_core_source}\n---\n# Light\n\nRules for light and vision.\n"), + ); + + let failures = extra_failures(&layer); + + assert_eq!(failures.len(), 2); + assert!(failures.iter().all(|f| f.message.contains("collides with"))); +} + +#[test] +fn a_type_directory_mismatch_is_reported() { + let layer = Layer::valid(); + layer.replace("spells/fireball.md", "type: spell", "type: monster"); + + let failures = extra_failures(&layer); + + assert_eq!(failures.len(), 1); + assert!(failures[0].message.contains("does not match its directory")); +} + +#[test] +fn a_missing_source_is_reported() { + let layer = Layer::valid(); + layer.remove(FIREBALL_SOURCE); + + let failures = extra_failures(&layer); + + assert_eq!(failures.len(), 1); + assert!(failures[0].message.contains("does not exist")); +} + +#[test] +fn a_double_claimed_each_file_is_reported() { + let layer = Layer::valid(); + let goblin = layer.read("monsters/goblin-warrior.md"); + layer.write( + "monsters/goblin-warrior-2.md", + &goblin.replacen("Goblin Warrior", "Goblin Warrior 2", 1), + ); + + let failures = extra_failures(&layer); + + assert!( + failures + .iter() + .any(|f| f.message.contains("2 corpus entries") && f.message.contains("exactly one")) + ); +} + +#[test] +fn a_frontmatter_body_field_drift_is_reported() { + let layer = Layer::valid(); + layer.replace( + "spells/fireball.md", + "casting_time: Action", + "casting_time: Bonus Action", + ); + + let failures = extra_failures(&layer); + + assert_eq!(failures.len(), 1); + assert!(failures[0].message.contains("Bonus Action")); +} + +#[test] +fn a_stat_region_word_missing_from_frontmatter_is_reported() { + let layer = Layer::valid(); + layer.replace( + "monsters/goblin-warrior.md", + "speed: 30 ft.", + "speed: 35 ft.", + ); + + let failures = extra_failures(&layer); + + assert!( + failures + .iter() + .any(|f| f.message.contains("stat block has words")) + ); +} diff --git a/src/srd/verify/mod.rs b/src/srd/verify/mod.rs index 1b4340f..d55364d 100644 --- a/src/srd/verify/mod.rs +++ b/src/srd/verify/mod.rs @@ -3,24 +3,28 @@ //! corpus format contract: whatever it accepts is legal, whatever it //! rejects is not. //! -//! This adds the fidelity layer to the schema layer: every corpus file's -//! body matches its vendored source as a normalized word stream (or, for -//! monsters, matches outside the stat-block region, with the region -//! checked against frontmatter instead), and spells and magic items keep -//! their frontmatter and body field lines from drifting apart. Coverage -//! of the vendored tree and corpus shape (entry-count floors, README, -//! meta.yaml) are a separate layer built on top of this one. - +//! This adds the coverage and shape layer to the schema and fidelity +//! layers: every vendored file under a `*_Each/` directory is claimed by +//! exactly one corpus entry, no other vendored file is claimed more than +//! once, every kind meets its entry-count floor, and the layer root's +//! README.md and meta.yaml carry what CC-BY-4.0 and the pinned sources +//! require. This is the complete verifier: `verify` is what both the CLI +//! and task 6's corpus build run. + +mod coverage; mod diff; mod fidelity; mod field_lines; #[cfg(test)] mod fixtures; +#[cfg(test)] +mod integration; mod kind; mod layer; mod magic_item; mod monster; mod normalize; +mod readme_meta; mod schema; mod slug; mod spell; @@ -80,15 +84,15 @@ impl Report { } } -/// Verifies the corpus layer rooted at `layer_root`'s schema and fidelity: -/// every file parses, its frontmatter matches its kind, its slug is -/// well-formed and collision-free, and its body matches its vendored -/// source. Returns every failure found rather than stopping at the first. +/// Verifies the corpus layer rooted at `layer_root` against every layer of +/// the format contract: schema, fidelity, coverage, and shape. 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(); + let mut claims: Vec = Vec::new(); for file in &files { *counts.entry(file.kind).or_insert(0) += 1; @@ -97,6 +101,7 @@ pub fn verify(layer_root: &Path) -> Report { continue; }; names.push((file.path.clone(), universal.name.clone())); + claims.push(universal.source.clone()); let spell_fields = (file.kind == Kind::Spell) .then(|| spell::validate(file, &mut failures)) @@ -122,6 +127,10 @@ pub fn verify(layer_root: &Path) -> Report { } failures.extend(schema::check_slug_collisions(&names)); + failures.extend(coverage::check_claims(layer_root, &claims)); + coverage::check_counts(&counts, &mut failures); + readme_meta::check_readme(layer_root, &mut failures); + readme_meta::check_meta(layer_root, &mut failures); Report { failures, counts } } @@ -151,13 +160,24 @@ mod tests { } #[test] - fn verify_on_an_empty_layer_finds_nothing() { + fn verify_on_an_empty_layer_finds_no_entries_but_reports_shape_failures() { let layer_root = unique_temp_dir(); let report = verify(&layer_root); - assert_eq!(report.failures, vec![]); assert!(report.counts.is_empty()); + assert!( + report + .failures + .iter() + .any(|f| f.message.contains("needs at least")) + ); + assert!( + report + .failures + .iter() + .any(|f| f.path == Path::new("README.md")) + ); } #[test] @@ -242,7 +262,15 @@ mod tests { let report = verify(&layer_root); - assert_eq!(report.failures, vec![]); + // The layer is otherwise empty, so shape checks (count floors, + // README, meta.yaml) still fail; this only confirms the feat + // entry itself adds no schema or fidelity failure of its own. + assert!( + !report + .failures + .iter() + .any(|f| f.path == Path::new("feats/alert.md")) + ); } #[test] diff --git a/src/srd/verify/readme_meta.rs b/src/srd/verify/readme_meta.rs new file mode 100644 index 0000000..ade69a0 --- /dev/null +++ b/src/srd/verify/readme_meta.rs @@ -0,0 +1,355 @@ +//! Checks the layer root's `README.md` and `meta.yaml`: the attribution +//! CC-BY-4.0 requires, a link back to the vendored source repo, and a +//! `meta.yaml` that actually records what it's pinned to. + +use std::fs; +use std::path::Path; + +use serde_yaml_ng::Value; + +use super::Failure; + +const README_PATH: &str = "README.md"; +const META_PATH: &str = "meta.yaml"; +const LEGAL_PATH: &str = "sources/dnd.srd.5.2.1/Legal.md"; +const ATTRIBUTION_ANCHOR: &str = "This work includes material from the System Reference Document"; +const SOURCE_REPO_URL: &str = "https://github.com/oldmanumby/dnd.srd.5.2.1"; + +/// Checks `README.md` for the exact WotC attribution paragraph (read live +/// from the vendored `Legal.md`, not hardcoded here, since a test layer's +/// `Legal.md` is a fixture rather than the real one) and a link to the +/// vendored source repo. +pub fn check_readme(layer_root: &Path, failures: &mut Vec) { + let readme_path = layer_root.join(README_PATH); + let Ok(readme) = fs::read_to_string(&readme_path) else { + failures.push(Failure::new( + README_PATH, + "does not exist or cannot be read", + )); + return; + }; + + match attribution_paragraph(layer_root) { + Ok(attribution) => { + if !normalize_whitespace(&readme).contains(&normalize_whitespace(&attribution)) { + failures.push(Failure::new( + README_PATH, + "does not contain the WotC attribution paragraph from Legal.md", + )); + } + } + Err(message) => failures.push(Failure::new(README_PATH, message)), + } + + if !readme.contains(SOURCE_REPO_URL) { + failures.push(Failure::new( + README_PATH, + format!("does not link to the vendored source repo ({SOURCE_REPO_URL})"), + )); + } +} + +/// The paragraph in the vendored `Legal.md` that opens with the WotC +/// attribution anchor sentence, blank-line-delimited. +fn attribution_paragraph(layer_root: &Path) -> Result { + let legal_path = layer_root.join(LEGAL_PATH); + let legal = fs::read_to_string(&legal_path) + .map_err(|error| format!("cannot read {LEGAL_PATH}: {error}"))?; + legal + .split("\n\n") + .find(|paragraph| paragraph.trim_start().starts_with(ATTRIBUTION_ANCHOR)) + .map(str::to_string) + .ok_or_else(|| format!("{LEGAL_PATH} has no attribution paragraph to check against")) +} + +fn normalize_whitespace(text: &str) -> String { + text.split_whitespace().collect::>().join(" ") +} + +/// Checks `meta.yaml` parses and records a version and both source pins +/// (the PDF's `sha256` and the markdown source's `commit`). +pub fn check_meta(layer_root: &Path, failures: &mut Vec) { + let meta_path = layer_root.join(META_PATH); + let Ok(contents) = fs::read_to_string(&meta_path) else { + failures.push(Failure::new(META_PATH, "does not exist or cannot be read")); + return; + }; + let value: Value = match serde_yaml_ng::from_str(&contents) { + Ok(value) => value, + Err(error) => { + failures.push(Failure::new(META_PATH, format!("invalid YAML: {error}"))); + return; + } + }; + let Some(mapping) = value.as_mapping() else { + failures.push(Failure::new(META_PATH, "must be a YAML mapping")); + return; + }; + + if !matches!(mapping.get("version"), Some(Value::String(version)) if !version.trim().is_empty()) + { + failures.push(Failure::new(META_PATH, "missing a non-empty 'version'")); + } + + let Some(Value::Sequence(sources)) = mapping.get("sources") else { + failures.push(Failure::new(META_PATH, "missing a 'sources' list")); + return; + }; + let has_pdf_pin = sources + .iter() + .any(|source| source.as_mapping().and_then(|m| m.get("sha256")).is_some()); + let has_text_pin = sources + .iter() + .any(|source| source.as_mapping().and_then(|m| m.get("commit")).is_some()); + if !has_pdf_pin { + failures.push(Failure::new( + META_PATH, + "no source pins the PDF with a 'sha256'", + )); + } + if !has_text_pin { + failures.push(Failure::new( + META_PATH, + "no source pins the markdown text with a 'commit'", + )); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::srd::verify::fixtures::{unique_temp_dir, write_file}; + + const ATTRIBUTION: &str = "This work includes material from the System Reference Document 5.2.1 (\"SRD 5.2.1\") by Wizards of the Coast LLC, available at https://www.dndbeyond.com/srd."; + + fn layer_with_legal(attribution: &str) -> std::path::PathBuf { + let layer_root = unique_temp_dir(); + write_file( + &layer_root.join(LEGAL_PATH), + &format!("# Legal Information\n\n{attribution}\n\nOther text.\n"), + ); + layer_root + } + + #[test] + fn check_readme_passes_with_attribution_and_link() { + let layer_root = layer_with_legal(ATTRIBUTION); + write_file( + &layer_root.join(README_PATH), + &format!("# SRD\n\n{ATTRIBUTION}\n\nSee {SOURCE_REPO_URL}.\n"), + ); + let mut failures = Vec::new(); + + check_readme(&layer_root, &mut failures); + + assert_eq!(failures, vec![]); + } + + #[test] + fn check_readme_tolerates_rewrapped_attribution_whitespace() { + let layer_root = layer_with_legal(ATTRIBUTION); + let rewrapped = ATTRIBUTION.replace(' ', "\n"); + write_file( + &layer_root.join(README_PATH), + &format!("# SRD\n\n{rewrapped}\n\nSee {SOURCE_REPO_URL}.\n"), + ); + let mut failures = Vec::new(); + + check_readme(&layer_root, &mut failures); + + assert_eq!(failures, vec![]); + } + + #[test] + fn check_readme_reports_a_missing_file() { + let layer_root = unique_temp_dir(); + let mut failures = Vec::new(); + + check_readme(&layer_root, &mut failures); + + assert!( + failures + .iter() + .any(|f| f.message.contains("does not exist")) + ); + } + + #[test] + fn check_readme_reports_a_missing_attribution() { + let layer_root = layer_with_legal(ATTRIBUTION); + write_file( + &layer_root.join(README_PATH), + &format!("# SRD\n\nSee {SOURCE_REPO_URL}.\n"), + ); + let mut failures = Vec::new(); + + check_readme(&layer_root, &mut failures); + + assert!( + failures + .iter() + .any(|f| f.message.contains("attribution paragraph")) + ); + } + + #[test] + fn check_readme_reports_a_missing_source_link() { + let layer_root = layer_with_legal(ATTRIBUTION); + write_file( + &layer_root.join(README_PATH), + &format!("# SRD\n\n{ATTRIBUTION}\n"), + ); + let mut failures = Vec::new(); + + check_readme(&layer_root, &mut failures); + + assert!(failures.iter().any(|f| f.message.contains("does not link"))); + } + + #[test] + fn check_readme_reports_an_unreadable_legal_file() { + let layer_root = unique_temp_dir(); + write_file(&layer_root.join(README_PATH), "# SRD\n"); + let mut failures = Vec::new(); + + check_readme(&layer_root, &mut failures); + + assert!(failures.iter().any(|f| f.message.contains("cannot read"))); + } + + #[test] + fn check_readme_reports_a_legal_file_without_an_attribution_paragraph() { + let layer_root = unique_temp_dir(); + write_file( + &layer_root.join(LEGAL_PATH), + "# Legal\n\nNo attribution here.\n", + ); + write_file(&layer_root.join(README_PATH), "# SRD\n"); + let mut failures = Vec::new(); + + check_readme(&layer_root, &mut failures); + + assert!( + failures + .iter() + .any(|f| f.message.contains("no attribution paragraph")) + ); + } + + fn valid_meta_yaml() -> &'static str { + "version: 5.2.1\n\ + sources:\n\ + \x20 - path: sources/SRD_CC_v5.2.1.pdf\n\ + \x20 sha256: abc\n\ + \x20 - path: sources/dnd.srd.5.2.1/\n\ + \x20 commit: deadbeef" + } + + #[test] + fn check_meta_passes_for_a_well_formed_file() { + let layer_root = unique_temp_dir(); + write_file(&layer_root.join(META_PATH), valid_meta_yaml()); + let mut failures = Vec::new(); + + check_meta(&layer_root, &mut failures); + + assert_eq!(failures, vec![]); + } + + #[test] + fn check_meta_reports_a_missing_file() { + let layer_root = unique_temp_dir(); + let mut failures = Vec::new(); + + check_meta(&layer_root, &mut failures); + + assert!( + failures + .iter() + .any(|f| f.message.contains("does not exist")) + ); + } + + #[test] + fn check_meta_reports_invalid_yaml() { + let layer_root = unique_temp_dir(); + write_file(&layer_root.join(META_PATH), "not: [valid"); + let mut failures = Vec::new(); + + check_meta(&layer_root, &mut failures); + + assert!(failures.iter().any(|f| f.message.contains("invalid YAML"))); + } + + #[test] + fn check_meta_reports_a_non_mapping_document() { + let layer_root = unique_temp_dir(); + write_file(&layer_root.join(META_PATH), "just a string"); + let mut failures = Vec::new(); + + check_meta(&layer_root, &mut failures); + + assert!( + failures + .iter() + .any(|f| f.message.contains("must be a YAML mapping")) + ); + } + + #[test] + fn check_meta_reports_a_missing_version() { + let layer_root = unique_temp_dir(); + write_file( + &layer_root.join(META_PATH), + "sources:\n - path: a\n sha256: abc\n - path: b\n commit: deadbeef", + ); + let mut failures = Vec::new(); + + check_meta(&layer_root, &mut failures); + + assert!(failures.iter().any(|f| f.message.contains("version"))); + } + + #[test] + fn check_meta_reports_a_missing_sources_list() { + let layer_root = unique_temp_dir(); + write_file(&layer_root.join(META_PATH), "version: 5.2.1"); + let mut failures = Vec::new(); + + check_meta(&layer_root, &mut failures); + + assert!( + failures + .iter() + .any(|f| f.message.contains("'sources' list")) + ); + } + + #[test] + fn check_meta_reports_a_missing_pdf_pin() { + let layer_root = unique_temp_dir(); + write_file( + &layer_root.join(META_PATH), + "version: 5.2.1\nsources:\n - path: sources/dnd.srd.5.2.1/\n commit: deadbeef", + ); + let mut failures = Vec::new(); + + check_meta(&layer_root, &mut failures); + + assert!(failures.iter().any(|f| f.message.contains("sha256"))); + } + + #[test] + fn check_meta_reports_a_missing_text_pin() { + let layer_root = unique_temp_dir(); + write_file( + &layer_root.join(META_PATH), + "version: 5.2.1\nsources:\n - path: sources/SRD_CC_v5.2.1.pdf\n sha256: abc", + ); + let mut failures = Vec::new(); + + check_meta(&layer_root, &mut failures); + + assert!(failures.iter().any(|f| f.message.contains("commit"))); + } +}