torrust_tracker_deployer_lib/domain/
instance_name.rs1use std::fmt;
21use std::str::FromStr;
22
23use serde::{Deserialize, Serialize};
24use thiserror::Error;
25
26#[derive(Debug, Error, PartialEq)]
28pub enum InstanceNameError {
29 #[error("Instance name cannot be empty")]
30 Empty,
31
32 #[error("Instance name must be 63 characters or less, got {length} characters")]
33 TooLong { length: usize },
34
35 #[error("Instance name must not start with a digit or dash")]
36 InvalidFirstCharacter,
37
38 #[error("Instance name must not end with a dash")]
39 InvalidLastCharacter,
40
41 #[error("Instance name must contain only ASCII letters, numbers, and dashes")]
42 InvalidCharacters,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
82#[serde(try_from = "String")]
83pub struct InstanceName(String);
84
85impl InstanceName {
86 pub fn new<S: Into<String>>(name: S) -> Result<Self, InstanceNameError> {
127 let name = name.into();
128 Self::validate(&name)?;
129 Ok(Self(name))
130 }
131
132 #[must_use]
134 pub fn as_str(&self) -> &str {
135 &self.0
136 }
137
138 fn validate(name: &str) -> Result<(), InstanceNameError> {
149 if name.is_empty() {
151 return Err(InstanceNameError::Empty);
152 }
153 if name.len() > 63 {
154 return Err(InstanceNameError::TooLong { length: name.len() });
155 }
156
157 if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
159 return Err(InstanceNameError::InvalidCharacters);
160 }
161
162 if let Some(first_char) = name.chars().next() {
164 if first_char.is_ascii_digit() || first_char == '-' {
165 return Err(InstanceNameError::InvalidFirstCharacter);
166 }
167 }
168
169 if name.ends_with('-') {
171 return Err(InstanceNameError::InvalidLastCharacter);
172 }
173
174 Ok(())
175 }
176}
177
178impl fmt::Display for InstanceName {
179 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180 write!(f, "{}", self.0)
181 }
182}
183
184impl FromStr for InstanceName {
185 type Err = InstanceNameError;
186
187 fn from_str(s: &str) -> Result<Self, InstanceNameError> {
188 Self::new(s.to_string())
189 }
190}
191
192impl AsRef<str> for InstanceName {
193 fn as_ref(&self) -> &str {
194 &self.0
195 }
196}
197
198impl TryFrom<String> for InstanceName {
199 type Error = InstanceNameError;
200
201 fn try_from(value: String) -> Result<Self, InstanceNameError> {
202 Self::new(value)
203 }
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 #[test]
211 fn it_should_create_valid_instance_name() {
212 let name = InstanceName::new("test-instance").unwrap();
213 assert_eq!(name.as_str(), "test-instance");
214 }
215
216 #[test]
217 fn it_should_accept_string_slice_and_owned_string() {
218 let name1 = InstanceName::new("test-instance").unwrap();
220 assert_eq!(name1.as_str(), "test-instance");
221
222 let name2 = InstanceName::new("test-instance".to_string()).unwrap();
224 assert_eq!(name2.as_str(), "test-instance");
225
226 let name3 = InstanceName::new(format!("app-{}", "prod")).unwrap();
228 assert_eq!(name3.as_str(), "app-prod");
229
230 assert_eq!(name1, name2);
232 }
233
234 #[test]
235 fn it_should_create_instance_name_with_numbers() {
236 let name = InstanceName::new("test123").unwrap();
237 assert_eq!(name.as_str(), "test123");
238 }
239
240 #[test]
241 fn it_should_create_instance_name_with_dashes() {
242 let name = InstanceName::new("test-instance-name").unwrap();
243 assert_eq!(name.as_str(), "test-instance-name");
244 }
245
246 #[test]
247 fn it_should_create_single_character_name() {
248 let name = InstanceName::new("a").unwrap();
249 assert_eq!(name.as_str(), "a");
250 }
251
252 #[test]
253 fn it_should_create_63_character_name() {
254 let long_name = "a".repeat(63);
255 let name = InstanceName::new(long_name.clone()).unwrap();
256 assert_eq!(name.as_str(), long_name);
257 }
258
259 #[test]
260 fn it_should_reject_empty_name() {
261 let result = InstanceName::new("");
262 assert!(result.is_err());
263 assert_eq!(result.unwrap_err(), InstanceNameError::Empty);
264 }
265
266 #[test]
267 fn it_should_reject_name_longer_than_63_characters() {
268 let long_name = "a".repeat(64);
269 let result = InstanceName::new(long_name);
270 assert!(result.is_err());
271 assert_eq!(
272 result.unwrap_err(),
273 InstanceNameError::TooLong { length: 64 }
274 );
275 }
276
277 #[test]
278 fn it_should_reject_name_starting_with_digit() {
279 let result = InstanceName::new("1test");
280 assert!(result.is_err());
281 assert_eq!(
282 result.unwrap_err(),
283 InstanceNameError::InvalidFirstCharacter
284 );
285 }
286
287 #[test]
288 fn it_should_reject_name_starting_with_dash() {
289 let result = InstanceName::new("-test");
290 assert!(result.is_err());
291 assert_eq!(
292 result.unwrap_err(),
293 InstanceNameError::InvalidFirstCharacter
294 );
295 }
296
297 #[test]
298 fn it_should_reject_name_ending_with_dash() {
299 let result = InstanceName::new("test-");
300 assert!(result.is_err());
301 assert_eq!(result.unwrap_err(), InstanceNameError::InvalidLastCharacter);
302 }
303
304 #[test]
305 fn it_should_reject_name_with_invalid_characters() {
306 let invalid_chars = vec![
307 "test@instance",
308 "test.instance",
309 "test_instance",
310 "test instance",
311 "test#instance",
312 "test$instance",
313 "test%instance",
314 "test*instance",
315 "test+instance",
316 "test=instance",
317 "test[instance]",
318 "test{instance}",
319 "test|instance",
320 "test\\instance",
321 "test:instance",
322 "test;instance",
323 "test\"instance",
324 "test'instance",
325 "test<instance>",
326 "test,instance",
327 "test?instance",
328 "test/instance",
329 ];
330
331 for invalid_name in invalid_chars {
332 let result = InstanceName::new(invalid_name);
333 assert!(result.is_err());
334 assert_eq!(result.unwrap_err(), InstanceNameError::InvalidCharacters);
335 }
336 }
337
338 #[test]
339 fn it_should_reject_name_with_unicode_characters() {
340 let result = InstanceName::new("tést-instance");
341 assert!(result.is_err());
342 assert_eq!(result.unwrap_err(), InstanceNameError::InvalidCharacters);
343 }
344
345 #[test]
346 fn it_should_display_instance_name() {
347 let name = InstanceName::new("test-instance").unwrap();
348 assert_eq!(format!("{name}"), "test-instance");
349 }
350
351 #[test]
352 fn it_should_parse_valid_name_from_string() {
353 let name: InstanceName = "test-instance".parse().unwrap();
354 assert_eq!(name.as_str(), "test-instance");
355 }
356
357 #[test]
358 fn it_should_fail_parsing_invalid_name_from_string() {
359 let result: Result<InstanceName, _> = "test-".parse();
360 assert!(result.is_err());
361 assert_eq!(result.unwrap_err(), InstanceNameError::InvalidLastCharacter);
362 }
363
364 #[test]
365 fn it_should_implement_as_ref() {
366 let name = InstanceName::new("test-instance").unwrap();
367 let as_ref: &str = name.as_ref();
368 assert_eq!(as_ref, "test-instance");
369 }
370
371 #[test]
372 fn it_should_be_cloneable_and_comparable() {
373 let name1 = InstanceName::new("test-instance").unwrap();
374 let name2 = name1.clone();
375 assert_eq!(name1, name2);
376 }
377
378 #[test]
379 fn it_should_be_hashable() {
380 use std::collections::HashSet;
381
382 let mut set = HashSet::new();
383 let name1 = InstanceName::new("test-instance").unwrap();
384 let name2 = InstanceName::new("test-instance").unwrap();
385 let name3 = InstanceName::new("other-instance").unwrap();
386
387 set.insert(name1);
388 set.insert(name2); set.insert(name3);
390
391 assert_eq!(set.len(), 2);
392 }
393
394 #[test]
395 fn it_should_serialize_and_deserialize() {
396 let name = InstanceName::new("test-instance").unwrap();
397
398 let json = serde_json::to_string(&name).unwrap();
400 assert_eq!(json, "\"test-instance\"");
401
402 let deserialized: InstanceName = serde_json::from_str(&json).unwrap();
404 assert_eq!(deserialized, name);
405 }
406
407 #[test]
408 fn it_should_fail_deserializing_invalid_name() {
409 let invalid_json = "\"test-\"";
410 let result: Result<InstanceName, _> = serde_json::from_str(invalid_json);
411 assert!(result.is_err());
412 }
413}