Skip to main content

aven_core/
ids.rs

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(&timestamp).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}