egml_core/model/base/id.rs
1use crate::Error;
2use sha2::{Digest, Sha256};
3use std::fmt;
4use std::fmt::Write;
5use uuid::Uuid;
6
7/// A stable, globally unique identifier for a GML object.
8///
9/// Corresponds to the `gml:id` XML attribute ([OGC 07-036 §7.2.4.5](https://docs.ogc.org/is/07-036/07-036.pdf)).
10/// An `Id` is a non-empty string; it can be constructed from arbitrary
11/// bytes or strings by hashing them with SHA-256, or generated as a
12/// random UUID v4.
13#[derive(Debug, Clone, PartialEq, Eq, Hash, Ord, PartialOrd)]
14pub struct Id(String);
15
16impl Id {
17 /// Constructs an `Id` by hashing bytes using SHA-256.
18 ///
19 /// The resulting id is a 64-character uppercase hex string.
20 ///
21 /// # Examples
22 ///
23 /// ```rust
24 /// use egml_core::model::base::Id;
25 ///
26 /// let id = Id::from_hashed_bytes(b"hello");
27 /// assert_eq!(id.as_str().len(), 64);
28 /// ```
29 pub fn from_hashed_bytes(val: impl AsRef<[u8]>) -> Self {
30 Self(Self::hash_bytes_to_hex(val.as_ref()))
31 }
32
33 /// Constructs an `Id` by hashing a string using SHA-256.
34 ///
35 /// Two calls with equal strings always produce the same id.
36 ///
37 /// # Examples
38 ///
39 /// ```rust
40 /// use egml_core::model::base::Id;
41 ///
42 /// let id_a = Id::from_hashed_string("object-42");
43 /// let id_b = Id::from_hashed_string("object-42");
44 /// assert_eq!(id_a, id_b);
45 /// ```
46 pub fn from_hashed_string(val: &str) -> Self {
47 Self::from_hashed_bytes(val.as_bytes())
48 }
49
50 /// Constructs an `Id` by hashing a `u64` using SHA-256 (little-endian bytes).
51 pub fn from_hashed_u64(val: u64) -> Self {
52 Self::from_hashed_bytes(val.to_le_bytes())
53 }
54
55 /// Generates a random UUID v4 as an `Id`.
56 ///
57 /// # Examples
58 ///
59 /// ```rust
60 /// use egml_core::model::base::Id;
61 ///
62 /// let id = Id::generate_uuid_v4();
63 /// assert!(!id.as_str().is_empty());
64 /// ```
65 pub fn generate_uuid_v4() -> Self {
66 Self(Uuid::new_v4().to_string())
67 }
68
69 /// Generates a time-ordered UUID v7 as an `Id`.
70 ///
71 /// v7 encodes a millisecond-precision Unix timestamp in the high bits,
72 /// making ids lexicographically sortable by creation time — preferable
73 /// over v4 when ids are stored in a database index.
74 ///
75 /// # Examples
76 ///
77 /// ```rust
78 /// use egml_core::model::base::Id;
79 ///
80 /// let a = Id::generate_uuid_v7();
81 /// let b = Id::generate_uuid_v7();
82 /// assert!(a.as_str() <= b.as_str());
83 /// ```
84 pub fn generate_uuid_v7() -> Self {
85 Self(Uuid::now_v7().to_string())
86 }
87
88 /// Generates a deterministic UUID v5 from a namespace and a name.
89 ///
90 /// Two calls with the same `namespace` and `name` always produce the same
91 /// id. Use the predefined namespace constants on [`Uuid`] (e.g.
92 /// [`Uuid::NAMESPACE_URL`]) or supply a custom one.
93 ///
94 /// # Examples
95 ///
96 /// ```rust
97 /// use egml_core::model::base::Id;
98 /// use uuid::Uuid;
99 ///
100 /// let id_a = Id::generate_uuid_v5(&Uuid::NAMESPACE_URL, "https://example.com/object-1");
101 /// let id_b = Id::generate_uuid_v5(&Uuid::NAMESPACE_URL, "https://example.com/object-1");
102 /// assert_eq!(id_a, id_b);
103 /// ```
104 pub fn generate_uuid_v5(namespace: &Uuid, name: &str) -> Self {
105 Self(Uuid::new_v5(namespace, name.as_bytes()).to_string())
106 }
107
108 /// Returns the id as a string slice.
109 pub fn as_str(&self) -> &str {
110 &self.0
111 }
112}
113
114impl Id {
115 fn hash_bytes_to_hex(val: &[u8]) -> String {
116 let mut sha256 = Sha256::new();
117 sha256.update(val);
118 let result = sha256.finalize();
119
120 let mut hash = String::with_capacity(64);
121 for byte in result {
122 write!(&mut hash, "{:02X}", byte).unwrap();
123 }
124 hash
125 }
126
127 fn validate(s: &str) -> Result<(), Error> {
128 if s.is_empty() {
129 return Err(Error::EmptyId);
130 }
131 Ok(())
132 }
133}
134
135impl From<Id> for String {
136 fn from(item: Id) -> Self {
137 item.0
138 }
139}
140
141impl TryFrom<&str> for Id {
142 type Error = Error;
143
144 /// # Errors
145 ///
146 /// Returns [`Error::EmptyId`] if the string slice is empty.
147 fn try_from(item: &str) -> Result<Self, Self::Error> {
148 Self::validate(item)?;
149 Ok(Self(item.to_string()))
150 }
151}
152
153impl TryFrom<String> for Id {
154 type Error = Error;
155
156 /// # Errors
157 ///
158 /// Returns [`Error::EmptyId`] if the owned string is empty.
159 fn try_from(item: String) -> Result<Self, Self::Error> {
160 Self::validate(&item)?;
161 Ok(Self(item))
162 }
163}
164
165impl fmt::Display for Id {
166 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
167 f.write_str(&self.0)
168 }
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174
175 #[test]
176 fn try_from_empty_string_returns_empty_id_error() {
177 assert_eq!(Id::try_from(""), Err(Error::EmptyId));
178 assert_eq!(Id::try_from("".to_string()), Err(Error::EmptyId));
179 }
180
181 #[test]
182 fn from_hashed_string_is_deterministic() {
183 let xml = "<gml:Point><gml:pos>1 2 3</gml:pos></gml:Point>";
184 assert_eq!(Id::from_hashed_string(xml), Id::from_hashed_string(xml));
185 }
186}