Skip to main content

dotloom_document/
ids.rs

1//! Stable identifiers.
2
3use core::fmt;
4
5use serde::{Deserialize, Serialize};
6
7use crate::DocError;
8
9macro_rules! id_type {
10    ($(#[$m:meta])* $name:ident, $prefix:literal) => {
11        $(#[$m])*
12        #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
13        #[serde(transparent)]
14        pub struct $name(pub u64);
15
16        impl fmt::Display for $name {
17            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
18                write!(f, concat!($prefix, "{}"), self.0)
19            }
20        }
21    };
22}
23
24id_type!(
25    /// Entity identifier. Stable for the lifetime of the entity; never reused within
26    /// a document (allocated from `Document::next_id`).
27    EntityId,
28    "e"
29);
30id_type!(
31    /// Constraint identifier.
32    ConstraintId,
33    "c"
34);
35id_type!(
36    /// Layer identifier.
37    LayerId,
38    "l"
39);
40id_type!(
41    /// Group identifier.
42    GroupId,
43    "g"
44);
45
46/// Namespaced entity type identifier such as `dotloom.line` or `acme.wall`.
47///
48/// Grammar: two or more dot-separated segments of `[a-z][a-z0-9_-]*`, at most 128
49/// bytes in total.
50#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
51#[serde(try_from = "String", into = "String")]
52pub struct TypeId(pub(crate) String);
53
54impl TypeId {
55    /// Maximum length in bytes.
56    pub const MAX_LEN: usize = 128;
57
58    /// Parse and validate.
59    pub fn new(s: impl Into<String>) -> Result<Self, DocError> {
60        let s = s.into();
61        if Self::is_valid(&s) { Ok(Self(s)) } else { Err(DocError::InvalidTypeId(s)) }
62    }
63
64    /// Whether `s` is a valid type ID.
65    #[must_use]
66    pub fn is_valid(s: &str) -> bool {
67        if s.is_empty() || s.len() > Self::MAX_LEN {
68            return false;
69        }
70        let mut segments = 0;
71        for seg in s.split('.') {
72            segments += 1;
73            let mut chars = seg.chars();
74            match chars.next() {
75                Some(c) if c.is_ascii_lowercase() => {}
76                _ => return false,
77            }
78            if !chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-') {
79                return false;
80            }
81        }
82        segments >= 2
83    }
84
85    /// The namespace (first segment).
86    #[must_use]
87    pub fn namespace(&self) -> &str {
88        self.0.split('.').next().unwrap_or("")
89    }
90
91    /// As string.
92    #[must_use]
93    pub fn as_str(&self) -> &str {
94        &self.0
95    }
96}
97
98impl TryFrom<String> for TypeId {
99    type Error = DocError;
100    fn try_from(s: String) -> Result<Self, DocError> {
101        Self::new(s)
102    }
103}
104
105impl From<TypeId> for String {
106    fn from(t: TypeId) -> Self {
107        t.0
108    }
109}
110
111impl fmt::Display for TypeId {
112    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113        f.write_str(&self.0)
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn type_id_grammar() {
123        assert!(TypeId::new("dotloom.line").is_ok());
124        assert!(TypeId::new("acme.floor-plan.wall_2").is_ok());
125        for bad in ["line", "Dotloom.line", "acme..wall", "acme.1wall", "", "acme.wa ll", "acme.wall."] {
126            assert!(TypeId::new(bad).is_err(), "{bad}");
127        }
128        assert!(TypeId::new(format!("a.{}", "b".repeat(200))).is_err());
129        assert_eq!(TypeId::new("acme.wall").unwrap().namespace(), "acme");
130    }
131
132    #[test]
133    fn ids_serialize_as_numbers() {
134        assert_eq!(serde_json::to_string(&EntityId(7)).unwrap(), "7");
135        assert_eq!(EntityId(7).to_string(), "e7");
136        let t: Result<TypeId, _> = serde_json::from_str("\"Bad\"");
137        assert!(t.is_err());
138    }
139}