Skip to main content

objects/object/
spool_id.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use 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}