objects/object/
spool_id.rs1use serde::{Deserialize, Serialize};
4
5#[derive(Clone, Debug, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
6#[serde(try_from = "String", into = "String")]
7pub struct SpoolId(String);
8
9impl SpoolId {
10 pub fn parse(value: impl Into<String>) -> Result<Self, SpoolIdParseError> {
11 let value = value.into();
12 let mut segments = value.split('/');
13 let namespace = segments.next().unwrap_or_default();
14 let name = segments.next().unwrap_or_default();
15 if segments.next().is_some() || !valid_segment(namespace) || !valid_segment(name) {
16 return Err(SpoolIdParseError(value));
17 }
18 Ok(Self(value))
19 }
20
21 pub fn as_str(&self) -> &str {
22 &self.0
23 }
24}
25
26fn valid_segment(segment: &str) -> bool {
27 !segment.is_empty()
28 && segment.bytes().all(|byte| {
29 byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_' | b'.')
30 })
31 && segment
32 .as_bytes()
33 .first()
34 .is_some_and(u8::is_ascii_alphanumeric)
35 && segment
36 .as_bytes()
37 .last()
38 .is_some_and(u8::is_ascii_alphanumeric)
39}
40
41impl std::fmt::Display for SpoolId {
42 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43 formatter.write_str(&self.0)
44 }
45}
46
47impl TryFrom<String> for SpoolId {
48 type Error = SpoolIdParseError;
49
50 fn try_from(value: String) -> Result<Self, Self::Error> {
51 Self::parse(value)
52 }
53}
54
55impl From<SpoolId> for String {
56 fn from(value: SpoolId) -> Self {
57 value.0
58 }
59}
60
61#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
62#[error("invalid spool id '{0}'; expected canonical namespace/name")]
63pub struct SpoolIdParseError(String);
64
65#[cfg(test)]
66mod tests {
67 use super::*;
68
69 #[test]
70 fn accepts_canonical_namespace_and_name() {
71 let id = SpoolId::parse("acme/api-v2").unwrap();
72 assert_eq!(id.as_str(), "acme/api-v2");
73 }
74
75 #[test]
76 fn rejects_noncanonical_values() {
77 for value in ["repo", "Acme/repo", "acme/repo/child", "acme/-repo"] {
78 assert!(SpoolId::parse(value).is_err(), "accepted {value}");
79 }
80 }
81}