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