use std::fmt;
use std::str::FromStr;
use crate::error::{Error, Result};
const MAX_LEN: usize = 64;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Username(String);
impl Username {
pub fn new(input: impl Into<String>) -> Result<Self> {
let input = input.into();
if let Some(reason) = invalid_reason(&input) {
return Err(Error::InvalidUsername { input, reason });
}
Ok(Self(input))
}
#[inline]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for Username {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl FromStr for Username {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
Self::new(s)
}
}
impl AsRef<str> for Username {
fn as_ref(&self) -> &str {
&self.0
}
}
impl serde::Serialize for Username {
fn serialize<S: serde::Serializer>(
&self,
serializer: S,
) -> std::result::Result<S::Ok, S::Error> {
self.0.serialize(serializer)
}
}
impl<'de> serde::Deserialize<'de> for Username {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Self, D::Error> {
let raw = String::deserialize(deserializer)?;
Self::new(raw).map_err(serde::de::Error::custom)
}
}
fn invalid_reason(s: &str) -> Option<String> {
if s.is_empty() {
return Some(String::from("username is empty"));
}
if s.len() > MAX_LEN {
return Some(format!("username exceeds {MAX_LEN} characters"));
}
s.chars()
.find(|c| !is_allowed(*c))
.map(|c| format!("contains invalid character {c:?}"))
}
#[inline]
const fn is_allowed(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.'
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_common_usernames() {
for ok in ["alice", "bob_doe", "user-name", "a.b", "1234", "A_b-c.d"] {
assert!(Username::new(ok).is_ok(), "{ok:?} should be accepted");
}
}
#[test]
fn rejects_empty() {
let err = Username::new("").unwrap_err();
assert!(matches!(err, Error::InvalidUsername { .. }));
assert!(err.to_string().contains("empty"));
}
#[test]
fn rejects_too_long() {
let long = "a".repeat(MAX_LEN + 1);
assert!(Username::new(long).is_err());
let edge = "a".repeat(MAX_LEN);
assert!(
Username::new(edge).is_ok(),
"exactly {MAX_LEN} chars is allowed"
);
}
#[test]
fn rejects_disallowed_characters() {
for bad in [
" alice", "alice ", "ali ce", "a/b", "a?b", "a#b", "ali@ce", "café",
] {
assert!(Username::new(bad).is_err(), "{bad:?} should be rejected");
}
}
#[test]
fn display_and_as_str_roundtrip() {
let u = Username::new("alice").unwrap();
assert_eq!(u.as_str(), "alice");
assert_eq!(u.to_string(), "alice");
assert_eq!(<Username as AsRef<str>>::as_ref(&u), "alice");
}
#[test]
fn from_str_works() {
let u: Username = "carol".parse().unwrap();
assert_eq!(u.as_str(), "carol");
}
#[test]
fn serde_roundtrip_via_json() {
let u = Username::new("dave_42").unwrap();
let json = serde_json::to_string(&u).unwrap();
assert_eq!(json, "\"dave_42\"");
let back: Username = serde_json::from_str(&json).unwrap();
assert_eq!(back, u);
}
#[test]
fn serde_deserialize_validates() {
let err = serde_json::from_str::<Username>("\"bad space\"").unwrap_err();
assert!(err.to_string().contains("invalid character"));
}
proptest::proptest! {
#[test]
fn new_never_panics_on_arbitrary_input(s in ".*") {
let _ = Username::new(s);
}
#[test]
fn valid_usernames_round_trip(s in "[A-Za-z0-9._-]{1,64}") {
let u = Username::new(s.clone()).expect("matches the username charset");
proptest::prop_assert_eq!(u.as_str(), s.as_str());
let json = serde_json::to_string(&u).unwrap();
let back: Username = serde_json::from_str(&json).unwrap();
proptest::prop_assert_eq!(back, u);
}
#[test]
fn strings_with_disallowed_chars_are_rejected(s in "[A-Za-z0-9._-]{0,20}[^A-Za-z0-9._-][A-Za-z0-9._-]{0,20}") {
proptest::prop_assert!(Username::new(s).is_err());
}
}
}