1use thiserror::Error;
2
3#[derive(Debug, Error)]
4pub enum IdError {
5 #[error("Invalid ID format: {0}")]
6 InvalidFormat(String),
7 #[error("Invalid hex in ID: {0}")]
8 InvalidHex(String),
9}
10
11const DEFAULT_HASH_BYTES: usize = 3;
12
13pub fn id_from_key(prefix: &str, key: &str) -> String {
14 id_from_key_with_length(prefix, key, DEFAULT_HASH_BYTES)
15}
16
17pub fn id_from_key_with_length(prefix: &str, key: &str, bytes: usize) -> String {
18 let hash = blake3::hash(key.as_bytes());
19 let hex = hex::encode(&hash.as_bytes()[..bytes]);
20 format!("{prefix}-{hex}")
21}
22
23pub fn id_from_parts(prefix: &str, parts: &[&str]) -> String {
24 id_from_parts_with_length(prefix, parts, DEFAULT_HASH_BYTES)
25}
26
27pub fn id_from_parts_with_length(prefix: &str, parts: &[&str], bytes: usize) -> String {
28 let key = parts.join(":");
29 id_from_key_with_length(prefix, &key, bytes)
30}
31
32pub fn id_random(prefix: &str) -> String {
33 id_random_with_length(prefix, DEFAULT_HASH_BYTES)
34}
35
36pub fn id_random_with_length(prefix: &str, bytes: usize) -> String {
37 let uuid = uuid::Uuid::new_v4();
38 let hash = blake3::hash(uuid.as_bytes());
39 let hex = hex::encode(&hash.as_bytes()[..bytes]);
40 format!("{prefix}-{hex}")
41}
42
43#[deprecated(
44 since = "0.2.0",
45 note = "Use id_random() for random IDs or id_from_key() for deterministic IDs"
46)]
47pub fn generate_id(prefix: &str) -> String {
48 id_random(prefix)
49}
50
51#[deprecated(
52 since = "0.2.0",
53 note = "Use id_random_with_length() for random IDs or id_from_key_with_length() for deterministic IDs"
54)]
55pub fn generate_id_with_length(prefix: &str, bytes: usize) -> String {
56 id_random_with_length(prefix, bytes)
57}
58
59#[deprecated(since = "0.2.0", note = "Use id_from_key() instead")]
60pub fn generate_content_id(prefix: &str, content: &str) -> String {
61 id_from_key(prefix, content)
62}
63
64#[deprecated(since = "0.2.0", note = "Use id_from_key_with_length() instead")]
65pub fn generate_content_id_with_length(prefix: &str, content: &str, bytes: usize) -> String {
66 id_from_key_with_length(prefix, content, bytes)
67}
68
69pub fn parse_id(id: &str) -> Result<(String, String), IdError> {
70 let parts: Vec<&str> = id.splitn(2, '-').collect();
71 if parts.len() != 2 {
72 return Err(IdError::InvalidFormat(id.to_string()));
73 }
74
75 let prefix = parts[0].to_string();
76 let hash = parts[1].to_string();
77
78 if hash.len() < 6 || hash.len() > 12 {
79 return Err(IdError::InvalidFormat(format!(
80 "Hash must be 6-12 characters, got {}",
81 hash.len()
82 )));
83 }
84
85 if !hash.chars().all(|c| c.is_ascii_hexdigit()) {
86 return Err(IdError::InvalidHex(hash));
87 }
88
89 Ok((prefix, hash))
90}
91
92#[macro_export]
93macro_rules! define_id {
94 ($name:ident, $prefix:expr) => {
95 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
96 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
97 pub struct $name(String);
98
99 impl $name {
100 pub fn from_key(key: &str) -> Self {
105 Self($crate::id_from_key($prefix, key))
106 }
107
108 pub fn from_key_with_length(key: &str, bytes: usize) -> Self {
110 Self($crate::id_from_key_with_length($prefix, key, bytes))
111 }
112
113 pub fn from_parts(parts: &[&str]) -> Self {
118 Self($crate::id_from_parts($prefix, parts))
119 }
120
121 pub fn from_parts_with_length(parts: &[&str], bytes: usize) -> Self {
123 Self($crate::id_from_parts_with_length($prefix, parts, bytes))
124 }
125
126 pub fn random() -> Self {
132 Self($crate::id_random($prefix))
133 }
134
135 pub fn random_with_length(bytes: usize) -> Self {
137 Self($crate::id_random_with_length($prefix, bytes))
138 }
139
140 pub fn from_string(s: impl Into<String>) -> Self {
142 Self(s.into())
143 }
144
145 pub fn as_str(&self) -> &str {
147 &self.0
148 }
149
150 pub fn prefix() -> &'static str {
152 $prefix
153 }
154
155 #[deprecated(
158 since = "0.2.0",
159 note = "Use random() for random IDs or from_key() for deterministic IDs"
160 )]
161 pub fn generate() -> Self {
162 Self::random()
163 }
164
165 #[deprecated(since = "0.2.0", note = "Use random_with_length() instead")]
166 pub fn generate_with_length(bytes: usize) -> Self {
167 Self::random_with_length(bytes)
168 }
169
170 #[deprecated(since = "0.2.0", note = "Use from_key() instead")]
171 pub fn from_content(content: &str) -> Self {
172 Self::from_key(content)
173 }
174
175 #[deprecated(since = "0.2.0", note = "Use from_key_with_length() instead")]
176 pub fn from_content_with_length(content: &str, bytes: usize) -> Self {
177 Self::from_key_with_length(content, bytes)
178 }
179 }
180
181 impl std::fmt::Display for $name {
182 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183 write!(f, "{}", self.0)
184 }
185 }
186
187 impl AsRef<str> for $name {
188 fn as_ref(&self) -> &str {
189 &self.0
190 }
191 }
192
193 impl From<$name> for String {
194 fn from(id: $name) -> Self {
195 id.0
196 }
197 }
198 };
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 #[test]
206 fn test_id_from_key_deterministic() {
207 let id1 = id_from_key("src", "facebook/react");
208 let id2 = id_from_key("src", "facebook/react");
209 assert_eq!(id1, id2);
210 }
211
212 #[test]
213 fn test_id_from_key_different_keys() {
214 let id1 = id_from_key("src", "facebook/react");
215 let id2 = id_from_key("src", "vercel/next.js");
216 assert_ne!(id1, id2);
217 }
218
219 #[test]
220 fn test_id_from_key_format() {
221 let id = id_from_key("src", "facebook/react");
222 assert!(id.starts_with("src-"));
223 let expected_len = "src-".len() + 6;
224 assert_eq!(id.len(), expected_len);
225 }
226
227 #[test]
228 fn test_id_from_parts() {
229 let source_id = "src-abc123";
230 let path = "docs/intro.md";
231 let id = id_from_parts("doc", &[source_id, path]);
232 assert!(id.starts_with("doc-"));
233 }
234
235 #[test]
236 fn test_id_from_parts_deterministic() {
237 let id1 = id_from_parts("doc", &["src-abc123", "docs/intro.md"]);
238 let id2 = id_from_parts("doc", &["src-abc123", "docs/intro.md"]);
239 assert_eq!(id1, id2);
240 }
241
242 #[test]
243 fn test_id_from_parts_order_matters() {
244 let id1 = id_from_parts("doc", &["a", "b"]);
245 let id2 = id_from_parts("doc", &["b", "a"]);
246 assert_ne!(id1, id2);
247 }
248
249 #[test]
250 fn test_id_random_unique() {
251 let id1 = id_random("bd");
252 let id2 = id_random("bd");
253 assert_ne!(id1, id2);
254 }
255
256 #[test]
257 fn test_id_random_format() {
258 let id = id_random("bd");
259 assert!(id.starts_with("bd-"));
260 let expected_len = "bd-".len() + 6;
261 assert_eq!(id.len(), expected_len);
262 }
263
264 #[test]
265 fn test_id_with_length() {
266 let id = id_from_key_with_length("src", "test", 4);
267 let expected_len = "src-".len() + 8;
268 assert_eq!(id.len(), expected_len);
269 }
270
271 #[test]
272 fn test_parse_id() {
273 let (prefix, hash) = parse_id("bd-a1b2c3").unwrap();
274 assert_eq!(prefix, "bd");
275 assert_eq!(hash, "a1b2c3");
276 }
277
278 #[test]
279 fn test_parse_id_invalid_no_separator() {
280 assert!(parse_id("invalid").is_err());
281 }
282
283 #[test]
284 fn test_parse_id_invalid_hash_too_short() {
285 assert!(parse_id("bd-abc").is_err());
286 }
287
288 #[test]
289 fn test_parse_id_invalid_non_hex_chars() {
290 assert!(parse_id("bd-xyz123").is_err());
291 }
292
293 define_id!(SourceId, "src");
294 define_id!(DocId, "doc");
295 define_id!(IssueId, "bd");
296
297 #[test]
298 fn test_typed_id_from_key() {
299 let id = SourceId::from_key("facebook/react");
300 assert!(id.as_str().starts_with("src-"));
301 }
302
303 #[test]
304 fn test_typed_id_from_key_deterministic() {
305 let id1 = SourceId::from_key("facebook/react");
306 let id2 = SourceId::from_key("facebook/react");
307 assert_eq!(id1, id2);
308 }
309
310 #[test]
311 fn test_typed_id_from_parts() {
312 let source = SourceId::from_key("facebook/react");
313 let doc = DocId::from_parts(&[source.as_str(), "docs/hooks.md"]);
314 assert!(doc.as_str().starts_with("doc-"));
315 }
316
317 #[test]
318 fn test_typed_id_random() {
319 let id1 = IssueId::random();
320 let id2 = IssueId::random();
321 assert!(id1.as_str().starts_with("bd-"));
322 assert_ne!(id1, id2);
323 }
324
325 #[test]
326 fn test_typed_id_prefix() {
327 assert_eq!(SourceId::prefix(), "src");
328 assert_eq!(DocId::prefix(), "doc");
329 assert_eq!(IssueId::prefix(), "bd");
330 }
331}