Skip to main content

nythos_core/domain/
value_objects.rs

1use std::fmt;
2use std::str::FromStr;
3
4use crate::{AuthError, NythosResult};
5
6/// Validated email value object used as the core input boundary.
7///
8/// Construction normalizes the email into a stable lookup from:
9/// - trims surrounding whitespace
10/// - requires exactly one '@'
11/// - lowercases the full address
12/// - rejects empty local/domain parts
13/// - rejects whitespace inside the address
14#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
15pub struct Email(String);
16
17impl Email {
18    /// Parses and validates an email input into its normalized form.
19    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    /// Returns the normalized email string.
56    pub fn as_str(&self) -> &str {
57        &self.0
58    }
59
60    /// Consumes the value object and returns the normalized email string.
61    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/// Stable tenant-scoped username value object.
87///
88/// Usernames are normalized to lowercase ASCII and are intended for lookup.
89/// They are distinct from display names and must never contain email-like input.
90#[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    /// Parses and validates a username into its normalized lookup form.
98    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    /// Returns the normalized username string.
154    pub fn as_str(&self) -> &str {
155        &self.0
156    }
157
158    /// Consumes the username and returns the normalized string.
159    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/// Human-readable display name value object.
185///
186/// Display names are profile metadata only. They are not lookup identifiers and
187/// must never be used for authentication.
188#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
189pub struct DisplayName(String);
190
191impl DisplayName {
192    const MAX_LEN: usize = 80;
193
194    /// Parses and validates a display name while preserving user-facing casing.
195    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    /// Returns the validated display name string.
227    pub fn as_str(&self) -> &str {
228        &self.0
229    }
230
231    /// Consumes the display name and returns the owned string.
232    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/// Typed login identifier used to classify login input as email or username.
258#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
259pub enum LoginIdentifier {
260    Email(Email),
261    Username(Username),
262}
263
264impl LoginIdentifier {
265    /// Parses a raw login identifier.
266    ///
267    /// Email parsing is attempted first. If that fails, username parsing is
268    /// attempted. This keeps classification deterministic.
269    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/// Raw validated password input.
323///
324/// This is intentionally distinct from a stored password hash. It represents
325/// inbound credential material that has passed the core validation boundary.
326#[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    /// Validates and constructs a raw password input.
334    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    /// Returns the validated raw password as a string slice.
367    pub fn as_str(&self) -> &str {
368        &self.0
369    }
370
371    /// Consumes the password input and returns the owned string.
372    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}