torrust_tracker_deployer_lib/domain/
profile_name.rs1use std::fmt;
18use std::str::FromStr;
19
20use serde::{Deserialize, Serialize};
21use thiserror::Error;
22
23#[derive(Debug, Error, PartialEq)]
25pub enum ProfileNameError {
26 #[error("Profile name cannot be empty")]
27 Empty,
28
29 #[error("Profile name must be 63 characters or less, got {length} characters")]
30 TooLong { length: usize },
31
32 #[error("Profile name must not start with a digit or dash")]
33 InvalidFirstCharacter,
34
35 #[error("Profile name must not end with a dash")]
36 InvalidLastCharacter,
37
38 #[error("Profile name must contain only ASCII letters, numbers, and dashes")]
39 InvalidCharacters,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
71#[serde(try_from = "String")]
72pub struct ProfileName(String);
73
74impl ProfileName {
75 pub fn new<S: Into<String>>(name: S) -> Result<Self, ProfileNameError> {
113 let name = name.into();
114 Self::validate(&name)?;
115 Ok(Self(name))
116 }
117
118 #[must_use]
120 pub fn as_str(&self) -> &str {
121 &self.0
122 }
123
124 fn validate(name: &str) -> Result<(), ProfileNameError> {
135 if name.is_empty() {
137 return Err(ProfileNameError::Empty);
138 }
139 if name.len() > 63 {
140 return Err(ProfileNameError::TooLong { length: name.len() });
141 }
142
143 if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
145 return Err(ProfileNameError::InvalidCharacters);
146 }
147
148 if let Some(first_char) = name.chars().next() {
150 if first_char.is_ascii_digit() || first_char == '-' {
151 return Err(ProfileNameError::InvalidFirstCharacter);
152 }
153 }
154
155 if let Some(last_char) = name.chars().last() {
157 if last_char == '-' {
158 return Err(ProfileNameError::InvalidLastCharacter);
159 }
160 }
161
162 Ok(())
163 }
164}
165
166impl fmt::Display for ProfileName {
167 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
168 write!(f, "{}", self.0)
169 }
170}
171
172impl FromStr for ProfileName {
173 type Err = ProfileNameError;
174
175 fn from_str(s: &str) -> Result<Self, Self::Err> {
176 Self::new(s)
177 }
178}
179
180impl TryFrom<String> for ProfileName {
181 type Error = ProfileNameError;
182
183 fn try_from(value: String) -> Result<Self, Self::Error> {
184 Self::new(value)
185 }
186}
187
188impl From<ProfileName> for String {
189 fn from(profile_name: ProfileName) -> Self {
190 profile_name.0
191 }
192}
193
194impl AsRef<str> for ProfileName {
195 fn as_ref(&self) -> &str {
196 &self.0
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203
204 #[test]
205 fn it_should_create_valid_profile_name() {
206 let profile = ProfileName::new("torrust-profile").unwrap();
207 assert_eq!(profile.as_str(), "torrust-profile");
208 }
209
210 #[test]
211 fn it_should_accept_string_input() {
212 let profile_str = "test-profile".to_string();
213 let profile = ProfileName::new(profile_str).unwrap();
214 assert_eq!(profile.as_str(), "test-profile");
215 }
216
217 #[test]
218 fn it_should_accept_formatted_string() {
219 let env = "production";
220 let profile = ProfileName::new(format!("torrust-profile-{env}")).unwrap();
221 assert_eq!(profile.as_str(), "torrust-profile-production");
222 }
223
224 #[test]
225 fn it_should_create_profile_name_with_numbers() {
226 let profile = ProfileName::new("profile123").unwrap();
227 assert_eq!(profile.as_str(), "profile123");
228 }
229
230 #[test]
231 fn it_should_create_profile_name_with_dashes() {
232 let profile = ProfileName::new("test-profile-name").unwrap();
233 assert_eq!(profile.as_str(), "test-profile-name");
234 }
235
236 #[test]
237 fn it_should_reject_empty_profile_name() {
238 let result = ProfileName::new("");
239 assert!(matches!(result, Err(ProfileNameError::Empty)));
240 }
241
242 #[test]
243 fn it_should_reject_profile_name_starting_with_digit() {
244 let result = ProfileName::new("1profile");
245 assert!(matches!(
246 result,
247 Err(ProfileNameError::InvalidFirstCharacter)
248 ));
249 }
250
251 #[test]
252 fn it_should_reject_profile_name_starting_with_dash() {
253 let result = ProfileName::new("-profile");
254 assert!(matches!(
255 result,
256 Err(ProfileNameError::InvalidFirstCharacter)
257 ));
258 }
259
260 #[test]
261 fn it_should_reject_profile_name_ending_with_dash() {
262 let result = ProfileName::new("profile-");
263 assert!(matches!(
264 result,
265 Err(ProfileNameError::InvalidLastCharacter)
266 ));
267 }
268
269 #[test]
270 fn it_should_reject_profile_name_with_invalid_characters() {
271 let result = ProfileName::new("test@profile");
272 assert!(matches!(result, Err(ProfileNameError::InvalidCharacters)));
273
274 let result = ProfileName::new("test_profile");
275 assert!(matches!(result, Err(ProfileNameError::InvalidCharacters)));
276
277 let result = ProfileName::new("test profile");
278 assert!(matches!(result, Err(ProfileNameError::InvalidCharacters)));
279 }
280
281 #[test]
282 fn it_should_reject_profile_name_too_long() {
283 let long_name = "a".repeat(64);
284 let result = ProfileName::new(long_name);
285 assert!(matches!(
286 result,
287 Err(ProfileNameError::TooLong { length: 64 })
288 ));
289 }
290
291 #[test]
292 fn it_should_accept_max_length_profile_name() {
293 let max_length_name = "a".repeat(63);
294 let result = ProfileName::new(max_length_name);
295 assert!(result.is_ok());
296 }
297
298 #[test]
299 fn it_should_implement_from_str() {
300 let profile: ProfileName = "test-profile".parse().unwrap();
301 assert_eq!(profile.as_str(), "test-profile");
302
303 let invalid: Result<ProfileName, _> = "".parse();
304 assert!(invalid.is_err());
305 }
306
307 #[test]
308 fn it_should_implement_try_from_string() {
309 let profile = ProfileName::try_from("test-profile".to_string()).unwrap();
310 assert_eq!(profile.as_str(), "test-profile");
311
312 let invalid = ProfileName::try_from(String::new());
313 assert!(invalid.is_err());
314 }
315
316 #[test]
317 fn it_should_convert_to_string() {
318 let profile = ProfileName::new("test-profile").unwrap();
319 let string: String = profile.into();
320 assert_eq!(string, "test-profile");
321 }
322
323 #[test]
324 fn it_should_implement_as_ref() {
325 let profile = ProfileName::new("test-profile").unwrap();
326 let s: &str = profile.as_ref();
327 assert_eq!(s, "test-profile");
328 }
329
330 #[test]
331 fn it_should_display_profile_name() {
332 let profile = ProfileName::new("test-profile").unwrap();
333 assert_eq!(format!("{profile}"), "test-profile");
334 }
335
336 #[test]
337 fn it_should_be_cloneable() {
338 let profile = ProfileName::new("test-profile").unwrap();
339 let cloned = profile.clone();
340 assert_eq!(profile, cloned);
341 }
342
343 #[test]
344 fn it_should_be_hashable() {
345 use std::collections::HashMap;
346
347 let profile = ProfileName::new("test-profile").unwrap();
348 let mut map = HashMap::new();
349 map.insert(profile, "value");
350 assert_eq!(map.len(), 1);
351 }
352
353 #[test]
354 fn it_should_serialize_and_deserialize() {
355 let profile = ProfileName::new("test-profile").unwrap();
356 let json = serde_json::to_string(&profile).unwrap();
357 assert_eq!(json, "\"test-profile\"");
358
359 let deserialized: ProfileName = serde_json::from_str(&json).unwrap();
360 assert_eq!(deserialized, profile);
361 }
362
363 #[test]
364 fn it_should_fail_deserialization_for_invalid_names() {
365 let invalid_json = "\"\""; let result: Result<ProfileName, _> = serde_json::from_str(invalid_json);
367 assert!(result.is_err());
368
369 let invalid_json = "\"-invalid\""; let result: Result<ProfileName, _> = serde_json::from_str(invalid_json);
371 assert!(result.is_err());
372 }
373}