1use std::fmt;
2use std::str::FromStr;
3
4use crate::{AuthError, NythosResult};
5
6#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
15pub struct Email(String);
16
17impl Email {
18 pub fn parse(input: impl AsRef<str>) -> NythosResult<Self> {
20 let raw = input.as_ref().trim();
21
22 if raw.is_empty() {
23 return Err(AuthError::ValidationError(
24 "email cannot be empty".to_owned(),
25 ));
26 }
27
28 if raw.chars().any(char::is_whitespace) {
29 return Err(AuthError::ValidationError(
30 "email cannot contain whitespace".to_owned(),
31 ));
32 }
33
34 let (local, domain) = raw.split_once("@").ok_or_else(|| {
35 AuthError::ValidationError("email must contain a single @".to_owned())
36 })?;
37
38 if local.is_empty() || domain.is_empty() || domain.contains('@') {
39 return Err(AuthError::ValidationError(
40 "email must contain a single @ with non-empty local and domain parts".to_owned(),
41 ));
42 }
43
44 if domain.starts_with('.') || domain.ends_with('.') || !domain.contains('.') {
45 return Err(AuthError::ValidationError(
46 "email domain must be valid".to_owned(),
47 ));
48 }
49
50 let normalized = raw.to_ascii_lowercase();
51
52 Ok(Self(normalized))
53 }
54
55 pub fn as_str(&self) -> &str {
57 &self.0
58 }
59
60 pub fn into_inner(self) -> String {
62 self.0
63 }
64}
65
66impl AsRef<str> for Email {
67 fn as_ref(&self) -> &str {
68 self.as_str()
69 }
70}
71
72impl fmt::Display for Email {
73 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74 self.0.fmt(f)
75 }
76}
77
78impl FromStr for Email {
79 type Err = AuthError;
80
81 fn from_str(s: &str) -> Result<Self, Self::Err> {
82 Self::parse(s)
83 }
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
91pub struct Username(String);
92
93impl Username {
94 const MIN_LEN: usize = 3;
95 const MAX_LEN: usize = 32;
96
97 pub fn parse(input: impl AsRef<str>) -> NythosResult<Self> {
99 let raw = input.as_ref().trim();
100
101 if raw.is_empty() {
102 return Err(AuthError::ValidationError(
103 "username cannot be empty".to_owned(),
104 ));
105 }
106
107 if raw.contains('@') {
108 return Err(AuthError::ValidationError(
109 "username cannot contain '@'".to_owned(),
110 ));
111 }
112
113 if raw.chars().any(char::is_whitespace) {
114 return Err(AuthError::ValidationError(
115 "username cannot contain whitespace".to_owned(),
116 ));
117 }
118
119 let normalized = raw.to_ascii_lowercase();
120
121 if normalized.len() < Self::MIN_LEN {
122 return Err(AuthError::ValidationError(format!(
123 "username must be at least {} characters",
124 Self::MIN_LEN
125 )));
126 }
127
128 if normalized.len() > Self::MAX_LEN {
129 return Err(AuthError::ValidationError(format!(
130 "username must be at most {} characters",
131 Self::MAX_LEN
132 )));
133 }
134
135 if normalized.starts_with(['_', '-']) || normalized.ends_with(['_', '-']) {
136 return Err(AuthError::ValidationError(
137 "username cannot start or end with '_' or '-'".to_owned(),
138 ));
139 }
140
141 if !normalized
142 .chars()
143 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
144 {
145 return Err(AuthError::ValidationError(
146 "username must contain only lowercase ASCII letters, digits, '_' or '-'".to_owned(),
147 ));
148 }
149
150 Ok(Self(normalized))
151 }
152
153 pub fn as_str(&self) -> &str {
155 &self.0
156 }
157
158 pub fn into_inner(self) -> String {
160 self.0
161 }
162}
163
164impl AsRef<str> for Username {
165 fn as_ref(&self) -> &str {
166 self.as_str()
167 }
168}
169
170impl fmt::Display for Username {
171 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172 self.0.fmt(f)
173 }
174}
175
176impl FromStr for Username {
177 type Err = AuthError;
178
179 fn from_str(s: &str) -> Result<Self, Self::Err> {
180 Self::parse(s)
181 }
182}
183
184#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
189pub struct DisplayName(String);
190
191impl DisplayName {
192 const MAX_LEN: usize = 80;
193
194 pub fn parse(input: impl AsRef<str>) -> NythosResult<Self> {
196 let value = input.as_ref().trim();
197
198 if value.is_empty() {
199 return Err(AuthError::ValidationError(
200 "display name cannot be empty".to_owned(),
201 ));
202 }
203
204 if value.chars().count() > Self::MAX_LEN {
205 return Err(AuthError::ValidationError(format!(
206 "display name must be at most {} characters",
207 Self::MAX_LEN
208 )));
209 }
210
211 if value.chars().any(|c| c == '\n' || c == '\r') {
212 return Err(AuthError::ValidationError(
213 "display name cannot contain newlines".to_owned(),
214 ));
215 }
216
217 if value.chars().any(char::is_control) {
218 return Err(AuthError::ValidationError(
219 "display name cannot contain control characters".to_owned(),
220 ));
221 }
222
223 Ok(Self(value.to_owned()))
224 }
225
226 pub fn as_str(&self) -> &str {
228 &self.0
229 }
230
231 pub fn into_inner(self) -> String {
233 self.0
234 }
235}
236
237impl AsRef<str> for DisplayName {
238 fn as_ref(&self) -> &str {
239 self.as_str()
240 }
241}
242
243impl fmt::Display for DisplayName {
244 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
245 self.0.fmt(f)
246 }
247}
248
249impl FromStr for DisplayName {
250 type Err = AuthError;
251
252 fn from_str(s: &str) -> Result<Self, Self::Err> {
253 Self::parse(s)
254 }
255}
256
257#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
259pub enum LoginIdentifier {
260 Email(Email),
261 Username(Username),
262}
263
264impl LoginIdentifier {
265 pub fn parse(input: impl AsRef<str>) -> NythosResult<Self> {
270 let raw = input.as_ref();
271
272 if let Ok(email) = Email::parse(raw) {
273 return Ok(Self::Email(email));
274 }
275
276 Username::parse(raw).map(Self::Username)
277 }
278
279 pub const fn is_email(&self) -> bool {
280 matches!(self, Self::Email(_))
281 }
282
283 pub const fn is_username(&self) -> bool {
284 matches!(self, Self::Username(_))
285 }
286
287 pub const fn as_email(&self) -> Option<&Email> {
288 match self {
289 Self::Email(email) => Some(email),
290 Self::Username(_) => None,
291 }
292 }
293
294 pub const fn as_username(&self) -> Option<&Username> {
295 match self {
296 Self::Email(_) => None,
297 Self::Username(username) => Some(username),
298 }
299 }
300}
301
302impl From<Email> for LoginIdentifier {
303 fn from(value: Email) -> Self {
304 Self::Email(value)
305 }
306}
307
308impl From<Username> for LoginIdentifier {
309 fn from(value: Username) -> Self {
310 Self::Username(value)
311 }
312}
313
314impl FromStr for LoginIdentifier {
315 type Err = AuthError;
316
317 fn from_str(s: &str) -> Result<Self, Self::Err> {
318 Self::parse(s)
319 }
320}
321
322#[derive(Debug, Clone, PartialEq, Eq)]
327pub struct Password(String);
328
329impl Password {
330 const MIN_LEN: usize = 8;
331 const MAX_LEN: usize = 1024;
332
333 pub fn new(input: impl AsRef<str>) -> NythosResult<Self> {
335 let raw = input.as_ref();
336
337 if raw.is_empty() {
338 return Err(AuthError::ValidationError(
339 "password cannot be empty".to_owned(),
340 ));
341 }
342
343 if raw.len() < Self::MIN_LEN {
344 return Err(AuthError::ValidationError(format!(
345 "password must be at least {} characters",
346 Self::MIN_LEN
347 )));
348 }
349
350 if raw.len() > Self::MAX_LEN {
351 return Err(AuthError::ValidationError(format!(
352 "password must be at most {} characters",
353 Self::MAX_LEN
354 )));
355 }
356
357 if raw.chars().any(|c| c == '\n' || c == '\r') {
358 return Err(AuthError::ValidationError(
359 "password cannot contain newlines".to_owned(),
360 ));
361 }
362
363 Ok(Self(raw.to_owned()))
364 }
365
366 pub fn as_str(&self) -> &str {
368 &self.0
369 }
370
371 pub fn into_inner(self) -> String {
373 self.0
374 }
375}
376
377impl AsRef<str> for Password {
378 fn as_ref(&self) -> &str {
379 self.as_str()
380 }
381}
382
383#[cfg(test)]
384mod tests {
385 use super::{DisplayName, Email, LoginIdentifier, Password, Username};
386 use crate::AuthError;
387
388 #[test]
389 fn email_normalizes_for_stable_lookup() {
390 let email = Email::parse(" Alice.Example@Example.COM").unwrap();
391
392 assert_eq!(email.as_str(), "alice.example@example.com");
393 }
394
395 #[test]
396 fn email_rejects_empty_input() {
397 let error = Email::parse(" ").unwrap_err();
398
399 assert_eq!(
400 error,
401 AuthError::ValidationError("email cannot be empty".to_owned())
402 )
403 }
404
405 #[test]
406 fn email_rejects_invalid_shapes() {
407 assert!(matches!(
408 Email::parse("missing-at.example.com"),
409 Err(AuthError::ValidationError(_))
410 ));
411 assert!(matches!(
412 Email::parse("a@b"),
413 Err(AuthError::ValidationError(_))
414 ));
415 assert!(matches!(
416 Email::parse("a@@example.com"),
417 Err(AuthError::ValidationError(_))
418 ));
419 assert!(matches!(
420 Email::parse("a @example.com"),
421 Err(AuthError::ValidationError(_))
422 ));
423 }
424
425 #[test]
426 fn username_accepts_simple_values_and_normalizes() {
427 let username = Username::parse(" Alice_123 ").unwrap();
428
429 assert_eq!(username.as_str(), "alice_123");
430 assert_eq!(username.to_string(), "alice_123");
431 }
432
433 #[test]
434 fn username_accepts_digits_underscore_and_hyphen() {
435 let username = Username::parse("dev-ops_123").unwrap();
436
437 assert_eq!(username.as_str(), "dev-ops_123");
438 }
439
440 #[test]
441 fn username_rejects_invalid_shapes() {
442 assert!(matches!(
443 Username::parse(""),
444 Err(AuthError::ValidationError(_))
445 ));
446 assert!(matches!(
447 Username::parse("ab"),
448 Err(AuthError::ValidationError(_))
449 ));
450 assert!(matches!(
451 Username::parse("a".repeat(33)),
452 Err(AuthError::ValidationError(_))
453 ));
454 assert!(matches!(
455 Username::parse("-alice"),
456 Err(AuthError::ValidationError(_))
457 ));
458 assert!(matches!(
459 Username::parse("alice_"),
460 Err(AuthError::ValidationError(_))
461 ));
462 assert!(matches!(
463 Username::parse("ali ce"),
464 Err(AuthError::ValidationError(_))
465 ));
466 assert!(matches!(
467 Username::parse("alice@example.com"),
468 Err(AuthError::ValidationError(_))
469 ));
470 assert!(matches!(
471 Username::parse("álîce"),
472 Err(AuthError::ValidationError(_))
473 ));
474 }
475
476 #[test]
477 fn display_name_accepts_unicode_and_preserves_casing() {
478 let display_name = DisplayName::parse(" Ada Lovelace 张伟 ").unwrap();
479
480 assert_eq!(display_name.as_str(), "Ada Lovelace 张伟");
481 assert_eq!(display_name.to_string(), "Ada Lovelace 张伟");
482 }
483
484 #[test]
485 fn display_name_rejects_invalid_shapes() {
486 assert!(matches!(
487 DisplayName::parse(" "),
488 Err(AuthError::ValidationError(_))
489 ));
490 assert!(matches!(
491 DisplayName::parse("a".repeat(81)),
492 Err(AuthError::ValidationError(_))
493 ));
494 assert!(matches!(
495 DisplayName::parse("Ada\nLovelace"),
496 Err(AuthError::ValidationError(_))
497 ));
498 assert!(matches!(
499 DisplayName::parse("Ada\rLovelace"),
500 Err(AuthError::ValidationError(_))
501 ));
502 assert!(matches!(
503 DisplayName::parse("Ada\u{0001}Lovelace"),
504 Err(AuthError::ValidationError(_))
505 ));
506 }
507
508 #[test]
509 fn login_identifier_parses_email_first() {
510 let identifier = LoginIdentifier::parse("User@Example.com").unwrap();
511
512 assert!(identifier.is_email());
513 assert!(!identifier.is_username());
514 assert_eq!(identifier.as_email().unwrap().as_str(), "user@example.com");
515 assert!(identifier.as_username().is_none());
516 }
517
518 #[test]
519 fn login_identifier_parses_username_when_email_fails() {
520 let identifier = LoginIdentifier::parse("Alice_123").unwrap();
521
522 assert!(identifier.is_username());
523 assert!(!identifier.is_email());
524 assert_eq!(identifier.as_username().unwrap().as_str(), "alice_123");
525 assert!(identifier.as_email().is_none());
526 }
527
528 #[test]
529 fn login_identifier_rejects_invalid_input() {
530 assert!(matches!(
531 LoginIdentifier::parse("!!bad"),
532 Err(AuthError::ValidationError(_))
533 ));
534 }
535
536 #[test]
537 fn login_identifier_from_value_objects_keeps_variant() {
538 let email = Email::parse("person@example.com").unwrap();
539 let username = Username::parse("person").unwrap();
540
541 assert!(LoginIdentifier::from(email).is_email());
542 assert!(LoginIdentifier::from(username).is_username());
543 }
544
545 #[test]
546 fn password_accepts_valid_raw_input() {
547 let password = Password::new("correct-horse-battery-staple").unwrap();
548
549 assert_eq!(password.as_str(), "correct-horse-battery-staple");
550 }
551
552 #[test]
553 fn password_rejects_empty_short_and_newline_inputs() {
554 assert!(matches!(
555 Password::new(""),
556 Err(AuthError::ValidationError(_))
557 ));
558 assert!(matches!(
559 Password::new("short"),
560 Err(AuthError::ValidationError(_))
561 ));
562 assert!(matches!(
563 Password::new("line\nbreak"),
564 Err(AuthError::ValidationError(_))
565 ));
566 }
567}