nythos_core/domain/
value_objects.rs1use 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)]
91pub struct Password(String);
92
93impl Password {
94 const MIN_LEN: usize = 8;
95 const MAX_LEN: usize = 1024;
96
97 pub fn new(input: impl AsRef<str>) -> NythosResult<Self> {
99 let raw = input.as_ref();
100
101 if raw.is_empty() {
102 return Err(AuthError::ValidationError(
103 "password cannot be empty".to_owned(),
104 ));
105 }
106
107 if raw.len() < Self::MIN_LEN {
108 return Err(AuthError::ValidationError(format!(
109 "password must be at least {} characters",
110 Self::MIN_LEN
111 )));
112 }
113
114 if raw.len() > Self::MAX_LEN {
115 return Err(AuthError::ValidationError(format!(
116 "password must be at most {} characters",
117 Self::MAX_LEN
118 )));
119 }
120
121 if raw.chars().any(|c| c == '\n' || c == '\r') {
122 return Err(AuthError::ValidationError(
123 "password cannot contain newlines".to_owned(),
124 ));
125 }
126
127 Ok(Self(raw.to_owned()))
128 }
129
130 pub fn as_str(&self) -> &str {
132 &self.0
133 }
134
135 pub fn into_inner(self) -> String {
137 self.0
138 }
139}
140
141impl AsRef<str> for Password {
142 fn as_ref(&self) -> &str {
143 self.as_str()
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use super::{Email, Password};
150 use crate::AuthError;
151
152 #[test]
153 fn email_normalizes_for_stable_lookup() {
154 let email = Email::parse(" Alice.Example@Example.COM").unwrap();
155
156 assert_eq!(email.as_str(), "alice.example@example.com");
157 }
158
159 #[test]
160 fn email_rejects_empty_input() {
161 let error = Email::parse(" ").unwrap_err();
162
163 assert_eq!(
164 error,
165 AuthError::ValidationError("email cannot be empty".to_owned())
166 )
167 }
168
169 #[test]
170 fn email_rejects_invalid_shapes() {
171 assert!(matches!(
172 Email::parse("missing-at.example.com"),
173 Err(AuthError::ValidationError(_))
174 ));
175 assert!(matches!(
176 Email::parse("a@b"),
177 Err(AuthError::ValidationError(_))
178 ));
179 assert!(matches!(
180 Email::parse("a@@example.com"),
181 Err(AuthError::ValidationError(_))
182 ));
183 assert!(matches!(
184 Email::parse("a @example.com"),
185 Err(AuthError::ValidationError(_))
186 ));
187 }
188
189 #[test]
190 fn password_accepts_valid_raw_input() {
191 let password = Password::new("correct-horse-battery-staple").unwrap();
192
193 assert_eq!(password.as_str(), "correct-horse-battery-staple");
194 }
195
196 #[test]
197 fn password_rejects_empty_short_and_newline_inputs() {
198 assert!(matches!(
199 Password::new(""),
200 Err(AuthError::ValidationError(_))
201 ));
202 assert!(matches!(
203 Password::new("short"),
204 Err(AuthError::ValidationError(_))
205 ));
206 assert!(matches!(
207 Password::new("line\nbreak"),
208 Err(AuthError::ValidationError(_))
209 ));
210 }
211}