diff --git a/src/srd/verify/integration.rs b/src/srd/verify/integration.rs index 89f6e17..6044115 100644 --- a/src/srd/verify/integration.rs +++ b/src/srd/verify/integration.rs @@ -132,6 +132,41 @@ fn a_slug_mismatch_is_reported() { #[test] fn a_slug_collision_is_reported() { + // Two feats sharing a slug: a real collision, since both live at + // the same kind-qualified address prefix (feats/). The second file's + // name doesn't match its own filename either (that filename can't + // also be light.md, since the first file already has it), so it + // also fails the ordinary filename-matches-slug check — the + // collision assertion below only counts the "collides with" failures. + let layer = Layer::valid(); + let light_source = "sources/dnd.srd.5.2.1/05_Feats/Feats_Each/Light.md"; + let dim_light_source = "sources/dnd.srd.5.2.1/05_Feats/Feats_Each/Dim_Light.md"; + layer.write(light_source, "# Light\n\nA cheap source of light.\n"); + layer.write( + "feats/light.md", + &format!("---\nname: Light\ntype: feat\nsource: {light_source}\n---\n# Light\n\nA cheap source of light.\n"), + ); + layer.write(dim_light_source, "# Light\n\nA dimmer light.\n"); + layer.write( + "feats/light-duplicate.md", + &format!("---\nname: Light\ntype: feat\nsource: {dim_light_source}\n---\n# Light\n\nA dimmer light.\n"), + ); + + let failures = extra_failures(&layer); + + let collisions: Vec<_> = failures + .iter() + .filter(|f| f.message.contains("collides with")) + .collect(); + assert_eq!(collisions.len(), 2); +} + +#[test] +fn a_shared_slug_across_different_kinds_is_allowed() { + // Round-4 correction: an entry's address is its kind-qualified path + // (feats/light and core/light are distinct addresses in the rules + // union mount), so the same slug in two different kinds' directories + // is not a collision. 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"; @@ -151,8 +186,7 @@ fn a_slug_collision_is_reported() { let failures = extra_failures(&layer); - assert_eq!(failures.len(), 2); - assert!(failures.iter().all(|f| f.message.contains("collides with"))); + assert_eq!(failures, vec![]); } #[test] diff --git a/src/srd/verify/mod.rs b/src/srd/verify/mod.rs index 878d0f6..f3d4543 100644 --- a/src/srd/verify/mod.rs +++ b/src/srd/verify/mod.rs @@ -92,7 +92,7 @@ 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 names: Vec<(PathBuf, Kind, String)> = Vec::new(); let mut claims: Vec = Vec::new(); for file in &files { @@ -101,7 +101,7 @@ pub fn verify(layer_root: &Path) -> Report { let Some(universal) = schema::validate_universal(file, layer_root, &mut failures) else { continue; }; - names.push((file.path.clone(), universal.name.clone())); + names.push((file.path.clone(), file.kind, universal.name.clone())); claims.push(universal.source.clone()); let spell_fields = (file.kind == Kind::Spell) @@ -298,19 +298,24 @@ mod tests { } #[test] - fn verify_reports_a_slug_collision_across_files() { + fn verify_reports_a_slug_collision_within_a_kind() { + // Two feats sharing a slug: a collision, since both live at the + // same kind-qualified address prefix (feats/). Two different + // kinds sharing a slug is not a collision — an entry's address + // is its kind-qualified path, so e.g. feats/light and core/light + // are distinct addresses in the rules union mount. 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"); + let light_source = "sources/dnd.srd.5.2.1/05_Feats/Feats_Each/Light.md"; + let dim_light_source = "sources/dnd.srd.5.2.1/05_Feats/Feats_Each/Dim_Light.md"; + write_file(&layer_root.join(light_source), "content"); + write_file(&layer_root.join(dim_light_source), "content"); write_file( &layer_root.join("feats/light.md"), - &format!("---\nname: Light\ntype: feat\nsource: {feat_source}\n---\nbody\n"), + &format!("---\nname: Light\ntype: feat\nsource: {light_source}\n---\nbody\n"), ); write_file( - &layer_root.join("core/light.md"), - &format!("---\nname: Light\ntype: core\nsource: {core_source}\n---\nbody\n"), + &layer_root.join("feats/light-duplicate.md"), + &format!("---\nname: Light\ntype: feat\nsource: {dim_light_source}\n---\nbody\n"), ); let report = verify(&layer_root); diff --git a/src/srd/verify/schema.rs b/src/srd/verify/schema.rs index b4d0a09..09f2678 100644 --- a/src/srd/verify/schema.rs +++ b/src/srd/verify/schema.rs @@ -337,17 +337,23 @@ fn check_slug(file: &CorpusFile, name: &str, failures: &mut Vec) { } } -/// 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); +/// Reports every name whose slug is used by more than one corpus file of +/// the same kind. An entry's address is its kind-qualified path +/// (`core/magic-items` and `equipment/magic-items` are distinct +/// addresses in the rules union mount), so two different kinds sharing a +/// slug is not a collision — only two files of the *same* kind are. +pub fn check_slug_collisions(names: &[(PathBuf, Kind, String)]) -> Vec { + let mut by_slug: HashMap<(Kind, String), Vec<&PathBuf>> = HashMap::new(); + for (path, kind, name) in names { + by_slug + .entry((*kind, slug::slugify(name))) + .or_default() + .push(path); } let mut failures: Vec = by_slug .into_iter() .filter(|(_, paths)| paths.len() > 1) - .flat_map(|(slug, paths)| { + .flat_map(|((_, slug), paths)| { let others = paths .iter() .map(|path| path.display().to_string()) diff --git a/src/srd/verify/schema_tests.rs b/src/srd/verify/schema_tests.rs index 6582047..ebbfae0 100644 --- a/src/srd/verify/schema_tests.rs +++ b/src/srd/verify/schema_tests.rs @@ -502,18 +502,34 @@ fn validate_universal_rejects_a_slug_mismatch() { #[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()), + ( + PathBuf::from("spells/fireball.md"), + Kind::Spell, + "Fireball".to_string(), + ), + ( + PathBuf::from("spells/wish.md"), + Kind::Spell, + "Wish".to_string(), + ), ]; assert_eq!(check_slug_collisions(&names), vec![]); } #[test] -fn check_slug_collisions_reports_every_colliding_file() { +fn check_slug_collisions_reports_every_colliding_file_within_a_kind() { let names = vec![ - (PathBuf::from("spells/light.md"), "Light".to_string()), - (PathBuf::from("magic-items/light.md"), "Light".to_string()), + ( + PathBuf::from("spells/light.md"), + Kind::Spell, + "Light".to_string(), + ), + ( + PathBuf::from("spells/light-duplicate.md"), + Kind::Spell, + "Light".to_string(), + ), ]; let failures = check_slug_collisions(&names); @@ -522,6 +538,27 @@ fn check_slug_collisions_reports_every_colliding_file() { assert!(failures.iter().all(|f| f.message.contains("collides with"))); } +#[test] +fn check_slug_collisions_allows_the_same_slug_across_different_kinds() { + // An entry's address is its kind-qualified path: core/magic-items + // and equipment/magic-items are distinct addresses in the rules + // union mount, so sharing a slug across kinds is not a collision. + let names = vec![ + ( + PathBuf::from("core/magic-items.md"), + Kind::Core, + "Magic Items".to_string(), + ), + ( + PathBuf::from("equipment/magic-items.md"), + Kind::Equipment, + "Magic Items".to_string(), + ), + ]; + + assert_eq!(check_slug_collisions(&names), vec![]); +} + #[test] fn is_core_chapter_source_rejects_a_path_outside_the_vendored_tree() { assert!(!is_core_chapter_source(Path::new("elsewhere/file.md")));