Skip to main content

memstead_base/validator/
ids.rs

1//! Entity-ID uniqueness across the archive after Unicode normalization.
2//!
3//! The filesystem lets two files coexist whose names are equivalent
4//! under NFC/NFD (think "Björn" typed two different ways) or whose
5//! title-to-slug pipelines collapse into the same id ("Hello
6//! World.md" and "hello-world.md" both slugify to `hello-world`).
7//! The last-written file wins silently on steady-state load. At
8//! strict ingress we reject.
9
10use std::collections::HashMap;
11use unicode_normalization::UnicodeNormalization;
12
13use super::ValidationError;
14use crate::entity::Entity;
15
16/// Reject if any two entities have the same id under NFC normalization.
17/// Reports the two `file_path`s so the error points at both colliding
18/// files.
19pub fn check_unique_ids(entities: &[Entity]) -> Result<(), ValidationError> {
20    let mut seen: HashMap<String, &str> = HashMap::new();
21    for entity in entities {
22        let nfc: String = entity.id.as_ref().nfc().collect();
23        if let Some(prev_path) = seen.get(&nfc) {
24            return Err(ValidationError::DuplicateEntityId {
25                id: nfc,
26                paths: ((*prev_path).to_string(), entity.file_path.clone()),
27            });
28        }
29        seen.insert(nfc, entity.file_path.as_str());
30    }
31    Ok(())
32}
33
34#[cfg(test)]
35mod tests {
36    use super::*;
37    use crate::entity::{Entity, EntityId};
38    use indexmap::IndexMap;
39
40    fn stub_entity(id: &str, file_path: &str) -> Entity {
41        Entity {
42            id: EntityId(id.to_string()),
43            title: String::new(),
44            entity_type: "spec".to_string(),
45            mem: "v".to_string(),
46            file_path: file_path.to_string(),
47            metadata: IndexMap::new(),
48            sections: IndexMap::new(),
49            relationships: Vec::new(),
50            content_hash: String::new(),
51            stub: false,
52            stub_kind: None,
53            heading_spans: std::collections::HashMap::new(),
54            raw_section_headings: Vec::new(),
55        }
56    }
57
58    #[test]
59    fn accepts_distinct_ids() {
60        let entities = vec![
61            stub_entity("v--a", "a.md"),
62            stub_entity("v--b", "b.md"),
63            stub_entity("v--a/child", "a/child.md"),
64        ];
65        check_unique_ids(&entities).unwrap();
66    }
67
68    #[test]
69    fn rejects_ascii_duplicates() {
70        let entities = vec![
71            stub_entity("v--hello-world", "a.md"),
72            stub_entity("v--hello-world", "b.md"),
73        ];
74        let err = check_unique_ids(&entities).unwrap_err();
75        match err {
76            ValidationError::DuplicateEntityId { id, paths } => {
77                assert_eq!(id, "v--hello-world");
78                assert_eq!(paths, ("a.md".to_string(), "b.md".to_string()));
79            }
80            other => panic!("expected DuplicateEntityId, got {other:?}"),
81        }
82    }
83
84    #[test]
85    fn rejects_nfc_nfd_duplicates() {
86        // "Björn" can be encoded NFC (B-j-ö-r-n, 'ö' is one codepoint)
87        // or NFD (B-j-o-\u{0308}-r-n). The filesystem preserves both;
88        // the validator normalizes to NFC and catches the collision.
89        let entities = vec![
90            stub_entity("v--bj\u{00F6}rn", "nfc.md"),
91            stub_entity("v--bjo\u{0308}rn", "nfd.md"),
92        ];
93        let err = check_unique_ids(&entities).unwrap_err();
94        assert!(matches!(err, ValidationError::DuplicateEntityId { .. }));
95    }
96
97    #[test]
98    fn accepts_empty() {
99        check_unique_ids(&[]).unwrap();
100    }
101}