use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Error, PartialEq)]
pub enum InstanceNameError {
#[error("Instance name cannot be empty")]
Empty,
#[error("Instance name must be 63 characters or less, got {length} characters")]
TooLong { length: usize },
#[error("Instance name must not start with a digit or dash")]
InvalidFirstCharacter,
#[error("Instance name must not end with a dash")]
InvalidLastCharacter,
#[error("Instance name must contain only ASCII letters, numbers, and dashes")]
InvalidCharacters,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(try_from = "String")]
pub struct InstanceName(String);
impl InstanceName {
pub fn new<S: Into<String>>(name: S) -> Result<Self, InstanceNameError> {
let name = name.into();
Self::validate(&name)?;
Ok(Self(name))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
fn validate(name: &str) -> Result<(), InstanceNameError> {
if name.is_empty() {
return Err(InstanceNameError::Empty);
}
if name.len() > 63 {
return Err(InstanceNameError::TooLong { length: name.len() });
}
if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
return Err(InstanceNameError::InvalidCharacters);
}
if let Some(first_char) = name.chars().next() {
if first_char.is_ascii_digit() || first_char == '-' {
return Err(InstanceNameError::InvalidFirstCharacter);
}
}
if name.ends_with('-') {
return Err(InstanceNameError::InvalidLastCharacter);
}
Ok(())
}
}
impl fmt::Display for InstanceName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl FromStr for InstanceName {
type Err = InstanceNameError;
fn from_str(s: &str) -> Result<Self, InstanceNameError> {
Self::new(s.to_string())
}
}
impl AsRef<str> for InstanceName {
fn as_ref(&self) -> &str {
&self.0
}
}
impl TryFrom<String> for InstanceName {
type Error = InstanceNameError;
fn try_from(value: String) -> Result<Self, InstanceNameError> {
Self::new(value)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_should_create_valid_instance_name() {
let name = InstanceName::new("test-instance").unwrap();
assert_eq!(name.as_str(), "test-instance");
}
#[test]
fn it_should_accept_string_slice_and_owned_string() {
let name1 = InstanceName::new("test-instance").unwrap();
assert_eq!(name1.as_str(), "test-instance");
let name2 = InstanceName::new("test-instance".to_string()).unwrap();
assert_eq!(name2.as_str(), "test-instance");
let name3 = InstanceName::new(format!("app-{}", "prod")).unwrap();
assert_eq!(name3.as_str(), "app-prod");
assert_eq!(name1, name2);
}
#[test]
fn it_should_create_instance_name_with_numbers() {
let name = InstanceName::new("test123").unwrap();
assert_eq!(name.as_str(), "test123");
}
#[test]
fn it_should_create_instance_name_with_dashes() {
let name = InstanceName::new("test-instance-name").unwrap();
assert_eq!(name.as_str(), "test-instance-name");
}
#[test]
fn it_should_create_single_character_name() {
let name = InstanceName::new("a").unwrap();
assert_eq!(name.as_str(), "a");
}
#[test]
fn it_should_create_63_character_name() {
let long_name = "a".repeat(63);
let name = InstanceName::new(long_name.clone()).unwrap();
assert_eq!(name.as_str(), long_name);
}
#[test]
fn it_should_reject_empty_name() {
let result = InstanceName::new("");
assert!(result.is_err());
assert_eq!(result.unwrap_err(), InstanceNameError::Empty);
}
#[test]
fn it_should_reject_name_longer_than_63_characters() {
let long_name = "a".repeat(64);
let result = InstanceName::new(long_name);
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
InstanceNameError::TooLong { length: 64 }
);
}
#[test]
fn it_should_reject_name_starting_with_digit() {
let result = InstanceName::new("1test");
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
InstanceNameError::InvalidFirstCharacter
);
}
#[test]
fn it_should_reject_name_starting_with_dash() {
let result = InstanceName::new("-test");
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
InstanceNameError::InvalidFirstCharacter
);
}
#[test]
fn it_should_reject_name_ending_with_dash() {
let result = InstanceName::new("test-");
assert!(result.is_err());
assert_eq!(result.unwrap_err(), InstanceNameError::InvalidLastCharacter);
}
#[test]
fn it_should_reject_name_with_invalid_characters() {
let invalid_chars = vec![
"test@instance",
"test.instance",
"test_instance",
"test instance",
"test#instance",
"test$instance",
"test%instance",
"test*instance",
"test+instance",
"test=instance",
"test[instance]",
"test{instance}",
"test|instance",
"test\\instance",
"test:instance",
"test;instance",
"test\"instance",
"test'instance",
"test<instance>",
"test,instance",
"test?instance",
"test/instance",
];
for invalid_name in invalid_chars {
let result = InstanceName::new(invalid_name);
assert!(result.is_err());
assert_eq!(result.unwrap_err(), InstanceNameError::InvalidCharacters);
}
}
#[test]
fn it_should_reject_name_with_unicode_characters() {
let result = InstanceName::new("tést-instance");
assert!(result.is_err());
assert_eq!(result.unwrap_err(), InstanceNameError::InvalidCharacters);
}
#[test]
fn it_should_display_instance_name() {
let name = InstanceName::new("test-instance").unwrap();
assert_eq!(format!("{name}"), "test-instance");
}
#[test]
fn it_should_parse_valid_name_from_string() {
let name: InstanceName = "test-instance".parse().unwrap();
assert_eq!(name.as_str(), "test-instance");
}
#[test]
fn it_should_fail_parsing_invalid_name_from_string() {
let result: Result<InstanceName, _> = "test-".parse();
assert!(result.is_err());
assert_eq!(result.unwrap_err(), InstanceNameError::InvalidLastCharacter);
}
#[test]
fn it_should_implement_as_ref() {
let name = InstanceName::new("test-instance").unwrap();
let as_ref: &str = name.as_ref();
assert_eq!(as_ref, "test-instance");
}
#[test]
fn it_should_be_cloneable_and_comparable() {
let name1 = InstanceName::new("test-instance").unwrap();
let name2 = name1.clone();
assert_eq!(name1, name2);
}
#[test]
fn it_should_be_hashable() {
use std::collections::HashSet;
let mut set = HashSet::new();
let name1 = InstanceName::new("test-instance").unwrap();
let name2 = InstanceName::new("test-instance").unwrap();
let name3 = InstanceName::new("other-instance").unwrap();
set.insert(name1);
set.insert(name2); set.insert(name3);
assert_eq!(set.len(), 2);
}
#[test]
fn it_should_serialize_and_deserialize() {
let name = InstanceName::new("test-instance").unwrap();
let json = serde_json::to_string(&name).unwrap();
assert_eq!(json, "\"test-instance\"");
let deserialized: InstanceName = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized, name);
}
#[test]
fn it_should_fail_deserializing_invalid_name() {
let invalid_json = "\"test-\"";
let result: Result<InstanceName, _> = serde_json::from_str(invalid_json);
assert!(result.is_err());
}
}