Skip to main content

pbox_core/
id.rs

1use rand::Rng;
2use serde::de::Error as DeError;
3use serde::{Deserialize, Deserializer, Serialize, Serializer};
4use std::fmt;
5use std::str::FromStr;
6use thiserror::Error;
7
8const PREFIX: &str = "pbx_";
9const LENGTH: usize = 8;
10// Crockford-style lowercase alphabet: ambiguous i/l/o/u are intentionally omitted.
11const ALPHABET: &[u8] = b"0123456789abcdefghjkmnpqrstvwxyz";
12
13#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
14pub struct PboxId(String);
15
16#[derive(Debug, Error, Clone, PartialEq, Eq)]
17pub enum PboxIdError {
18    #[error("public id must start with pbx_")]
19    InvalidPrefix,
20    #[error("public id must contain exactly 8 characters after pbx_")]
21    InvalidLength,
22    #[error("public id contains an invalid character: {0}")]
23    InvalidCharacter(char),
24}
25
26impl PboxId {
27    pub fn generate() -> Self {
28        let mut rng = rand::thread_rng();
29        let value: String = (0..LENGTH)
30            .map(|_| {
31                let index = rng.gen_range(0..ALPHABET.len());
32                ALPHABET[index] as char
33            })
34            .collect();
35        Self(format!("{PREFIX}{value}"))
36    }
37
38    pub fn parse(value: &str) -> Result<Self, PboxIdError> {
39        if !value.starts_with(PREFIX) {
40            return Err(PboxIdError::InvalidPrefix);
41        }
42        let suffix = &value[PREFIX.len()..];
43        if suffix.chars().count() != LENGTH {
44            return Err(PboxIdError::InvalidLength);
45        }
46        for character in suffix.chars() {
47            if !ALPHABET.contains(&(character as u8)) {
48                return Err(PboxIdError::InvalidCharacter(character));
49            }
50        }
51        Ok(Self(value.to_owned()))
52    }
53
54    pub fn as_str(&self) -> &str {
55        &self.0
56    }
57}
58
59impl fmt::Display for PboxId {
60    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
61        formatter.write_str(&self.0)
62    }
63}
64
65impl FromStr for PboxId {
66    type Err = PboxIdError;
67
68    fn from_str(value: &str) -> Result<Self, Self::Err> {
69        Self::parse(value)
70    }
71}
72
73impl Serialize for PboxId {
74    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
75    where
76        S: Serializer,
77    {
78        serializer.serialize_str(self.as_str())
79    }
80}
81
82impl<'de> Deserialize<'de> for PboxId {
83    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
84    where
85        D: Deserializer<'de>,
86    {
87        let value = String::deserialize(deserializer)?;
88        Self::parse(&value).map_err(D::Error::custom)
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn generated_ids_have_the_stable_shape() {
98        let id = PboxId::generate();
99        assert!(id.as_str().starts_with("pbx_"));
100        assert_eq!(id.as_str().len(), 12);
101        assert_eq!(PboxId::parse(id.as_str()).expect("generated id"), id);
102    }
103
104    #[test]
105    fn parser_rejects_ambiguous_or_malformed_values() {
106        for value in [
107            "box_t3yzd9y3",
108            "pbx_t3yzd9y",
109            "pbx_t3yzd9y3!",
110            "pbx_T3yzd9y3",
111        ] {
112            assert!(PboxId::parse(value).is_err(), "accepted {value}");
113        }
114    }
115}