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}