use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Error, PartialEq)]
pub enum ProfileNameError {
#[error("Profile name cannot be empty")]
Empty,
#[error("Profile name must be 63 characters or less, got {length} characters")]
TooLong { length: usize },
#[error("Profile name must not start with a digit or dash")]
InvalidFirstCharacter,
#[error("Profile name must not end with a dash")]
InvalidLastCharacter,
#[error("Profile name must contain only ASCII letters, numbers, and dashes")]
InvalidCharacters,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(try_from = "String")]
pub struct ProfileName(String);
impl ProfileName {
pub fn new<S: Into<String>>(name: S) -> Result<Self, ProfileNameError> {
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<(), ProfileNameError> {
if name.is_empty() {
return Err(ProfileNameError::Empty);
}
if name.len() > 63 {
return Err(ProfileNameError::TooLong { length: name.len() });
}
if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
return Err(ProfileNameError::InvalidCharacters);
}
if let Some(first_char) = name.chars().next() {
if first_char.is_ascii_digit() || first_char == '-' {
return Err(ProfileNameError::InvalidFirstCharacter);
}
}
if let Some(last_char) = name.chars().last() {
if last_char == '-' {
return Err(ProfileNameError::InvalidLastCharacter);
}
}
Ok(())
}
}
impl fmt::Display for ProfileName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl FromStr for ProfileName {
type Err = ProfileNameError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s)
}
}
impl TryFrom<String> for ProfileName {
type Error = ProfileNameError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<ProfileName> for String {
fn from(profile_name: ProfileName) -> Self {
profile_name.0
}
}
impl AsRef<str> for ProfileName {
fn as_ref(&self) -> &str {
&self.0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_should_create_valid_profile_name() {
let profile = ProfileName::new("torrust-profile").unwrap();
assert_eq!(profile.as_str(), "torrust-profile");
}
#[test]
fn it_should_accept_string_input() {
let profile_str = "test-profile".to_string();
let profile = ProfileName::new(profile_str).unwrap();
assert_eq!(profile.as_str(), "test-profile");
}
#[test]
fn it_should_accept_formatted_string() {
let env = "production";
let profile = ProfileName::new(format!("torrust-profile-{env}")).unwrap();
assert_eq!(profile.as_str(), "torrust-profile-production");
}
#[test]
fn it_should_create_profile_name_with_numbers() {
let profile = ProfileName::new("profile123").unwrap();
assert_eq!(profile.as_str(), "profile123");
}
#[test]
fn it_should_create_profile_name_with_dashes() {
let profile = ProfileName::new("test-profile-name").unwrap();
assert_eq!(profile.as_str(), "test-profile-name");
}
#[test]
fn it_should_reject_empty_profile_name() {
let result = ProfileName::new("");
assert!(matches!(result, Err(ProfileNameError::Empty)));
}
#[test]
fn it_should_reject_profile_name_starting_with_digit() {
let result = ProfileName::new("1profile");
assert!(matches!(
result,
Err(ProfileNameError::InvalidFirstCharacter)
));
}
#[test]
fn it_should_reject_profile_name_starting_with_dash() {
let result = ProfileName::new("-profile");
assert!(matches!(
result,
Err(ProfileNameError::InvalidFirstCharacter)
));
}
#[test]
fn it_should_reject_profile_name_ending_with_dash() {
let result = ProfileName::new("profile-");
assert!(matches!(
result,
Err(ProfileNameError::InvalidLastCharacter)
));
}
#[test]
fn it_should_reject_profile_name_with_invalid_characters() {
let result = ProfileName::new("test@profile");
assert!(matches!(result, Err(ProfileNameError::InvalidCharacters)));
let result = ProfileName::new("test_profile");
assert!(matches!(result, Err(ProfileNameError::InvalidCharacters)));
let result = ProfileName::new("test profile");
assert!(matches!(result, Err(ProfileNameError::InvalidCharacters)));
}
#[test]
fn it_should_reject_profile_name_too_long() {
let long_name = "a".repeat(64);
let result = ProfileName::new(long_name);
assert!(matches!(
result,
Err(ProfileNameError::TooLong { length: 64 })
));
}
#[test]
fn it_should_accept_max_length_profile_name() {
let max_length_name = "a".repeat(63);
let result = ProfileName::new(max_length_name);
assert!(result.is_ok());
}
#[test]
fn it_should_implement_from_str() {
let profile: ProfileName = "test-profile".parse().unwrap();
assert_eq!(profile.as_str(), "test-profile");
let invalid: Result<ProfileName, _> = "".parse();
assert!(invalid.is_err());
}
#[test]
fn it_should_implement_try_from_string() {
let profile = ProfileName::try_from("test-profile".to_string()).unwrap();
assert_eq!(profile.as_str(), "test-profile");
let invalid = ProfileName::try_from(String::new());
assert!(invalid.is_err());
}
#[test]
fn it_should_convert_to_string() {
let profile = ProfileName::new("test-profile").unwrap();
let string: String = profile.into();
assert_eq!(string, "test-profile");
}
#[test]
fn it_should_implement_as_ref() {
let profile = ProfileName::new("test-profile").unwrap();
let s: &str = profile.as_ref();
assert_eq!(s, "test-profile");
}
#[test]
fn it_should_display_profile_name() {
let profile = ProfileName::new("test-profile").unwrap();
assert_eq!(format!("{profile}"), "test-profile");
}
#[test]
fn it_should_be_cloneable() {
let profile = ProfileName::new("test-profile").unwrap();
let cloned = profile.clone();
assert_eq!(profile, cloned);
}
#[test]
fn it_should_be_hashable() {
use std::collections::HashMap;
let profile = ProfileName::new("test-profile").unwrap();
let mut map = HashMap::new();
map.insert(profile, "value");
assert_eq!(map.len(), 1);
}
#[test]
fn it_should_serialize_and_deserialize() {
let profile = ProfileName::new("test-profile").unwrap();
let json = serde_json::to_string(&profile).unwrap();
assert_eq!(json, "\"test-profile\"");
let deserialized: ProfileName = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized, profile);
}
#[test]
fn it_should_fail_deserialization_for_invalid_names() {
let invalid_json = "\"\""; let result: Result<ProfileName, _> = serde_json::from_str(invalid_json);
assert!(result.is_err());
let invalid_json = "\"-invalid\""; let result: Result<ProfileName, _> = serde_json::from_str(invalid_json);
assert!(result.is_err());
}
}