nntp_proxy/types/
validated.rs

1//! Validated string types that enforce invariants at construction time
2
3use serde::{Deserialize, Serialize};
4use std::fmt;
5use std::path::{Path, PathBuf};
6use thiserror::Error;
7
8/// Validation errors for string types
9#[derive(Debug, Clone, Error, PartialEq, Eq)]
10#[non_exhaustive]
11pub enum ValidationError {
12    #[error("hostname cannot be empty or whitespace")]
13    EmptyHostName,
14    #[error("server name cannot be empty or whitespace")]
15    EmptyServerName,
16    #[error("invalid hostname: {0}")]
17    InvalidHostName(String),
18    #[error("port cannot be 0")]
19    InvalidPort,
20    #[error("invalid message ID: {0}")]
21    InvalidMessageId(String),
22    #[error("config path cannot be empty")]
23    EmptyConfigPath,
24    #[error("username cannot be empty or whitespace")]
25    EmptyUsername,
26    #[error("password cannot be empty or whitespace")]
27    EmptyPassword,
28}
29
30/// Generate validated non-empty string newtypes
31macro_rules! validated_string {
32    ($(#[$meta:meta])* $vis:vis struct $name:ident($err:path);) => {
33        $(#[$meta])*
34        #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
35        #[serde(transparent)]
36        $vis struct $name(String);
37
38        impl $name {
39            pub fn new(s: String) -> Result<Self, ValidationError> {
40                if s.trim().is_empty() {
41                    Err($err)
42                } else {
43                    Ok(Self(s))
44                }
45            }
46
47            #[must_use]
48            #[inline]
49            pub fn as_str(&self) -> &str {
50                &self.0
51            }
52        }
53
54        impl AsRef<str> for $name {
55            #[inline]
56            fn as_ref(&self) -> &str {
57                &self.0
58            }
59        }
60
61        impl std::ops::Deref for $name {
62            type Target = str;
63            #[inline]
64            fn deref(&self) -> &Self::Target {
65                &self.0
66            }
67        }
68
69        impl fmt::Display for $name {
70            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71                f.write_str(&self.0)
72            }
73        }
74
75        impl TryFrom<String> for $name {
76            type Error = ValidationError;
77            fn try_from(s: String) -> Result<Self, Self::Error> {
78                Self::new(s)
79            }
80        }
81
82        impl<'de> Deserialize<'de> for $name {
83            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
84            where
85                D: serde::Deserializer<'de>,
86            {
87                let s = String::deserialize(deserializer)?;
88                Self::new(s).map_err(serde::de::Error::custom)
89            }
90        }
91    };
92}
93
94validated_string! {
95    /// Validated hostname (non-empty, non-whitespace)
96    pub struct HostName(ValidationError::EmptyHostName);
97}
98
99validated_string! {
100    /// Validated server name (non-empty, non-whitespace)
101    pub struct ServerName(ValidationError::EmptyServerName);
102}
103
104validated_string! {
105    /// Validated username (non-empty, non-whitespace)
106    pub struct Username(ValidationError::EmptyUsername);
107}
108
109validated_string! {
110    /// Validated password (non-empty, non-whitespace)
111    pub struct Password(ValidationError::EmptyPassword);
112}
113
114/// Validated configuration file path (non-empty, non-whitespace)
115#[derive(Debug, Clone, PartialEq, Eq, Hash)]
116pub struct ConfigPath(PathBuf);
117
118impl ConfigPath {
119    pub fn new(path: impl AsRef<Path>) -> Result<Self, ValidationError> {
120        let path_ref = path.as_ref();
121        let path_str = path_ref.to_str().ok_or(ValidationError::EmptyConfigPath)?;
122        if path_str.trim().is_empty() {
123            return Err(ValidationError::EmptyConfigPath);
124        }
125        Ok(Self(path_ref.to_path_buf()))
126    }
127
128    #[must_use]
129    #[inline]
130    pub fn as_path(&self) -> &Path {
131        &self.0
132    }
133
134    #[must_use]
135    #[inline]
136    pub fn as_str(&self) -> &str {
137        self.0.to_str().unwrap_or("")
138    }
139}
140
141impl AsRef<Path> for ConfigPath {
142    #[inline]
143    fn as_ref(&self) -> &Path {
144        &self.0
145    }
146}
147
148impl fmt::Display for ConfigPath {
149    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150        write!(f, "{}", self.0.display())
151    }
152}
153
154impl std::str::FromStr for ConfigPath {
155    type Err = ValidationError;
156    fn from_str(s: &str) -> Result<Self, Self::Err> {
157        Self::new(s)
158    }
159}
160
161impl TryFrom<String> for ConfigPath {
162    type Error = ValidationError;
163    fn try_from(s: String) -> Result<Self, Self::Error> {
164        Self::new(s)
165    }
166}
167
168impl TryFrom<&str> for ConfigPath {
169    type Error = ValidationError;
170    fn try_from(s: &str) -> Result<Self, Self::Error> {
171        Self::new(s)
172    }
173}
174
175impl Serialize for ConfigPath {
176    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
177    where
178        S: serde::Serializer,
179    {
180        serializer.serialize_str(self.as_str())
181    }
182}
183
184impl<'de> Deserialize<'de> for ConfigPath {
185    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
186    where
187        D: serde::Deserializer<'de>,
188    {
189        let s = String::deserialize(deserializer)?;
190        Self::new(s).map_err(serde::de::Error::custom)
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn test_hostname_validation() {
200        assert!(HostName::new("example.com".to_string()).is_ok());
201        assert!(matches!(
202            HostName::new("".to_string()),
203            Err(ValidationError::EmptyHostName)
204        ));
205        assert!(matches!(
206            HostName::new("   ".to_string()),
207            Err(ValidationError::EmptyHostName)
208        ));
209    }
210
211    #[test]
212    fn test_server_name_validation() {
213        assert!(ServerName::new("backend-1".to_string()).is_ok());
214        assert!(matches!(
215            ServerName::new("".to_string()),
216            Err(ValidationError::EmptyServerName)
217        ));
218        assert!(matches!(
219            ServerName::new("\t".to_string()),
220            Err(ValidationError::EmptyServerName)
221        ));
222    }
223
224    #[test]
225    fn test_username_validation() {
226        assert!(Username::new("alice".to_string()).is_ok());
227        assert!(matches!(
228            Username::new("".to_string()),
229            Err(ValidationError::EmptyUsername)
230        ));
231    }
232
233    #[test]
234    fn test_password_validation() {
235        assert!(Password::new("secret".to_string()).is_ok());
236        assert!(matches!(
237            Password::new("".to_string()),
238            Err(ValidationError::EmptyPassword)
239        ));
240    }
241
242    #[test]
243    fn test_config_path_validation() {
244        assert!(ConfigPath::try_from("config.toml").is_ok());
245        assert!(matches!(
246            ConfigPath::try_from(""),
247            Err(ValidationError::EmptyConfigPath)
248        ));
249    }
250}