1use std::fmt;
4use std::str::FromStr;
5
6use crate::error::{Error, Result};
7
8const MAX_LEN: usize = 64;
13
14#[derive(Debug, Clone, PartialEq, Eq, Hash)]
21pub struct Username(String);
22
23impl Username {
24 pub fn new(input: impl Into<String>) -> Result<Self> {
26 let input = input.into();
27 if let Some(reason) = invalid_reason(&input) {
28 return Err(Error::InvalidUsername { input, reason });
29 }
30 Ok(Self(input))
31 }
32
33 #[inline]
35 pub fn as_str(&self) -> &str {
36 &self.0
37 }
38}
39
40impl fmt::Display for Username {
41 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42 f.write_str(&self.0)
43 }
44}
45
46impl FromStr for Username {
47 type Err = Error;
48
49 fn from_str(s: &str) -> Result<Self> {
50 Self::new(s)
51 }
52}
53
54impl AsRef<str> for Username {
55 fn as_ref(&self) -> &str {
56 &self.0
57 }
58}
59
60impl serde::Serialize for Username {
61 fn serialize<S: serde::Serializer>(
62 &self,
63 serializer: S,
64 ) -> std::result::Result<S::Ok, S::Error> {
65 self.0.serialize(serializer)
66 }
67}
68
69impl<'de> serde::Deserialize<'de> for Username {
70 fn deserialize<D: serde::Deserializer<'de>>(
71 deserializer: D,
72 ) -> std::result::Result<Self, D::Error> {
73 let raw = String::deserialize(deserializer)?;
74 Self::new(raw).map_err(serde::de::Error::custom)
75 }
76}
77
78fn invalid_reason(s: &str) -> Option<String> {
79 if s.is_empty() {
80 return Some(String::from("username is empty"));
81 }
82 if s.len() > MAX_LEN {
83 return Some(format!("username exceeds {MAX_LEN} characters"));
84 }
85 s.chars()
86 .find(|c| !is_allowed(*c))
87 .map(|c| format!("contains invalid character {c:?}"))
88}
89
90#[inline]
91const fn is_allowed(c: char) -> bool {
92 c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.'
93}
94
95#[cfg(test)]
96mod tests {
97 use super::*;
98
99 #[test]
100 fn accepts_common_usernames() {
101 for ok in ["alice", "bob_doe", "user-name", "a.b", "1234", "A_b-c.d"] {
102 assert!(Username::new(ok).is_ok(), "{ok:?} should be accepted");
103 }
104 }
105
106 #[test]
107 fn rejects_empty() {
108 let err = Username::new("").unwrap_err();
109 assert!(matches!(err, Error::InvalidUsername { .. }));
110 assert!(err.to_string().contains("empty"));
111 }
112
113 #[test]
114 fn rejects_too_long() {
115 let long = "a".repeat(MAX_LEN + 1);
116 assert!(Username::new(long).is_err());
117 let edge = "a".repeat(MAX_LEN);
118 assert!(
119 Username::new(edge).is_ok(),
120 "exactly {MAX_LEN} chars is allowed"
121 );
122 }
123
124 #[test]
125 fn rejects_disallowed_characters() {
126 for bad in [
127 " alice", "alice ", "ali ce", "a/b", "a?b", "a#b", "ali@ce", "café",
128 ] {
129 assert!(Username::new(bad).is_err(), "{bad:?} should be rejected");
130 }
131 }
132
133 #[test]
134 fn display_and_as_str_roundtrip() {
135 let u = Username::new("alice").unwrap();
136 assert_eq!(u.as_str(), "alice");
137 assert_eq!(u.to_string(), "alice");
138 assert_eq!(<Username as AsRef<str>>::as_ref(&u), "alice");
139 }
140
141 #[test]
142 fn from_str_works() {
143 let u: Username = "carol".parse().unwrap();
144 assert_eq!(u.as_str(), "carol");
145 }
146
147 #[test]
148 fn serde_roundtrip_via_json() {
149 let u = Username::new("dave_42").unwrap();
150 let json = serde_json::to_string(&u).unwrap();
151 assert_eq!(json, "\"dave_42\"");
152 let back: Username = serde_json::from_str(&json).unwrap();
153 assert_eq!(back, u);
154 }
155
156 #[test]
157 fn serde_deserialize_validates() {
158 let err = serde_json::from_str::<Username>("\"bad space\"").unwrap_err();
159 assert!(err.to_string().contains("invalid character"));
160 }
161
162 proptest::proptest! {
163 #[test]
165 fn new_never_panics_on_arbitrary_input(s in ".*") {
166 let _ = Username::new(s);
167 }
168
169 #[test]
172 fn valid_usernames_round_trip(s in "[A-Za-z0-9._-]{1,64}") {
173 let u = Username::new(s.clone()).expect("matches the username charset");
174 proptest::prop_assert_eq!(u.as_str(), s.as_str());
175 let json = serde_json::to_string(&u).unwrap();
176 let back: Username = serde_json::from_str(&json).unwrap();
177 proptest::prop_assert_eq!(back, u);
178 }
179
180 #[test]
182 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}") {
183 proptest::prop_assert!(Username::new(s).is_err());
184 }
185 }
186}