Skip to main content

cadforge_core/
id.rs

1//! IFC identity.
2//!
3//! `GlobalId` is an `IfcGloballyUniqueId`: a 128-bit UUID compressed into 22 characters of
4//! IFC's own base-64 alphabet. It is the only cross-system identity CADForge recognises.
5//! Display names are never keys (`docs/ifc-semantics.md` §4.1).
6
7use serde::{Deserialize, Serialize};
8use std::fmt;
9use uuid::Uuid;
10
11/// IFC's base-64 alphabet. Note it is *not* RFC 4648 — the last two symbols are `_` and `$`,
12/// and the ordering is digits, uppercase, lowercase.
13const CHARS: &[u8; 64] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_$";
14
15/// Length of an `IfcGloballyUniqueId` in characters.
16pub const GLOBAL_ID_LEN: usize = 22;
17
18/// A stable, IFC-compatible element identity.
19#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
20pub struct GlobalId(String);
21
22/// Reasons a string is not a valid `IfcGloballyUniqueId`.
23#[derive(Debug, thiserror::Error, PartialEq, Eq)]
24pub enum GlobalIdError {
25    #[error("expected {GLOBAL_ID_LEN} characters, got {0}")]
26    WrongLength(usize),
27    #[error("character {0:?} is not in the IFC base-64 alphabet")]
28    InvalidChar(char),
29    /// The leading character encodes the high bits of byte 0, so it can only be `0`–`3`.
30    #[error("leading character {0:?} would overflow 128 bits")]
31    Overflow(char),
32}
33
34impl GlobalId {
35    /// Mint a fresh identity from a random UUID.
36    pub fn new() -> Self {
37        Self::from_uuid(Uuid::new_v4())
38    }
39
40    /// Compress a UUID into IFC's 22-character form.
41    pub fn from_uuid(uuid: Uuid) -> Self {
42        let bytes = uuid.as_bytes();
43        let mut out = String::with_capacity(GLOBAL_ID_LEN);
44
45        // Byte 0 alone in two digits, then five groups of three bytes in four digits each:
46        // 2 + 5 * 4 = 22 characters, 8 + 5 * 24 = 128 bits.
47        push_digits(u32::from(bytes[0]), 2, &mut out);
48        for chunk in bytes[1..].chunks_exact(3) {
49            let v = (u32::from(chunk[0]) << 16) | (u32::from(chunk[1]) << 8) | u32::from(chunk[2]);
50            push_digits(v, 4, &mut out);
51        }
52
53        debug_assert_eq!(out.len(), GLOBAL_ID_LEN);
54        GlobalId(out)
55    }
56
57    /// Build from raw bytes.
58    ///
59    /// Used to derive *deterministic* identities — an exporter that mints a random GlobalId
60    /// for each synthesised entity produces a different file every run, which defeats
61    /// content-addressed revisions and golden-file tests.
62    pub fn from_bytes(bytes: [u8; 16]) -> Self {
63        Self::from_uuid(Uuid::from_bytes(bytes))
64    }
65
66    /// Accept an identity that came from a file, validating it rather than trusting it.
67    ///
68    /// Imported IFC is untrusted input (`docs/ifc-semantics.md` §13).
69    pub fn parse(s: &str) -> Result<Self, GlobalIdError> {
70        if s.len() != GLOBAL_ID_LEN {
71            return Err(GlobalIdError::WrongLength(s.len()));
72        }
73        for c in s.chars() {
74            if digit_of(c).is_none() {
75                return Err(GlobalIdError::InvalidChar(c));
76            }
77        }
78        let lead = s.chars().next().expect("length checked above");
79        if digit_of(lead).expect("validated above") > 3 {
80            return Err(GlobalIdError::Overflow(lead));
81        }
82        Ok(GlobalId(s.to_owned()))
83    }
84
85    /// Expand back to a UUID. Round-trips with [`GlobalId::from_uuid`].
86    pub fn to_uuid(&self) -> Uuid {
87        let digits: Vec<u32> = self
88            .0
89            .chars()
90            .map(|c| digit_of(c).expect("GlobalId is validated on construction"))
91            .collect();
92
93        let mut bytes = [0u8; 16];
94        bytes[0] = (digits[0] * 64 + digits[1]) as u8;
95        for (group, out) in digits[2..]
96            .chunks_exact(4)
97            .zip(bytes[1..].chunks_exact_mut(3))
98        {
99            let v = group.iter().fold(0u32, |acc, d| acc * 64 + d);
100            out[0] = (v >> 16) as u8;
101            out[1] = (v >> 8) as u8;
102            out[2] = v as u8;
103        }
104        Uuid::from_bytes(bytes)
105    }
106
107    pub fn as_str(&self) -> &str {
108        &self.0
109    }
110}
111
112impl Default for GlobalId {
113    fn default() -> Self {
114        Self::new()
115    }
116}
117
118impl fmt::Display for GlobalId {
119    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120        f.write_str(&self.0)
121    }
122}
123
124fn push_digits(mut v: u32, n: usize, out: &mut String) {
125    let mut buf = [0u8; 4];
126    for slot in buf[..n].iter_mut().rev() {
127        *slot = CHARS[(v % 64) as usize];
128        v /= 64;
129    }
130    out.push_str(std::str::from_utf8(&buf[..n]).expect("alphabet is ASCII"));
131}
132
133fn digit_of(c: char) -> Option<u32> {
134    if !c.is_ascii() {
135        return None;
136    }
137    CHARS.iter().position(|&b| b == c as u8).map(|i| i as u32)
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn round_trips_through_uuid() {
146        for _ in 0..1000 {
147            let uuid = Uuid::new_v4();
148            let id = GlobalId::from_uuid(uuid);
149            assert_eq!(id.as_str().len(), GLOBAL_ID_LEN);
150            assert_eq!(id.to_uuid(), uuid, "round trip failed for {uuid}");
151        }
152    }
153
154    #[test]
155    fn parses_what_it_generates() {
156        let id = GlobalId::new();
157        assert_eq!(GlobalId::parse(id.as_str()), Ok(id));
158    }
159
160    #[test]
161    fn rejects_malformed_ids() {
162        assert_eq!(
163            GlobalId::parse("too-short"),
164            Err(GlobalIdError::WrongLength(9))
165        );
166        // 22 characters, but '*' is outside the IFC alphabet.
167        assert_eq!(
168            GlobalId::parse("0*00000000000000000000"),
169            Err(GlobalIdError::InvalidChar('*'))
170        );
171        // A leading digit above 3 encodes more than 128 bits.
172        assert_eq!(
173            GlobalId::parse("9000000000000000000000"),
174            Err(GlobalIdError::Overflow('9'))
175        );
176    }
177
178    #[test]
179    fn derived_ids_are_stable_and_valid() {
180        let a = GlobalId::from_bytes([7; 16]);
181        let b = GlobalId::from_bytes([7; 16]);
182        assert_eq!(a, b, "the same bytes must always give the same identity");
183        assert!(GlobalId::parse(a.as_str()).is_ok());
184        assert_ne!(a, GlobalId::from_bytes([8; 16]));
185        // Every possible leading byte still yields a parseable id.
186        for lead in [0u8, 1, 63, 64, 191, 255] {
187            let mut bytes = [0u8; 16];
188            bytes[0] = lead;
189            let id = GlobalId::from_bytes(bytes);
190            assert!(
191                GlobalId::parse(id.as_str()).is_ok(),
192                "leading byte {lead} failed"
193            );
194        }
195    }
196
197    #[test]
198    fn boundary_uuids_survive() {
199        for uuid in [Uuid::nil(), Uuid::from_bytes([0xff; 16])] {
200            assert_eq!(GlobalId::from_uuid(uuid).to_uuid(), uuid);
201        }
202    }
203
204    #[test]
205    fn ids_are_unique_enough_to_key_a_model() {
206        let ids: std::collections::BTreeSet<_> = (0..10_000).map(|_| GlobalId::new()).collect();
207        assert_eq!(ids.len(), 10_000);
208    }
209}