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        }
55    }
56
57    #[test]
58    fn accepts_distinct_ids() {
59        let entities = vec![
60            stub_entity("v--a", "a.md"),
61            stub_entity("v--b", "b.md"),
62            stub_entity("v--a/child", "a/child.md"),
63        ];
64        check_unique_ids(&entities).unwrap();
65    }
66
67    #[test]
68    fn rejects_ascii_duplicates() {
69        let entities = vec![
70            stub_entity("v--hello-world", "a.md"),
71            stub_entity("v--hello-world", "b.md"),
72        ];
73        let err = check_unique_ids(&entities).unwrap_err();
74        match err {
75            ValidationError::DuplicateEntityId { id, paths } => {
76                assert_eq!(id, "v--hello-world");
77                assert_eq!(paths, ("a.md".to_string(), "b.md".to_string()));
78            }
79            other => panic!("expected DuplicateEntityId, got {other:?}"),
80        }
81    }
82
83    #[test]
84    fn rejects_nfc_nfd_duplicates() {
85        // "Björn" can be encoded NFC (B-j-ö-r-n, 'ö' is one codepoint)
86        // or NFD (B-j-o-\u{0308}-r-n). The filesystem preserves both;
87        // the validator normalizes to NFC and catches the collision.
88        let entities = vec![
89            stub_entity("v--bj\u{00F6}rn", "nfc.md"),
90            stub_entity("v--bjo\u{0308}rn", "nfd.md"),
91        ];
92        let err = check_unique_ids(&entities).unwrap_err();
93        assert!(matches!(err, ValidationError::DuplicateEntityId { .. }));
94    }
95
96    #[test]
97    fn accepts_empty() {
98        check_unique_ids(&[]).unwrap();
99    }
100}