nntp_proxy/types/
validated.rs

1//! Validated string types that enforce invariants at construction time
2
3use serde::{Deserialize, Serialize};
4use std::fmt;
5use thiserror::Error;
6
7/// Validation errors for string types
8#[derive(Debug, Clone, Error, PartialEq, Eq)]
9#[non_exhaustive]
10pub enum ValidationError {
11    #[error("hostname cannot be empty or whitespace")]
12    EmptyHostName,
13
14    #[error("server name cannot be empty or whitespace")]
15    EmptyServerName,
16
17    #[error("invalid hostname: {0}")]
18    InvalidHostName(String),
19
20    #[error("port cannot be 0")]
21    InvalidPort,
22
23    #[error("invalid message ID: {0}")]
24    InvalidMessageId(String),
25}
26
27/// Macro to generate validated string newtypes.
28///
29/// This macro eliminates boilerplate by generating all the standard implementations
30/// for validated string types. Each type gets:
31/// - A `new()` constructor that validates
32/// - `as_str()` getter
33/// - `AsRef<str>`, `Deref`, `Display`, `TryFrom<String>` impls
34/// - Serde `Serialize` and `Deserialize` with validation
35///
36/// # Example
37///
38/// ```ignore
39/// validated_string! {
40///     /// A validated username
41///     pub struct UserName(String) {
42///         validation: |s| {
43///             if s.trim().is_empty() {
44///                 Err(ValidationError::EmptyUserName)
45///             } else {
46///                 Ok(())
47///             }
48///         },
49///         error_variant: EmptyUserName,
50///         error_message: "username cannot be empty",
51///     }
52/// }
53/// ```
54macro_rules! validated_string {
55    (
56        $(#[$meta:meta])*
57        $vis:vis struct $name:ident(String) {
58            validation: |$s_param:ident| $validation:expr,
59        }
60    ) => {
61        $(#[$meta])*
62        #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
63        #[serde(transparent)]
64        $vis struct $name(String);
65
66        impl $name {
67            #[doc = concat!("Create a new ", stringify!($name), " after validation")]
68            pub fn new($s_param: String) -> Result<Self, ValidationError> {
69                $validation?;
70                Ok(Self($s_param))
71            }
72
73            #[doc = concat!("Get the ", stringify!($name), " as a string slice")]
74            #[must_use]
75            #[inline]
76            pub fn as_str(&self) -> &str {
77                &self.0
78            }
79        }
80
81        impl AsRef<str> for $name {
82            #[inline]
83            fn as_ref(&self) -> &str {
84                &self.0
85            }
86        }
87
88        impl std::ops::Deref for $name {
89            type Target = str;
90
91            #[inline]
92            fn deref(&self) -> &Self::Target {
93                &self.0
94            }
95        }
96
97        impl fmt::Display for $name {
98            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99                write!(f, "{}", self.0)
100            }
101        }
102
103        impl TryFrom<String> for $name {
104            type Error = ValidationError;
105
106            fn try_from($s_param: String) -> Result<Self, Self::Error> {
107                Self::new($s_param)
108            }
109        }
110
111        impl<'de> Deserialize<'de> for $name {
112            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
113            where
114                D: serde::Deserializer<'de>,
115            {
116                let s = String::deserialize(deserializer)?;
117                Self::new(s).map_err(serde::de::Error::custom)
118            }
119        }
120    };
121}
122
123// Now use the macro to generate the types
124
125validated_string! {
126    /// A validated hostname that cannot be empty or whitespace-only
127    ///
128    /// This type enforces at compile time that a hostname is always valid,
129    /// eliminating the need for runtime validation checks.
130    ///
131    /// # Examples
132    /// ```
133    /// use nntp_proxy::types::HostName;
134    ///
135    /// let host = HostName::new("news.example.com".to_string()).unwrap();
136    /// assert_eq!(host.as_str(), "news.example.com");
137    ///
138    /// // Empty strings are rejected
139    /// assert!(HostName::new("".to_string()).is_err());
140    /// assert!(HostName::new("   ".to_string()).is_err());
141    /// ```
142    #[doc(alias = "host")]
143    #[doc(alias = "domain")]
144    pub struct HostName(String) {
145        validation: |s| {
146            if s.trim().is_empty() {
147                Err(ValidationError::EmptyHostName)
148            } else {
149                Ok(())
150            }
151        },
152    }
153}
154
155validated_string! {
156    /// A validated server name that cannot be empty or whitespace-only
157    pub struct ServerName(String) {
158        validation: |s| {
159            if s.trim().is_empty() {
160                Err(ValidationError::EmptyServerName)
161            } else {
162                Ok(())
163            }
164        },
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    // HostName tests
173    #[test]
174    fn test_hostname_valid() {
175        let host = HostName::new("example.com".to_string()).unwrap();
176        assert_eq!(host.as_str(), "example.com");
177    }
178
179    #[test]
180    fn test_hostname_valid_ip() {
181        let host = HostName::new("192.168.1.1".to_string()).unwrap();
182        assert_eq!(host.as_str(), "192.168.1.1");
183    }
184
185    #[test]
186    fn test_hostname_valid_localhost() {
187        let host = HostName::new("localhost".to_string()).unwrap();
188        assert_eq!(host.as_str(), "localhost");
189    }
190
191    #[test]
192    fn test_hostname_valid_with_subdomain() {
193        let host = HostName::new("news.example.com".to_string()).unwrap();
194        assert_eq!(host.as_str(), "news.example.com");
195    }
196
197    #[test]
198    fn test_hostname_valid_with_port_notation() {
199        // HostName only validates non-empty, does not parse or validate port notation.
200        // In production, host and port are stored separately (HostName + Port types).
201        // This test verifies the type doesn't reject strings with colons.
202        let host = HostName::new("example.com:119".to_string()).unwrap();
203        assert_eq!(host.as_str(), "example.com:119");
204    }
205
206    #[test]
207    fn test_hostname_empty_rejected() {
208        let result = HostName::new("".to_string());
209        assert!(matches!(result, Err(ValidationError::EmptyHostName)));
210    }
211
212    #[test]
213    fn test_hostname_whitespace_rejected() {
214        let result = HostName::new("   ".to_string());
215        assert!(matches!(result, Err(ValidationError::EmptyHostName)));
216    }
217
218    #[test]
219    fn test_hostname_tabs_rejected() {
220        let result = HostName::new("\t\t".to_string());
221        assert!(matches!(result, Err(ValidationError::EmptyHostName)));
222    }
223
224    #[test]
225    fn test_hostname_newlines_rejected() {
226        let result = HostName::new("\n\n".to_string());
227        assert!(matches!(result, Err(ValidationError::EmptyHostName)));
228    }
229
230    #[test]
231    fn test_hostname_mixed_whitespace_rejected() {
232        let result = HostName::new(" \t\n ".to_string());
233        assert!(matches!(result, Err(ValidationError::EmptyHostName)));
234    }
235
236    #[test]
237    fn test_hostname_display() {
238        let host = HostName::new("example.com".to_string()).unwrap();
239        assert_eq!(format!("{}", host), "example.com");
240    }
241
242    #[test]
243    fn test_hostname_as_ref() {
244        let host = HostName::new("example.com".to_string()).unwrap();
245        let s: &str = host.as_ref();
246        assert_eq!(s, "example.com");
247    }
248
249    #[test]
250    fn test_hostname_try_from() {
251        let result: Result<HostName, _> = "example.com".to_string().try_into();
252        assert!(result.is_ok());
253        assert_eq!(result.unwrap().as_str(), "example.com");
254    }
255
256    #[test]
257    fn test_hostname_try_from_empty() {
258        let result: Result<HostName, _> = "".to_string().try_into();
259        assert!(result.is_err());
260    }
261
262    #[test]
263    fn test_hostname_clone() {
264        let host1 = HostName::new("example.com".to_string()).unwrap();
265        let host2 = host1.clone();
266        assert_eq!(host1, host2);
267    }
268
269    #[test]
270    fn test_hostname_equality() {
271        let host1 = HostName::new("example.com".to_string()).unwrap();
272        let host2 = HostName::new("example.com".to_string()).unwrap();
273        let host3 = HostName::new("other.com".to_string()).unwrap();
274        assert_eq!(host1, host2);
275        assert_ne!(host1, host3);
276    }
277
278    #[test]
279    fn test_hostname_serde() {
280        let host = HostName::new("test.com".to_string()).unwrap();
281        let json = serde_json::to_string(&host).unwrap();
282        assert_eq!(json, "\"test.com\"");
283
284        let deserialized: HostName = serde_json::from_str(&json).unwrap();
285        assert_eq!(deserialized, host);
286    }
287
288    #[test]
289    fn test_hostname_serde_invalid() {
290        let json = "\"\"";
291        let result: Result<HostName, _> = serde_json::from_str(json);
292        assert!(result.is_err());
293    }
294
295    #[test]
296    fn test_hostname_serde_whitespace_rejected() {
297        let json = "\"   \"";
298        let result: Result<HostName, _> = serde_json::from_str(json);
299        assert!(result.is_err());
300    }
301
302    // ServerName tests
303    #[test]
304    fn test_server_name_valid() {
305        let name = ServerName::new("backend-1".to_string()).unwrap();
306        assert_eq!(name.as_str(), "backend-1");
307    }
308
309    #[test]
310    fn test_server_name_valid_simple() {
311        let name = ServerName::new("server1".to_string()).unwrap();
312        assert_eq!(name.as_str(), "server1");
313    }
314
315    #[test]
316    fn test_server_name_valid_descriptive() {
317        let name = ServerName::new("Primary News Server".to_string()).unwrap();
318        assert_eq!(name.as_str(), "Primary News Server");
319    }
320
321    #[test]
322    fn test_server_name_valid_with_symbols() {
323        let name = ServerName::new("server_1-prod".to_string()).unwrap();
324        assert_eq!(name.as_str(), "server_1-prod");
325    }
326
327    #[test]
328    fn test_server_name_empty_rejected() {
329        let result = ServerName::new("".to_string());
330        assert!(matches!(result, Err(ValidationError::EmptyServerName)));
331    }
332
333    #[test]
334    fn test_server_name_whitespace_rejected() {
335        let result = ServerName::new("   ".to_string());
336        assert!(matches!(result, Err(ValidationError::EmptyServerName)));
337    }
338
339    #[test]
340    fn test_server_name_tabs_rejected() {
341        let result = ServerName::new("\t".to_string());
342        assert!(matches!(result, Err(ValidationError::EmptyServerName)));
343    }
344
345    #[test]
346    fn test_server_name_display() {
347        let name = ServerName::new("backend-1".to_string()).unwrap();
348        assert_eq!(format!("{}", name), "backend-1");
349    }
350
351    #[test]
352    fn test_server_name_as_ref() {
353        let name = ServerName::new("backend-1".to_string()).unwrap();
354        let s: &str = name.as_ref();
355        assert_eq!(s, "backend-1");
356    }
357
358    #[test]
359    fn test_server_name_try_from() {
360        let result: Result<ServerName, _> = "backend-1".to_string().try_into();
361        assert!(result.is_ok());
362        assert_eq!(result.unwrap().as_str(), "backend-1");
363    }
364
365    #[test]
366    fn test_server_name_try_from_empty() {
367        let result: Result<ServerName, _> = "".to_string().try_into();
368        assert!(result.is_err());
369    }
370
371    #[test]
372    fn test_server_name_clone() {
373        let name1 = ServerName::new("backend-1".to_string()).unwrap();
374        let name2 = name1.clone();
375        assert_eq!(name1, name2);
376    }
377
378    #[test]
379    fn test_server_name_equality() {
380        let name1 = ServerName::new("backend-1".to_string()).unwrap();
381        let name2 = ServerName::new("backend-1".to_string()).unwrap();
382        let name3 = ServerName::new("backend-2".to_string()).unwrap();
383        assert_eq!(name1, name2);
384        assert_ne!(name1, name3);
385    }
386
387    #[test]
388    fn test_server_name_serde() {
389        let name = ServerName::new("backend-1".to_string()).unwrap();
390        let json = serde_json::to_string(&name).unwrap();
391        assert_eq!(json, "\"backend-1\"");
392
393        let deserialized: ServerName = serde_json::from_str(&json).unwrap();
394        assert_eq!(deserialized, name);
395    }
396
397    #[test]
398    fn test_server_name_serde_invalid() {
399        let json = "\"\"";
400        let result: Result<ServerName, _> = serde_json::from_str(json);
401        assert!(result.is_err());
402    }
403
404    // ValidationError tests
405    #[test]
406    fn test_validation_error_display_hostname() {
407        let error = ValidationError::EmptyHostName;
408        assert_eq!(
409            format!("{}", error),
410            "hostname cannot be empty or whitespace"
411        );
412    }
413
414    #[test]
415    fn test_validation_error_display_servername() {
416        let error = ValidationError::EmptyServerName;
417        assert_eq!(
418            format!("{}", error),
419            "server name cannot be empty or whitespace"
420        );
421    }
422
423    #[test]
424    fn test_validation_error_display_invalid_hostname() {
425        let error = ValidationError::InvalidHostName("bad-host".to_string());
426        assert!(format!("{}", error).contains("bad-host"));
427    }
428
429    #[test]
430    fn test_validation_error_equality() {
431        let error1 = ValidationError::EmptyHostName;
432        let error2 = ValidationError::EmptyHostName;
433        let error3 = ValidationError::EmptyServerName;
434        assert_eq!(error1, error2);
435        assert_ne!(error1, error3);
436    }
437
438    #[test]
439    fn test_validation_error_clone() {
440        let error1 = ValidationError::EmptyHostName;
441        let error2 = error1.clone();
442        assert_eq!(error1, error2);
443    }
444
445    // Integration tests
446    #[test]
447    fn test_hostname_and_servername_different_types() {
448        let host = HostName::new("example.com".to_string()).unwrap();
449        let name = ServerName::new("example.com".to_string()).unwrap();
450        // They have the same string value but are different types
451        assert_eq!(host.as_str(), name.as_str());
452    }
453
454    #[test]
455    fn test_multiple_validations() {
456        // Ensure multiple validations work independently
457        let host1 = HostName::new("host1.com".to_string()).unwrap();
458        let host2 = HostName::new("host2.com".to_string()).unwrap();
459        let name1 = ServerName::new("server1".to_string()).unwrap();
460        let name2 = ServerName::new("server2".to_string()).unwrap();
461
462        assert_ne!(host1, host2);
463        assert_ne!(name1, name2);
464    }
465
466    #[test]
467    fn test_serde_roundtrip_hostname() {
468        let original = HostName::new("test.example.com".to_string()).unwrap();
469        let json = serde_json::to_string(&original).unwrap();
470        let deserialized: HostName = serde_json::from_str(&json).unwrap();
471        assert_eq!(original, deserialized);
472    }
473
474    #[test]
475    fn test_serde_roundtrip_servername() {
476        let original = ServerName::new("production-server-01".to_string()).unwrap();
477        let json = serde_json::to_string(&original).unwrap();
478        let deserialized: ServerName = serde_json::from_str(&json).unwrap();
479        assert_eq!(original, deserialized);
480    }
481}