use std::fmt;
use std::str::FromStr;
use crate::{AuthError, NythosResult};
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct Email(String);
impl Email {
pub fn parse(input: impl AsRef<str>) -> NythosResult<Self> {
let raw = input.as_ref().trim();
if raw.is_empty() {
return Err(AuthError::ValidationError(
"email cannot be empty".to_owned(),
));
}
if raw.chars().any(char::is_whitespace) {
return Err(AuthError::ValidationError(
"email cannot contain whitespace".to_owned(),
));
}
let (local, domain) = raw.split_once("@").ok_or_else(|| {
AuthError::ValidationError("email must contain a single @".to_owned())
})?;
if local.is_empty() || domain.is_empty() || domain.contains('@') {
return Err(AuthError::ValidationError(
"email must contain a single @ with non-empty local and domain parts".to_owned(),
));
}
if domain.starts_with('.') || domain.ends_with('.') || !domain.contains('.') {
return Err(AuthError::ValidationError(
"email domain must be valid".to_owned(),
));
}
let normalized = raw.to_ascii_lowercase();
Ok(Self(normalized))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
}
impl AsRef<str> for Email {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl fmt::Display for Email {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
impl FromStr for Email {
type Err = AuthError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Password(String);
impl Password {
const MIN_LEN: usize = 8;
const MAX_LEN: usize = 1024;
pub fn new(input: impl AsRef<str>) -> NythosResult<Self> {
let raw = input.as_ref();
if raw.is_empty() {
return Err(AuthError::ValidationError(
"password cannot be empty".to_owned(),
));
}
if raw.len() < Self::MIN_LEN {
return Err(AuthError::ValidationError(format!(
"password must be at least {} characters",
Self::MIN_LEN
)));
}
if raw.len() > Self::MAX_LEN {
return Err(AuthError::ValidationError(format!(
"password must be at most {} characters",
Self::MAX_LEN
)));
}
if raw.chars().any(|c| c == '\n' || c == '\r') {
return Err(AuthError::ValidationError(
"password cannot contain newlines".to_owned(),
));
}
Ok(Self(raw.to_owned()))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
}
impl AsRef<str> for Password {
fn as_ref(&self) -> &str {
self.as_str()
}
}
#[cfg(test)]
mod tests {
use super::{Email, Password};
use crate::AuthError;
#[test]
fn email_normalizes_for_stable_lookup() {
let email = Email::parse(" Alice.Example@Example.COM").unwrap();
assert_eq!(email.as_str(), "alice.example@example.com");
}
#[test]
fn email_rejects_empty_input() {
let error = Email::parse(" ").unwrap_err();
assert_eq!(
error,
AuthError::ValidationError("email cannot be empty".to_owned())
)
}
#[test]
fn email_rejects_invalid_shapes() {
assert!(matches!(
Email::parse("missing-at.example.com"),
Err(AuthError::ValidationError(_))
));
assert!(matches!(
Email::parse("a@b"),
Err(AuthError::ValidationError(_))
));
assert!(matches!(
Email::parse("a@@example.com"),
Err(AuthError::ValidationError(_))
));
assert!(matches!(
Email::parse("a @example.com"),
Err(AuthError::ValidationError(_))
));
}
#[test]
fn password_accepts_valid_raw_input() {
let password = Password::new("correct-horse-battery-staple").unwrap();
assert_eq!(password.as_str(), "correct-horse-battery-staple");
}
#[test]
fn password_rejects_empty_short_and_newline_inputs() {
assert!(matches!(
Password::new(""),
Err(AuthError::ValidationError(_))
));
assert!(matches!(
Password::new("short"),
Err(AuthError::ValidationError(_))
));
assert!(matches!(
Password::new("line\nbreak"),
Err(AuthError::ValidationError(_))
));
}
}