1use nutype::nutype;
4use serde::{Deserialize, Serialize};
5use std::fmt;
6use std::path::{Path, PathBuf};
7use thiserror::Error;
8
9#[derive(Debug, Clone, Error, PartialEq, Eq)]
11#[non_exhaustive]
12pub enum ValidationError {
13 #[error("hostname cannot be empty or whitespace")]
14 EmptyHostName,
15 #[error("server name cannot be empty or whitespace")]
16 EmptyServerName,
17 #[error("invalid hostname: {0}")]
18 InvalidHostName(String),
19 #[error("port cannot be 0")]
20 InvalidPort,
21 #[error("invalid message ID: {0}")]
22 InvalidMessageId(String),
23 #[error("config path cannot be empty")]
24 EmptyConfigPath,
25 #[error("username cannot be empty or whitespace")]
26 EmptyUsername,
27 #[error("password cannot be empty or whitespace")]
28 EmptyPassword,
29}
30
31#[nutype(
33 sanitize(trim),
34 validate(not_empty),
35 derive(
36 Debug,
37 Clone,
38 PartialEq,
39 Eq,
40 Hash,
41 Display,
42 AsRef,
43 Deref,
44 TryFrom,
45 Serialize,
46 Deserialize
47 )
48)]
49pub struct HostName(String);
50
51#[nutype(
53 sanitize(trim),
54 validate(not_empty),
55 derive(
56 Debug,
57 Clone,
58 PartialEq,
59 Eq,
60 Hash,
61 Display,
62 AsRef,
63 Deref,
64 TryFrom,
65 Serialize,
66 Deserialize
67 )
68)]
69pub struct ServerName(String);
70
71#[nutype(
73 sanitize(trim),
74 validate(not_empty),
75 derive(
76 Debug,
77 Clone,
78 PartialEq,
79 Eq,
80 Hash,
81 Display,
82 AsRef,
83 Deref,
84 TryFrom,
85 Serialize,
86 Deserialize
87 )
88)]
89pub struct Username(String);
90
91#[nutype(
93 sanitize(trim),
94 validate(not_empty),
95 derive(
96 Debug,
97 Clone,
98 PartialEq,
99 Eq,
100 Hash,
101 Display,
102 AsRef,
103 Deref,
104 TryFrom,
105 Serialize,
106 Deserialize
107 )
108)]
109pub struct Password(String);
110
111impl From<HostNameError> for ValidationError {
113 fn from(_: HostNameError) -> Self {
114 ValidationError::EmptyHostName
115 }
116}
117
118impl From<ServerNameError> for ValidationError {
119 fn from(_: ServerNameError) -> Self {
120 ValidationError::EmptyServerName
121 }
122}
123
124impl From<UsernameError> for ValidationError {
125 fn from(_: UsernameError) -> Self {
126 ValidationError::EmptyUsername
127 }
128}
129
130impl From<PasswordError> for ValidationError {
131 fn from(_: PasswordError) -> Self {
132 ValidationError::EmptyPassword
133 }
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Hash)]
138pub struct ConfigPath(PathBuf);
139
140impl ConfigPath {
141 pub fn new(path: impl AsRef<Path>) -> Result<Self, ValidationError> {
142 let path_ref = path.as_ref();
143 let path_str = path_ref.to_str().ok_or(ValidationError::EmptyConfigPath)?;
144 if path_str.trim().is_empty() {
145 return Err(ValidationError::EmptyConfigPath);
146 }
147 Ok(Self(path_ref.to_path_buf()))
148 }
149
150 #[must_use]
151 #[inline]
152 pub fn as_path(&self) -> &Path {
153 &self.0
154 }
155
156 #[must_use]
157 #[inline]
158 pub fn as_str(&self) -> &str {
159 self.0.to_str().unwrap_or("")
160 }
161}
162
163impl AsRef<Path> for ConfigPath {
164 #[inline]
165 fn as_ref(&self) -> &Path {
166 &self.0
167 }
168}
169
170impl fmt::Display for ConfigPath {
171 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172 write!(f, "{}", self.0.display())
173 }
174}
175
176impl std::str::FromStr for ConfigPath {
177 type Err = ValidationError;
178 fn from_str(s: &str) -> Result<Self, Self::Err> {
179 Self::new(s)
180 }
181}
182
183impl TryFrom<String> for ConfigPath {
184 type Error = ValidationError;
185 fn try_from(s: String) -> Result<Self, Self::Error> {
186 Self::new(s)
187 }
188}
189
190impl TryFrom<&str> for ConfigPath {
191 type Error = ValidationError;
192 fn try_from(s: &str) -> Result<Self, Self::Error> {
193 Self::new(s)
194 }
195}
196
197impl Serialize for ConfigPath {
198 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
199 where
200 S: serde::Serializer,
201 {
202 serializer.serialize_str(self.as_str())
203 }
204}
205
206impl<'de> Deserialize<'de> for ConfigPath {
207 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
208 where
209 D: serde::Deserializer<'de>,
210 {
211 let s = String::deserialize(deserializer)?;
212 Self::new(s).map_err(serde::de::Error::custom)
213 }
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219 use proptest::prelude::*;
220
221 fn non_empty_string() -> impl Strategy<Value = String> {
223 "[a-zA-Z0-9._@-]{1,100}"
224 }
225
226 fn path_string() -> impl Strategy<Value = String> {
227 "[a-zA-Z0-9._/-]{1,100}"
228 }
229
230 proptest! {
232 #[test]
233 fn hostname_serde_roundtrip(s in non_empty_string()) {
234 let hostname = HostName::try_new(s).unwrap();
235 let json = serde_json::to_string(&hostname).unwrap();
236 let deserialized: HostName = serde_json::from_str(&json).unwrap();
237 prop_assert_eq!(hostname, deserialized);
238 }
239 }
240
241 proptest! {
243 #[test]
244 fn server_name_serde_roundtrip(s in non_empty_string()) {
245 let server = ServerName::try_new(s).unwrap();
246 let json = serde_json::to_string(&server).unwrap();
247 let deserialized: ServerName = serde_json::from_str(&json).unwrap();
248 prop_assert_eq!(server, deserialized);
249 }
250 }
251
252 proptest! {
254 #[test]
255 fn username_serde_roundtrip(s in non_empty_string()) {
256 let username = Username::try_new(s).unwrap();
257 let json = serde_json::to_string(&username).unwrap();
258 let deserialized: Username = serde_json::from_str(&json).unwrap();
259 prop_assert_eq!(username, deserialized);
260 }
261 }
262
263 proptest! {
265 #[test]
266 fn password_serde_roundtrip(s in non_empty_string()) {
267 let password = Password::try_new(s).unwrap();
268 let json = serde_json::to_string(&password).unwrap();
269 let deserialized: Password = serde_json::from_str(&json).unwrap();
270 prop_assert_eq!(password, deserialized);
271 }
272 }
273
274 proptest! {
276 #[test]
277 fn config_path_non_empty_accepts(s in path_string()) {
278 let config = ConfigPath::try_from(s.clone()).unwrap();
279 prop_assert_eq!(config.as_str(), &s);
280 }
281
282 #[test]
283 fn config_path_as_path_roundtrip(s in path_string()) {
284 let config = ConfigPath::try_from(s.clone()).unwrap();
285 let path: &Path = config.as_ref();
286 prop_assert_eq!(path, Path::new(&s));
287 }
288
289 #[test]
290 fn config_path_serde_roundtrip(s in path_string()) {
291 let config = ConfigPath::try_from(s).unwrap();
292 let json = serde_json::to_string(&config).unwrap();
293 let deserialized: ConfigPath = serde_json::from_str(&json).unwrap();
294 prop_assert_eq!(config, deserialized);
295 }
296 }
297
298 #[test]
300 fn username_with_spaces_accepted() {
301 assert!(Username::try_new(" user ".to_string()).is_ok());
303 assert!(Username::try_new("user name".to_string()).is_ok());
304 }
305
306 #[test]
307 fn password_with_spaces_accepted() {
308 assert!(Password::try_new(" pass ".to_string()).is_ok());
309 assert!(Password::try_new("P@ssw0rd!".to_string()).is_ok());
310 assert!(Password::try_new("密码123".to_string()).is_ok());
311 }
312
313 #[test]
314 fn config_path_with_spaces_accepted() {
315 assert!(ConfigPath::try_from("my config.toml").is_ok());
316 assert!(ConfigPath::try_from("/absolute/path/config.toml").is_ok());
317 assert!(ConfigPath::try_from("./relative/config.toml").is_ok());
318 assert!(ConfigPath::try_from("../parent/config.toml").is_ok());
319 }
320
321 #[test]
323 fn hostname_deserialize_empty_fails() {
324 let json = "\"\"";
325 let result: Result<HostName, _> = serde_json::from_str(json);
326 assert!(result.is_err());
327 }
328
329 #[test]
330 fn config_path_deserialize_empty_fails() {
331 let json = "\"\"";
332 let result: Result<ConfigPath, _> = serde_json::from_str(json);
333 assert!(result.is_err());
334 }
335
336 #[test]
338 fn validation_error_messages() {
339 assert_eq!(
340 ValidationError::EmptyHostName.to_string(),
341 "hostname cannot be empty or whitespace"
342 );
343 assert_eq!(
344 ValidationError::EmptyServerName.to_string(),
345 "server name cannot be empty or whitespace"
346 );
347 assert_eq!(
348 ValidationError::EmptyUsername.to_string(),
349 "username cannot be empty or whitespace"
350 );
351 assert_eq!(
352 ValidationError::EmptyPassword.to_string(),
353 "password cannot be empty or whitespace"
354 );
355 assert_eq!(
356 ValidationError::EmptyConfigPath.to_string(),
357 "config path cannot be empty"
358 );
359 }
360
361 #[test]
362 fn validation_error_clone() {
363 let err = ValidationError::EmptyHostName;
364 let cloned = err.clone();
365 assert_eq!(err, cloned);
366 }
367}