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(×tamp).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}