//! 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. The fidelity /// layer's field-line drift check reads these values. 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![]); } }