1use std::fmt;
4use std::str::FromStr;
5
6use minicbor::decode::{Decoder, Error as DecodeError};
7use minicbor::encode::{Encoder, Error as EncodeError, Write};
8use minicbor::{Decode, Encode};
9
10pub const MAX_NAME_LEN: usize = 64;
12
13#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
35pub struct StreamName(String);
36
37#[derive(Clone, Debug, PartialEq, Eq)]
39pub enum InvalidName {
40 Empty,
42 TooLong(usize),
44 BadStart(char),
46 BadCharacter(char),
48}
49
50impl fmt::Display for InvalidName {
51 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52 match self {
53 Self::Empty => f.write_str("a stream name cannot be empty"),
54 Self::TooLong(len) => {
55 write!(f, "a stream name is at most {MAX_NAME_LEN} characters, this one is {len}")
56 }
57 Self::BadStart(c) => write!(f, "a stream name starts with a letter or a digit, not {c:?}"),
58 Self::BadCharacter(c) => write!(
59 f,
60 "a stream name is made of lowercase letters, digits, '-', '_' and '.', and {c:?} is none of them"
61 ),
62 }
63 }
64}
65
66impl std::error::Error for InvalidName {}
67
68impl StreamName {
69 pub fn new(name: impl Into<String>) -> Result<Self, InvalidName> {
75 let name = name.into();
76 validate(&name)?;
77 Ok(Self(name))
78 }
79
80 pub fn as_str(&self) -> &str {
82 &self.0
83 }
84}
85
86fn validate(name: &str) -> Result<(), InvalidName> {
87 let mut chars = name.chars();
88 let first = chars.next().ok_or(InvalidName::Empty)?;
89 if name.len() > MAX_NAME_LEN {
90 return Err(InvalidName::TooLong(name.len()));
91 }
92 if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
93 return Err(InvalidName::BadStart(first));
94 }
95 match chars.find(|&c| !(c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.'))) {
96 Some(c) => Err(InvalidName::BadCharacter(c)),
97 None => Ok(()),
98 }
99}
100
101impl FromStr for StreamName {
102 type Err = InvalidName;
103
104 fn from_str(s: &str) -> Result<Self, Self::Err> {
105 Self::new(s)
106 }
107}
108
109impl fmt::Display for StreamName {
110 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111 f.write_str(&self.0)
112 }
113}
114
115impl AsRef<str> for StreamName {
116 fn as_ref(&self) -> &str {
117 &self.0
118 }
119}
120
121impl<C> Encode<C> for StreamName {
122 fn encode<W: Write>(&self, e: &mut Encoder<W>, _: &mut C) -> Result<(), EncodeError<W::Error>> {
123 e.str(&self.0)?.ok()
124 }
125}
126
127impl<'b, C> Decode<'b, C> for StreamName {
130 fn decode(d: &mut Decoder<'b>, _: &mut C) -> Result<Self, DecodeError> {
131 let position = d.position();
132 let text = d.str()?;
133 Self::new(text).map_err(|e| DecodeError::message(e.to_string()).at(position))
134 }
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140
141 #[test]
142 fn accepts_the_documented_alphabet() {
143 for name in ["a", "0", "notes", "vault-2026", "a_b", "x.y.z", "9lives", &"a".repeat(MAX_NAME_LEN)] {
144 assert!(StreamName::new(name).is_ok(), "{name:?} should be a valid name");
145 }
146 }
147
148 #[test]
149 fn refuses_with_the_first_reason() {
150 assert_eq!(StreamName::new(""), Err(InvalidName::Empty));
151 assert_eq!(StreamName::new("a".repeat(MAX_NAME_LEN + 1)), Err(InvalidName::TooLong(MAX_NAME_LEN + 1)));
152 assert_eq!(StreamName::new("-a"), Err(InvalidName::BadStart('-')));
153 assert_eq!(StreamName::new(".a"), Err(InvalidName::BadStart('.')));
154 assert_eq!(StreamName::new("Notes"), Err(InvalidName::BadStart('N')));
155 assert_eq!(StreamName::new("no tes"), Err(InvalidName::BadCharacter(' ')));
156 assert_eq!(StreamName::new("a/b"), Err(InvalidName::BadCharacter('/')));
157 assert_eq!(StreamName::new("a:b"), Err(InvalidName::BadCharacter(':')));
158 assert_eq!(StreamName::new("заметки"), Err(InvalidName::BadStart('з')));
159 assert_eq!(StreamName::new("aé"), Err(InvalidName::BadCharacter('é')));
160 }
161
162 #[test]
163 fn decoding_refuses_what_parsing_refuses() {
164 let encoded = minicbor::to_vec("Bad Name").unwrap();
165 assert!(minicbor::decode::<StreamName>(&encoded).is_err());
166 let encoded = minicbor::to_vec("good-name").unwrap();
167 assert_eq!(minicbor::decode::<StreamName>(&encoded).unwrap().as_str(), "good-name");
168 }
169}