Skip to main content

ifc_model/
guid.rs

1//! IFC GlobalId: the 22-character compressed GUID.
2//!
3//! # Why this is not standard base-64
4//!
5//! IFC packs a 128-bit UUID into 22 characters using its own alphabet ordered
6//! `0-9 A-Z a-z _ $`, processed as four 6-digit base-64 groups. It is *not*
7//! RFC 4648, and feeding it to a general base-64 decoder produces silent
8//! garbage rather than an error — which is exactly the kind of bug that
9//! surfaces as "some elements mysteriously fail to match" months later.
10//!
11//! Every IFC root object carries one, and it is the only stable cross-file
12//! identity an element has, so correctness here underpins diffing, clash
13//! tracking, and BCF issue references.
14
15/// The IFC base-64 alphabet, in IFC's own order.
16const ALPHABET: &[u8; 64] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_$";
17
18/// Reverse lookup, built at compile time.
19const fn reverse_table() -> [i8; 256] {
20    let mut table = [-1i8; 256];
21    let mut i = 0;
22    while i < 64 {
23        table[ALPHABET[i] as usize] = i as i8;
24        i += 1;
25    }
26    table
27}
28
29const REVERSE: [i8; 256] = reverse_table();
30
31/// A 22-character IFC GlobalId.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
33pub struct Guid([u8; 22]);
34
35impl Guid {
36    /// Wrap 22 ASCII characters, validating the alphabet.
37    pub fn parse(text: &str) -> Option<Self> {
38        let bytes = text.as_bytes();
39        if bytes.len() != 22 {
40            return None;
41        }
42        if bytes.iter().any(|&b| REVERSE[b as usize] < 0) {
43            return None;
44        }
45        let mut buf = [0u8; 22];
46        buf.copy_from_slice(bytes);
47        Some(Self(buf))
48    }
49
50    /// The GlobalId as text.
51    pub fn as_str(&self) -> &str {
52        // SAFETY-free: every byte was validated against an ASCII alphabet in
53        // `parse`, and `from_uuid` only emits alphabet bytes.
54        std::str::from_utf8(&self.0).expect("alphabet is ASCII by construction")
55    }
56
57    /// Compress a raw 128-bit UUID into the IFC form.
58    pub fn from_uuid(uuid: [u8; 16]) -> Self {
59        let mut num = 0u128;
60        for b in uuid {
61            num = (num << 8) | b as u128;
62        }
63        // 22 base-64 digits cover 132 bits; the leading digit carries the
64        // remaining 2 bits of the 128-bit value.
65        let mut out = [b'0'; 22];
66        let mut n = num;
67        for slot in out.iter_mut().rev() {
68            *slot = ALPHABET[(n & 0x3f) as usize];
69            n >>= 6;
70        }
71        Self(out)
72    }
73
74    /// Expand back to the raw 128-bit UUID.
75    pub fn to_uuid(self) -> [u8; 16] {
76        let mut num = 0u128;
77        for &b in &self.0 {
78            num = (num << 6) | (REVERSE[b as usize] as u128);
79        }
80        let mut out = [0u8; 16];
81        for (i, slot) in out.iter_mut().enumerate() {
82            *slot = ((num >> (8 * (15 - i))) & 0xff) as u8;
83        }
84        out
85    }
86}
87
88impl std::fmt::Display for Guid {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        f.write_str(self.as_str())
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    #[test]
99    fn rejects_wrong_length_and_foreign_characters() {
100        assert!(Guid::parse("tooshort").is_none());
101        // '+' and '/' are standard base-64 but NOT in IFC's alphabet.
102        assert!(Guid::parse("0123456789ABCDEFGHIJ+/").is_none());
103    }
104
105    #[test]
106    fn accepts_a_real_globalid_from_the_fixture_corpus() {
107        assert!(Guid::parse("2O2Fr$t4X7Zf8NOew3FLOH").is_some());
108    }
109
110    /// The property that matters: compress then expand must be identity, or
111    /// element identity silently changes on export.
112    #[test]
113    fn uuid_roundtrip_is_lossless() {
114        let uuid: [u8; 16] = [
115            0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54,
116            0x32, 0x10,
117        ];
118        assert_eq!(Guid::from_uuid(uuid).to_uuid(), uuid);
119    }
120
121    #[test]
122    fn text_roundtrip_is_lossless() {
123        let g = Guid::parse("2O2Fr$t4X7Zf8NOew3FLOH").unwrap();
124        assert_eq!(Guid::from_uuid(g.to_uuid()), g);
125    }
126}