emergent_client/types/
primitive_name.rs1use serde::{Deserialize, Deserializer, Serialize, Serializer};
4use std::fmt;
5use std::str::FromStr;
6
7#[derive(Debug, Clone, PartialEq, Eq, Hash)]
20pub struct PrimitiveName(String);
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum InvalidPrimitiveName {
25 Empty,
27 InvalidCharacters { value: String },
29 InvalidStructure { value: String, reason: &'static str },
31}
32
33impl fmt::Display for InvalidPrimitiveName {
34 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35 match self {
36 Self::Empty => write!(f, "primitive name cannot be empty"),
37 Self::InvalidCharacters { value } => {
38 write!(f, "primitive name '{value}' contains invalid characters")
39 }
40 Self::InvalidStructure { value, reason } => {
41 write!(f, "primitive name '{value}' is invalid: {reason}")
42 }
43 }
44 }
45}
46
47impl std::error::Error for InvalidPrimitiveName {}
48
49impl PrimitiveName {
50 pub const MAX_LENGTH: usize = 64;
52
53 pub fn new(value: impl Into<String>) -> Result<Self, InvalidPrimitiveName> {
59 let value = value.into();
60
61 if value.is_empty() {
62 return Err(InvalidPrimitiveName::Empty);
63 }
64
65 if value.len() > Self::MAX_LENGTH {
66 return Err(InvalidPrimitiveName::InvalidStructure {
67 value,
68 reason: "cannot exceed 64 characters",
69 });
70 }
71
72 let mut chars = value.chars();
73 if let Some(first) = chars.next()
74 && !first.is_ascii_lowercase()
75 {
76 return Err(InvalidPrimitiveName::InvalidStructure {
77 value,
78 reason: "must start with lowercase letter",
79 });
80 }
81
82 if !value
83 .chars()
84 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
85 {
86 return Err(InvalidPrimitiveName::InvalidCharacters { value });
87 }
88
89 Ok(Self(value))
90 }
91
92 #[must_use]
94 pub fn as_str(&self) -> &str {
95 &self.0
96 }
97
98 #[must_use]
102 pub fn is_default(&self) -> bool {
103 self.0 == "unknown"
104 }
105}
106
107impl fmt::Display for PrimitiveName {
108 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109 write!(f, "{}", self.0)
110 }
111}
112
113impl FromStr for PrimitiveName {
114 type Err = InvalidPrimitiveName;
115
116 fn from_str(s: &str) -> Result<Self, Self::Err> {
117 Self::new(s)
118 }
119}
120
121impl AsRef<str> for PrimitiveName {
122 fn as_ref(&self) -> &str {
123 &self.0
124 }
125}
126
127impl Serialize for PrimitiveName {
128 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
129 where
130 S: Serializer,
131 {
132 self.0.serialize(serializer)
133 }
134}
135
136impl<'de> Deserialize<'de> for PrimitiveName {
137 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
138 where
139 D: Deserializer<'de>,
140 {
141 let s = String::deserialize(deserializer)?;
142 Self::new(s).map_err(serde::de::Error::custom)
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149
150 #[test]
151 fn valid_primitive_names() {
152 assert!(PrimitiveName::new("timer").is_ok());
153 assert!(PrimitiveName::new("user_service").is_ok());
154 assert!(PrimitiveName::new("email-handler").is_ok());
155 assert!(PrimitiveName::new("filter123").is_ok());
156 assert!(PrimitiveName::new("my_handler_v2").is_ok());
157 }
158
159 #[test]
160 fn empty_is_invalid() {
161 let result = PrimitiveName::new("");
162 assert!(matches!(result, Err(InvalidPrimitiveName::Empty)));
163 }
164
165 #[test]
166 fn starting_with_digit_is_invalid() {
167 let result = PrimitiveName::new("1invalid");
168 assert!(matches!(
169 result,
170 Err(InvalidPrimitiveName::InvalidStructure { .. })
171 ));
172 }
173
174 #[test]
175 fn starting_with_uppercase_is_invalid() {
176 let result = PrimitiveName::new("Invalid");
177 assert!(matches!(
178 result,
179 Err(InvalidPrimitiveName::InvalidStructure { .. })
180 ));
181 }
182
183 #[test]
184 fn uppercase_in_middle_is_invalid() {
185 let result = PrimitiveName::new("inValid");
186 assert!(matches!(
187 result,
188 Err(InvalidPrimitiveName::InvalidCharacters { .. })
189 ));
190 }
191
192 #[test]
193 fn too_long_is_invalid() {
194 let result = PrimitiveName::new("a".repeat(65));
195 assert!(matches!(
196 result,
197 Err(InvalidPrimitiveName::InvalidStructure { .. })
198 ));
199 }
200
201 #[test]
202 fn max_length_is_valid() {
203 let result = PrimitiveName::new("a".repeat(64));
204 assert!(result.is_ok());
205 }
206
207 #[test]
208 fn special_characters_invalid() {
209 assert!(PrimitiveName::new("has space").is_err());
210 assert!(PrimitiveName::new("has.dot").is_err());
211 assert!(PrimitiveName::new("has@at").is_err());
212 }
213
214 #[test]
215 fn serde_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
216 let name = PrimitiveName::new("timer")?;
217 let json = serde_json::to_string(&name)?;
218 let restored: PrimitiveName = serde_json::from_str(&json)?;
219 assert_eq!(name, restored);
220 Ok(())
221 }
222
223 #[test]
224 fn from_str_works() -> Result<(), InvalidPrimitiveName> {
225 let name: PrimitiveName = "timer".parse()?;
226 assert_eq!(name.as_str(), "timer");
227 Ok(())
228 }
229}