1use std::fmt;
2use std::str::FromStr;
3
4use chrono::{DateTime, SecondsFormat, Timelike, Utc};
5use getrandom::fill as fill_random;
6use serde::{Deserialize, Deserializer, Serialize};
7use sqlx::database::Database;
8use sqlx::decode::Decode;
9use sqlx::encode::{Encode, IsNull};
10use sqlx::error::BoxDynError;
11use sqlx::sqlite::{Sqlite, SqliteTypeInfo, SqliteValueRef};
12use sqlx::types::Type;
13
14pub const BASE32: &[u8] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
15
16#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
17#[serde(transparent)]
18pub struct WorkspaceId(String);
19
20#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
21#[serde(transparent)]
22pub struct ProjectId(String);
23
24#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
25#[serde(transparent)]
26pub struct MetadataFieldId(String);
27
28#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
29#[serde(transparent)]
30pub struct TaskId(String);
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct InvalidWorkspaceId;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub struct InvalidProjectId;
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub struct InvalidMetadataFieldId;
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub struct InvalidTaskId;
43
44#[allow(clippy::new_without_default)]
45impl WorkspaceId {
46 pub fn new() -> Self {
47 Self(new_id())
48 }
49
50 pub fn as_str(&self) -> &str {
51 &self.0
52 }
53}
54
55#[allow(clippy::new_without_default)]
56impl ProjectId {
57 pub fn new() -> Self {
58 Self(new_id())
59 }
60
61 pub fn as_str(&self) -> &str {
62 &self.0
63 }
64}
65
66#[allow(clippy::new_without_default)]
67impl MetadataFieldId {
68 pub fn new() -> Self {
69 Self(new_id())
70 }
71
72 pub fn as_str(&self) -> &str {
73 &self.0
74 }
75}
76
77#[allow(clippy::new_without_default)]
78impl TaskId {
79 pub fn new() -> Self {
80 Self(new_id())
81 }
82
83 pub fn as_str(&self) -> &str {
84 &self.0
85 }
86}
87
88impl std::ops::Deref for TaskId {
89 type Target = str;
90
91 fn deref(&self) -> &Self::Target {
92 self.as_str()
93 }
94}
95
96impl AsRef<str> for TaskId {
97 fn as_ref(&self) -> &str {
98 self.as_str()
99 }
100}
101
102impl std::borrow::Borrow<str> for TaskId {
103 fn borrow(&self) -> &str {
104 self.as_str()
105 }
106}
107
108impl fmt::Display for WorkspaceId {
109 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
110 self.0.fmt(formatter)
111 }
112}
113
114impl fmt::Display for ProjectId {
115 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
116 self.0.fmt(formatter)
117 }
118}
119
120impl fmt::Display for MetadataFieldId {
121 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
122 self.0.fmt(formatter)
123 }
124}
125
126impl fmt::Display for TaskId {
127 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
128 self.0.fmt(formatter)
129 }
130}
131
132impl fmt::Display for InvalidWorkspaceId {
133 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
134 formatter.write_str("workspace ID must be 16 Crockford Base32 characters")
135 }
136}
137
138impl fmt::Display for InvalidProjectId {
139 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
140 formatter.write_str("project ID must be 16 Crockford Base32 characters")
141 }
142}
143
144impl fmt::Display for InvalidMetadataFieldId {
145 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
146 formatter.write_str("metadata field ID must be 16 Crockford Base32 characters")
147 }
148}
149
150impl fmt::Display for InvalidTaskId {
151 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
152 formatter.write_str("task ID must be 16 Crockford Base32 characters")
153 }
154}
155
156impl std::error::Error for InvalidWorkspaceId {}
157impl std::error::Error for InvalidProjectId {}
158impl std::error::Error for InvalidMetadataFieldId {}
159impl std::error::Error for InvalidTaskId {}
160
161impl FromStr for WorkspaceId {
162 type Err = InvalidWorkspaceId;
163
164 fn from_str(value: &str) -> Result<Self, Self::Err> {
165 if value.len() == 16 && value.bytes().all(|byte| BASE32.contains(&byte)) {
166 Ok(Self(value.to_string()))
167 } else {
168 Err(InvalidWorkspaceId)
169 }
170 }
171}
172
173impl FromStr for ProjectId {
174 type Err = InvalidProjectId;
175
176 fn from_str(value: &str) -> Result<Self, Self::Err> {
177 if value.len() == 16 && value.bytes().all(|byte| BASE32.contains(&byte)) {
178 Ok(Self(value.to_string()))
179 } else {
180 Err(InvalidProjectId)
181 }
182 }
183}
184
185impl FromStr for MetadataFieldId {
186 type Err = InvalidMetadataFieldId;
187
188 fn from_str(value: &str) -> Result<Self, Self::Err> {
189 if value.len() == 16 && value.bytes().all(|byte| BASE32.contains(&byte)) {
190 Ok(Self(value.to_string()))
191 } else {
192 Err(InvalidMetadataFieldId)
193 }
194 }
195}
196
197impl FromStr for TaskId {
198 type Err = InvalidTaskId;
199
200 fn from_str(value: &str) -> Result<Self, Self::Err> {
201 if value.len() == 16 && value.bytes().all(|byte| BASE32.contains(&byte)) {
202 Ok(Self(value.to_string()))
203 } else {
204 Err(InvalidTaskId)
205 }
206 }
207}
208
209impl TryFrom<String> for WorkspaceId {
210 type Error = InvalidWorkspaceId;
211
212 fn try_from(value: String) -> Result<Self, Self::Error> {
213 value.parse()
214 }
215}
216
217impl TryFrom<String> for ProjectId {
218 type Error = InvalidProjectId;
219
220 fn try_from(value: String) -> Result<Self, Self::Error> {
221 value.parse()
222 }
223}
224
225impl TryFrom<String> for MetadataFieldId {
226 type Error = InvalidMetadataFieldId;
227
228 fn try_from(value: String) -> Result<Self, Self::Error> {
229 value.parse()
230 }
231}
232
233impl TryFrom<String> for TaskId {
234 type Error = InvalidTaskId;
235
236 fn try_from(value: String) -> Result<Self, Self::Error> {
237 value.parse()
238 }
239}
240
241impl<'de> Deserialize<'de> for WorkspaceId {
242 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
243 where
244 D: Deserializer<'de>,
245 {
246 String::deserialize(deserializer)?
247 .parse()
248 .map_err(serde::de::Error::custom)
249 }
250}
251
252impl<'de> Deserialize<'de> for ProjectId {
253 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
254 where
255 D: Deserializer<'de>,
256 {
257 String::deserialize(deserializer)?
258 .parse()
259 .map_err(serde::de::Error::custom)
260 }
261}
262
263impl<'de> Deserialize<'de> for MetadataFieldId {
264 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
265 where
266 D: Deserializer<'de>,
267 {
268 String::deserialize(deserializer)?
269 .parse()
270 .map_err(serde::de::Error::custom)
271 }
272}
273
274impl<'de> Deserialize<'de> for TaskId {
275 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
276 where
277 D: Deserializer<'de>,
278 {
279 String::deserialize(deserializer)?
280 .parse()
281 .map_err(serde::de::Error::custom)
282 }
283}
284
285impl Type<Sqlite> for WorkspaceId {
286 fn type_info() -> SqliteTypeInfo {
287 <String as Type<Sqlite>>::type_info()
288 }
289}
290
291impl Type<Sqlite> for ProjectId {
292 fn type_info() -> SqliteTypeInfo {
293 <String as Type<Sqlite>>::type_info()
294 }
295}
296
297impl Type<Sqlite> for MetadataFieldId {
298 fn type_info() -> SqliteTypeInfo {
299 <String as Type<Sqlite>>::type_info()
300 }
301}
302
303impl Type<Sqlite> for TaskId {
304 fn type_info() -> SqliteTypeInfo {
305 <String as Type<Sqlite>>::type_info()
306 }
307}
308
309impl Encode<'_, Sqlite> for WorkspaceId {
310 fn encode_by_ref(
311 &self,
312 buffer: &mut <Sqlite as Database>::ArgumentBuffer,
313 ) -> Result<IsNull, BoxDynError> {
314 <String as Encode<Sqlite>>::encode_by_ref(&self.0, buffer)
315 }
316}
317
318impl Encode<'_, Sqlite> for ProjectId {
319 fn encode_by_ref(
320 &self,
321 buffer: &mut <Sqlite as Database>::ArgumentBuffer,
322 ) -> Result<IsNull, BoxDynError> {
323 <String as Encode<Sqlite>>::encode_by_ref(&self.0, buffer)
324 }
325}
326
327impl Encode<'_, Sqlite> for MetadataFieldId {
328 fn encode_by_ref(
329 &self,
330 buffer: &mut <Sqlite as Database>::ArgumentBuffer,
331 ) -> Result<IsNull, BoxDynError> {
332 <String as Encode<Sqlite>>::encode_by_ref(&self.0, buffer)
333 }
334}
335
336impl Encode<'_, Sqlite> for TaskId {
337 fn encode_by_ref(
338 &self,
339 buffer: &mut <Sqlite as Database>::ArgumentBuffer,
340 ) -> Result<IsNull, BoxDynError> {
341 <String as Encode<Sqlite>>::encode_by_ref(&self.0, buffer)
342 }
343}
344
345impl<'row> Decode<'row, Sqlite> for WorkspaceId {
346 fn decode(value: SqliteValueRef<'row>) -> Result<Self, BoxDynError> {
347 String::decode(value)?.parse().map_err(Into::into)
348 }
349}
350
351impl<'row> Decode<'row, Sqlite> for ProjectId {
352 fn decode(value: SqliteValueRef<'row>) -> Result<Self, BoxDynError> {
353 String::decode(value)?.parse().map_err(Into::into)
354 }
355}
356
357impl<'row> Decode<'row, Sqlite> for MetadataFieldId {
358 fn decode(value: SqliteValueRef<'row>) -> Result<Self, BoxDynError> {
359 String::decode(value)?.parse().map_err(Into::into)
360 }
361}
362
363impl<'row> Decode<'row, Sqlite> for TaskId {
364 fn decode(value: SqliteValueRef<'row>) -> Result<Self, BoxDynError> {
365 String::decode(value)?.parse().map_err(Into::into)
366 }
367}
368
369pub fn now_utc() -> DateTime<Utc> {
370 Utc::now()
371 .with_nanosecond(0)
372 .expect("UTC timestamps support second precision")
373}
374
375pub fn now() -> String {
376 now_utc().to_rfc3339_opts(SecondsFormat::Secs, true)
377}
378
379pub fn new_id() -> String {
380 let mut bytes = [0u8; 10];
381 fill_random(&mut bytes).expect("fill random bytes");
382 encode_crockford(&bytes)
383}
384
385pub fn encode_crockford(bytes: &[u8; 10]) -> String {
386 let mut value = u128::from_be_bytes([
387 0, 0, 0, 0, 0, 0, bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6],
388 bytes[7], bytes[8], bytes[9],
389 ]);
390 let mut chars = [b'0'; 16];
391 for i in (0..16).rev() {
392 chars[i] = BASE32[(value & 31) as usize];
393 value >>= 5;
394 }
395 String::from_utf8(chars.to_vec()).expect("base32 is utf8")
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401 use crate::test_support::test_conn;
402
403 #[test]
404 fn timestamps_are_current_utc_values_at_second_precision() {
405 let before = Utc::now() - chrono::Duration::seconds(1);
406 let typed = now_utc();
407 let timestamp = now();
408 let after = Utc::now() + chrono::Duration::seconds(1);
409 let parsed = DateTime::parse_from_rfc3339(×tamp).unwrap().to_utc();
410
411 assert_eq!(typed.nanosecond(), 0);
412 assert!(typed >= before && typed <= after);
413 assert_eq!(timestamp.len(), 20);
414 assert_eq!(timestamp, parsed.to_rfc3339_opts(SecondsFormat::Secs, true));
415 assert_eq!(parsed.nanosecond(), 0);
416 assert!(parsed >= before && parsed <= after);
417 }
418
419 #[test]
420 fn workspace_ids_validate_domain_id_shape() {
421 assert!("0123456789ABCDEF".parse::<WorkspaceId>().is_ok());
422 assert!("0123456789ABCDE".parse::<WorkspaceId>().is_err());
423 assert!("0123456789abcdef".parse::<WorkspaceId>().is_err());
424 assert!("0123456789ABCDEI".parse::<WorkspaceId>().is_err());
425 }
426
427 #[test]
428 fn workspace_ids_serialize_as_validated_strings() {
429 let id: WorkspaceId = serde_json::from_str("\"0123456789ABCDEF\"").unwrap();
430 assert_eq!(serde_json::to_string(&id).unwrap(), "\"0123456789ABCDEF\"");
431 assert!(serde_json::from_str::<WorkspaceId>("\"invalid\"").is_err());
432 }
433
434 #[test]
435 fn project_ids_validate_domain_id_shape() {
436 assert!("0123456789ABCDEF".parse::<ProjectId>().is_ok());
437 assert!("0123456789ABCDE".parse::<ProjectId>().is_err());
438 assert!("0123456789abcdef".parse::<ProjectId>().is_err());
439 assert!("0123456789ABCDEI".parse::<ProjectId>().is_err());
440 }
441
442 #[test]
443 fn project_ids_serialize_as_validated_strings() {
444 let id: ProjectId = serde_json::from_str("\"0123456789ABCDEF\"").unwrap();
445 assert_eq!(serde_json::to_string(&id).unwrap(), "\"0123456789ABCDEF\"");
446 assert!(serde_json::from_str::<ProjectId>("\"invalid\"").is_err());
447 }
448
449 #[test]
450 fn task_ids_validate_domain_id_shape() {
451 assert!("0123456789ABCDEF".parse::<TaskId>().is_ok());
452 assert!("0123456789ABCDE".parse::<TaskId>().is_err());
453 assert!("0123456789abcdef".parse::<TaskId>().is_err());
454 assert!("0123456789ABCDEI".parse::<TaskId>().is_err());
455 }
456
457 #[test]
458 fn task_ids_serialize_as_validated_strings() {
459 let id: TaskId = serde_json::from_str("\"0123456789ABCDEF\"").unwrap();
460 assert_eq!(serde_json::to_string(&id).unwrap(), "\"0123456789ABCDEF\"");
461 assert!(serde_json::from_str::<TaskId>("\"invalid\"").is_err());
462 }
463
464 #[tokio::test]
465 async fn task_ids_bind_and_decode_as_sqlite_text() {
466 let (_temp, mut conn) = test_conn().await;
467 let id: TaskId = "0123456789ABCDEF".parse().unwrap();
468 let decoded = sqlx::query_scalar::<_, TaskId>("SELECT ?")
469 .bind(&id)
470 .fetch_one(&mut *conn)
471 .await
472 .unwrap();
473 assert_eq!(decoded, id);
474
475 let invalid = sqlx::query_scalar::<_, TaskId>("SELECT 'invalid'")
476 .fetch_one(&mut *conn)
477 .await;
478 assert!(invalid.is_err());
479 }
480
481 #[tokio::test]
482 async fn project_ids_bind_and_decode_as_sqlite_text() {
483 let (_temp, mut conn) = test_conn().await;
484 let id: ProjectId = "0123456789ABCDEF".parse().unwrap();
485 let decoded = sqlx::query_scalar::<_, ProjectId>("SELECT ?")
486 .bind(&id)
487 .fetch_one(&mut *conn)
488 .await
489 .unwrap();
490 assert_eq!(decoded, id);
491
492 let invalid = sqlx::query_scalar::<_, ProjectId>("SELECT 'invalid'")
493 .fetch_one(&mut *conn)
494 .await;
495 assert!(invalid.is_err());
496 }
497
498 #[tokio::test]
499 async fn workspace_ids_bind_and_decode_as_sqlite_text() {
500 let (_temp, mut conn) = test_conn().await;
501 let id: WorkspaceId = "0123456789ABCDEF".parse().unwrap();
502 let decoded = sqlx::query_scalar::<_, WorkspaceId>("SELECT ?")
503 .bind(&id)
504 .fetch_one(&mut *conn)
505 .await
506 .unwrap();
507 assert_eq!(decoded, id);
508
509 let invalid = sqlx::query_scalar::<_, WorkspaceId>("SELECT 'invalid'")
510 .fetch_one(&mut *conn)
511 .await;
512 assert!(invalid.is_err());
513 }
514}