Skip to main content

aven_core/
ids.rs

1use std::fmt;
2use std::process::Command;
3use std::str::FromStr;
4
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, PartialOrd, Ord, Hash, Serialize)]
25#[serde(transparent)]
26pub struct TaskId(String);
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub struct InvalidWorkspaceId;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct InvalidProjectId;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub struct InvalidTaskId;
36
37#[allow(clippy::new_without_default)]
38impl WorkspaceId {
39    pub fn new() -> Self {
40        Self(new_id())
41    }
42
43    pub fn as_str(&self) -> &str {
44        &self.0
45    }
46}
47
48#[allow(clippy::new_without_default)]
49impl ProjectId {
50    pub fn new() -> Self {
51        Self(new_id())
52    }
53
54    pub fn as_str(&self) -> &str {
55        &self.0
56    }
57}
58
59#[allow(clippy::new_without_default)]
60impl TaskId {
61    pub fn new() -> Self {
62        Self(new_id())
63    }
64
65    pub fn as_str(&self) -> &str {
66        &self.0
67    }
68}
69
70impl std::ops::Deref for TaskId {
71    type Target = str;
72
73    fn deref(&self) -> &Self::Target {
74        self.as_str()
75    }
76}
77
78impl AsRef<str> for TaskId {
79    fn as_ref(&self) -> &str {
80        self.as_str()
81    }
82}
83
84impl std::borrow::Borrow<str> for TaskId {
85    fn borrow(&self) -> &str {
86        self.as_str()
87    }
88}
89
90impl fmt::Display for WorkspaceId {
91    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
92        self.0.fmt(formatter)
93    }
94}
95
96impl fmt::Display for ProjectId {
97    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
98        self.0.fmt(formatter)
99    }
100}
101
102impl fmt::Display for TaskId {
103    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
104        self.0.fmt(formatter)
105    }
106}
107
108impl fmt::Display for InvalidWorkspaceId {
109    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
110        formatter.write_str("workspace ID must be 16 Crockford Base32 characters")
111    }
112}
113
114impl fmt::Display for InvalidProjectId {
115    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
116        formatter.write_str("project ID must be 16 Crockford Base32 characters")
117    }
118}
119
120impl fmt::Display for InvalidTaskId {
121    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
122        formatter.write_str("task ID must be 16 Crockford Base32 characters")
123    }
124}
125
126impl std::error::Error for InvalidWorkspaceId {}
127impl std::error::Error for InvalidProjectId {}
128impl std::error::Error for InvalidTaskId {}
129
130impl FromStr for WorkspaceId {
131    type Err = InvalidWorkspaceId;
132
133    fn from_str(value: &str) -> Result<Self, Self::Err> {
134        if value.len() == 16 && value.bytes().all(|byte| BASE32.contains(&byte)) {
135            Ok(Self(value.to_string()))
136        } else {
137            Err(InvalidWorkspaceId)
138        }
139    }
140}
141
142impl FromStr for ProjectId {
143    type Err = InvalidProjectId;
144
145    fn from_str(value: &str) -> Result<Self, Self::Err> {
146        if value.len() == 16 && value.bytes().all(|byte| BASE32.contains(&byte)) {
147            Ok(Self(value.to_string()))
148        } else {
149            Err(InvalidProjectId)
150        }
151    }
152}
153
154impl FromStr for TaskId {
155    type Err = InvalidTaskId;
156
157    fn from_str(value: &str) -> Result<Self, Self::Err> {
158        if value.len() == 16 && value.bytes().all(|byte| BASE32.contains(&byte)) {
159            Ok(Self(value.to_string()))
160        } else {
161            Err(InvalidTaskId)
162        }
163    }
164}
165
166impl TryFrom<String> for WorkspaceId {
167    type Error = InvalidWorkspaceId;
168
169    fn try_from(value: String) -> Result<Self, Self::Error> {
170        value.parse()
171    }
172}
173
174impl TryFrom<String> for ProjectId {
175    type Error = InvalidProjectId;
176
177    fn try_from(value: String) -> Result<Self, Self::Error> {
178        value.parse()
179    }
180}
181
182impl TryFrom<String> for TaskId {
183    type Error = InvalidTaskId;
184
185    fn try_from(value: String) -> Result<Self, Self::Error> {
186        value.parse()
187    }
188}
189
190impl<'de> Deserialize<'de> for WorkspaceId {
191    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
192    where
193        D: Deserializer<'de>,
194    {
195        String::deserialize(deserializer)?
196            .parse()
197            .map_err(serde::de::Error::custom)
198    }
199}
200
201impl<'de> Deserialize<'de> for ProjectId {
202    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
203    where
204        D: Deserializer<'de>,
205    {
206        String::deserialize(deserializer)?
207            .parse()
208            .map_err(serde::de::Error::custom)
209    }
210}
211
212impl<'de> Deserialize<'de> for TaskId {
213    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
214    where
215        D: Deserializer<'de>,
216    {
217        String::deserialize(deserializer)?
218            .parse()
219            .map_err(serde::de::Error::custom)
220    }
221}
222
223impl Type<Sqlite> for WorkspaceId {
224    fn type_info() -> SqliteTypeInfo {
225        <String as Type<Sqlite>>::type_info()
226    }
227}
228
229impl Type<Sqlite> for ProjectId {
230    fn type_info() -> SqliteTypeInfo {
231        <String as Type<Sqlite>>::type_info()
232    }
233}
234
235impl Type<Sqlite> for TaskId {
236    fn type_info() -> SqliteTypeInfo {
237        <String as Type<Sqlite>>::type_info()
238    }
239}
240
241impl Encode<'_, Sqlite> for WorkspaceId {
242    fn encode_by_ref(
243        &self,
244        buffer: &mut <Sqlite as Database>::ArgumentBuffer,
245    ) -> Result<IsNull, BoxDynError> {
246        <String as Encode<Sqlite>>::encode_by_ref(&self.0, buffer)
247    }
248}
249
250impl Encode<'_, Sqlite> for ProjectId {
251    fn encode_by_ref(
252        &self,
253        buffer: &mut <Sqlite as Database>::ArgumentBuffer,
254    ) -> Result<IsNull, BoxDynError> {
255        <String as Encode<Sqlite>>::encode_by_ref(&self.0, buffer)
256    }
257}
258
259impl Encode<'_, Sqlite> for TaskId {
260    fn encode_by_ref(
261        &self,
262        buffer: &mut <Sqlite as Database>::ArgumentBuffer,
263    ) -> Result<IsNull, BoxDynError> {
264        <String as Encode<Sqlite>>::encode_by_ref(&self.0, buffer)
265    }
266}
267
268impl<'row> Decode<'row, Sqlite> for WorkspaceId {
269    fn decode(value: SqliteValueRef<'row>) -> Result<Self, BoxDynError> {
270        String::decode(value)?.parse().map_err(Into::into)
271    }
272}
273
274impl<'row> Decode<'row, Sqlite> for ProjectId {
275    fn decode(value: SqliteValueRef<'row>) -> Result<Self, BoxDynError> {
276        String::decode(value)?.parse().map_err(Into::into)
277    }
278}
279
280impl<'row> Decode<'row, Sqlite> for TaskId {
281    fn decode(value: SqliteValueRef<'row>) -> Result<Self, BoxDynError> {
282        String::decode(value)?.parse().map_err(Into::into)
283    }
284}
285
286pub fn now() -> String {
287    let output = Command::new("date")
288        .arg("-u")
289        .arg("+%Y-%m-%dT%H:%M:%SZ")
290        .output();
291    output
292        .ok()
293        .and_then(|out| String::from_utf8(out.stdout).ok())
294        .map(|s| s.trim().to_string())
295        .filter(|s| !s.is_empty())
296        .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string())
297}
298
299pub fn new_id() -> String {
300    let mut bytes = [0u8; 10];
301    fill_random(&mut bytes).expect("fill random bytes");
302    encode_crockford(&bytes)
303}
304
305pub fn encode_crockford(bytes: &[u8; 10]) -> String {
306    let mut value = u128::from_be_bytes([
307        0, 0, 0, 0, 0, 0, bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6],
308        bytes[7], bytes[8], bytes[9],
309    ]);
310    let mut chars = [b'0'; 16];
311    for i in (0..16).rev() {
312        chars[i] = BASE32[(value & 31) as usize];
313        value >>= 5;
314    }
315    String::from_utf8(chars.to_vec()).expect("base32 is utf8")
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use crate::test_support::test_conn;
322
323    #[test]
324    fn workspace_ids_validate_domain_id_shape() {
325        assert!("0123456789ABCDEF".parse::<WorkspaceId>().is_ok());
326        assert!("0123456789ABCDE".parse::<WorkspaceId>().is_err());
327        assert!("0123456789abcdef".parse::<WorkspaceId>().is_err());
328        assert!("0123456789ABCDEI".parse::<WorkspaceId>().is_err());
329    }
330
331    #[test]
332    fn workspace_ids_serialize_as_validated_strings() {
333        let id: WorkspaceId = serde_json::from_str("\"0123456789ABCDEF\"").unwrap();
334        assert_eq!(serde_json::to_string(&id).unwrap(), "\"0123456789ABCDEF\"");
335        assert!(serde_json::from_str::<WorkspaceId>("\"invalid\"").is_err());
336    }
337
338    #[test]
339    fn project_ids_validate_domain_id_shape() {
340        assert!("0123456789ABCDEF".parse::<ProjectId>().is_ok());
341        assert!("0123456789ABCDE".parse::<ProjectId>().is_err());
342        assert!("0123456789abcdef".parse::<ProjectId>().is_err());
343        assert!("0123456789ABCDEI".parse::<ProjectId>().is_err());
344    }
345
346    #[test]
347    fn project_ids_serialize_as_validated_strings() {
348        let id: ProjectId = serde_json::from_str("\"0123456789ABCDEF\"").unwrap();
349        assert_eq!(serde_json::to_string(&id).unwrap(), "\"0123456789ABCDEF\"");
350        assert!(serde_json::from_str::<ProjectId>("\"invalid\"").is_err());
351    }
352
353    #[test]
354    fn task_ids_validate_domain_id_shape() {
355        assert!("0123456789ABCDEF".parse::<TaskId>().is_ok());
356        assert!("0123456789ABCDE".parse::<TaskId>().is_err());
357        assert!("0123456789abcdef".parse::<TaskId>().is_err());
358        assert!("0123456789ABCDEI".parse::<TaskId>().is_err());
359    }
360
361    #[test]
362    fn task_ids_serialize_as_validated_strings() {
363        let id: TaskId = serde_json::from_str("\"0123456789ABCDEF\"").unwrap();
364        assert_eq!(serde_json::to_string(&id).unwrap(), "\"0123456789ABCDEF\"");
365        assert!(serde_json::from_str::<TaskId>("\"invalid\"").is_err());
366    }
367
368    #[tokio::test]
369    async fn task_ids_bind_and_decode_as_sqlite_text() {
370        let (_temp, mut conn) = test_conn().await;
371        let id: TaskId = "0123456789ABCDEF".parse().unwrap();
372        let decoded = sqlx::query_scalar::<_, TaskId>("SELECT ?")
373            .bind(&id)
374            .fetch_one(&mut *conn)
375            .await
376            .unwrap();
377        assert_eq!(decoded, id);
378
379        let invalid = sqlx::query_scalar::<_, TaskId>("SELECT 'invalid'")
380            .fetch_one(&mut *conn)
381            .await;
382        assert!(invalid.is_err());
383    }
384
385    #[tokio::test]
386    async fn project_ids_bind_and_decode_as_sqlite_text() {
387        let (_temp, mut conn) = test_conn().await;
388        let id: ProjectId = "0123456789ABCDEF".parse().unwrap();
389        let decoded = sqlx::query_scalar::<_, ProjectId>("SELECT ?")
390            .bind(&id)
391            .fetch_one(&mut *conn)
392            .await
393            .unwrap();
394        assert_eq!(decoded, id);
395
396        let invalid = sqlx::query_scalar::<_, ProjectId>("SELECT 'invalid'")
397            .fetch_one(&mut *conn)
398            .await;
399        assert!(invalid.is_err());
400    }
401
402    #[tokio::test]
403    async fn workspace_ids_bind_and_decode_as_sqlite_text() {
404        let (_temp, mut conn) = test_conn().await;
405        let id: WorkspaceId = "0123456789ABCDEF".parse().unwrap();
406        let decoded = sqlx::query_scalar::<_, WorkspaceId>("SELECT ?")
407            .bind(&id)
408            .fetch_one(&mut *conn)
409            .await
410            .unwrap();
411        assert_eq!(decoded, id);
412
413        let invalid = sqlx::query_scalar::<_, WorkspaceId>("SELECT 'invalid'")
414            .fetch_one(&mut *conn)
415            .await;
416        assert!(invalid.is_err());
417    }
418}