1use std::net::IpAddr;
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19#[allow(missing_docs)]
20pub enum ValidationError {
21 Empty,
23 TooShort { min: usize, actual: usize },
25 TooLong { max: usize, actual: usize },
27 BelowMin { min: String, actual: String },
29 AboveMax { max: String, actual: String },
31 InvalidPattern { pattern: String },
33 NotInSet { allowed: Vec<String> },
35 Custom(String),
37}
38
39impl std::fmt::Display for ValidationError {
40 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 match self {
42 Self::Empty => write!(f, "Value cannot be empty"),
43 Self::TooShort { min, actual } => {
44 write!(f, "Value too short: minimum {min}, got {actual}")
45 }
46 Self::TooLong { max, actual } => {
47 write!(f, "Value too long: maximum {max}, got {actual}")
48 }
49 Self::BelowMin { min, actual } => {
50 write!(f, "Value below minimum: min {min}, got {actual}")
51 }
52 Self::AboveMax { max, actual } => {
53 write!(f, "Value above maximum: max {max}, got {actual}")
54 }
55 Self::InvalidPattern { pattern } => {
56 write!(f, "Value doesn't match pattern: {pattern}")
57 }
58 Self::NotInSet { allowed } => {
59 write!(f, "Value not in allowed set: {allowed:?}")
60 }
61 Self::Custom(msg) => write!(f, "{msg}"),
62 }
63 }
64}
65
66impl std::error::Error for ValidationError {}
67
68pub type ValidationResult<T> = Result<T, ValidationError>;
70
71pub fn validate_not_empty(value: &str) -> ValidationResult<&str> {
77 if value.trim().is_empty() {
78 Err(ValidationError::Empty)
79 } else {
80 Ok(value)
81 }
82}
83
84pub const fn validate_length(value: &str, min: usize, max: usize) -> ValidationResult<&str> {
90 let len = value.len();
91 if len < min {
92 Err(ValidationError::TooShort { min, actual: len })
93 } else if len > max {
94 Err(ValidationError::TooLong { max, actual: len })
95 } else {
96 Ok(value)
97 }
98}
99
100pub fn validate_range<T>(value: T, min: T, max: T) -> ValidationResult<T>
106where
107 T: PartialOrd + std::fmt::Display + Copy,
108{
109 if value < min {
110 Err(ValidationError::BelowMin {
111 min: min.to_string(),
112 actual: value.to_string(),
113 })
114 } else if value > max {
115 Err(ValidationError::AboveMax {
116 max: max.to_string(),
117 actual: value.to_string(),
118 })
119 } else {
120 Ok(value)
121 }
122}
123
124#[must_use]
128pub fn is_valid_email(email: &str) -> bool {
129 let email = email.trim();
130
131 let parts: Vec<&str> = email.split('@').collect();
133 if parts.len() != 2 {
134 return false;
135 }
136
137 let (local, domain) = (parts[0], parts[1]);
138
139 if local.is_empty() || local.len() > 64 {
141 return false;
142 }
143
144 if domain.is_empty() || domain.len() > 255 {
146 return false;
147 }
148
149 if !domain.contains('.') {
151 return false;
152 }
153
154 if email.contains("..") {
156 return false;
157 }
158
159 true
160}
161
162#[must_use]
168pub fn is_valid_url(url: &str) -> bool {
169 let url = url.trim();
170
171 let rest = url
173 .strip_prefix("https://")
174 .or_else(|| url.strip_prefix("http://"));
175
176 let Some(rest) = rest else {
177 return false;
178 };
179
180 if rest.is_empty() {
182 return false;
183 }
184
185 if rest.contains(char::is_whitespace) {
187 return false;
188 }
189
190 let host = rest.split('/').next().unwrap_or(rest);
192 let host = host.split('?').next().unwrap_or(host);
193 let host = host.split('#').next().unwrap_or(host);
194
195 let host_without_port = if host.starts_with('[') {
197 host.split(']')
198 .next()
199 .map_or(host, |h| h.trim_start_matches('['))
200 } else {
201 host.rsplit_once(':').map_or(host, |(h, _)| h)
202 };
203
204 if host_without_port.eq_ignore_ascii_case("localhost") {
206 return true;
207 }
208
209 host_without_port
211 .find('.')
212 .is_some_and(|dot_pos| dot_pos > 0 && dot_pos < host_without_port.len() - 1)
213}
214
215#[must_use]
217pub fn is_valid_ip(ip: &str) -> bool {
218 ip.trim().parse::<IpAddr>().is_ok()
219}
220
221#[must_use]
223pub fn is_valid_ipv4(ip: &str) -> bool {
224 ip.trim().parse::<std::net::Ipv4Addr>().is_ok()
225}
226
227#[must_use]
229pub fn is_valid_ipv6(ip: &str) -> bool {
230 ip.trim().parse::<std::net::Ipv6Addr>().is_ok()
231}
232
233#[must_use]
235pub fn is_alphanumeric(s: &str) -> bool {
236 !s.is_empty() && s.chars().all(char::is_alphanumeric)
237}
238
239#[must_use]
241pub fn is_identifier(s: &str) -> bool {
242 if s.is_empty() {
243 return false;
244 }
245
246 let mut chars = s.chars();
247
248 match chars.next() {
250 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
251 _ => return false,
252 }
253
254 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
256}
257
258#[must_use]
264pub fn is_valid_semver(version: &str) -> bool {
265 let version = version.trim().strip_prefix('v').unwrap_or(version);
266
267 let core_version = version.split(&['-', '+'][..]).next().unwrap_or(version);
269
270 let parts: Vec<&str> = core_version.split('.').collect();
271
272 if parts.len() != 3 {
273 return false;
274 }
275
276 parts.iter().all(|part| part.parse::<u64>().is_ok())
277}
278
279pub fn validate_in_set<T>(value: &T, allowed: &[T]) -> ValidationResult<()>
285where
286 T: PartialEq + std::fmt::Display,
287{
288 if allowed.contains(value) {
289 Ok(())
290 } else {
291 Err(ValidationError::NotInSet {
292 allowed: allowed.iter().map(ToString::to_string).collect(),
293 })
294 }
295}
296
297#[derive(Debug, Default)]
299pub struct Validator {
300 errors: Vec<(String, ValidationError)>,
301}
302
303impl Validator {
304 #[must_use]
306 pub fn new() -> Self {
307 Self::default()
308 }
309
310 pub fn check<F>(&mut self, field: &str, validation: F) -> &mut Self
312 where
313 F: FnOnce() -> Result<(), ValidationError>,
314 {
315 if let Err(e) = validation() {
316 self.errors.push((field.to_string(), e));
317 }
318 self
319 }
320
321 #[must_use]
323 pub const fn is_valid(&self) -> bool {
324 self.errors.is_empty()
325 }
326
327 #[must_use]
329 pub fn errors(&self) -> &[(String, ValidationError)] {
330 &self.errors
331 }
332
333 pub fn finish(self) -> Result<(), Vec<(String, ValidationError)>> {
339 if self.errors.is_empty() {
340 Ok(())
341 } else {
342 Err(self.errors)
343 }
344 }
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350
351 #[test]
352 fn test_validate_not_empty() {
353 assert!(validate_not_empty("hello").is_ok());
354 assert!(validate_not_empty("").is_err());
355 assert!(validate_not_empty(" ").is_err());
356 }
357
358 #[test]
359 fn test_validate_length() {
360 assert!(validate_length("hello", 1, 10).is_ok());
361 assert!(validate_length("hi", 5, 10).is_err());
362 assert!(validate_length("hello world!", 1, 5).is_err());
363 }
364
365 #[test]
366 fn test_validate_range() {
367 assert!(validate_range(5, 1, 10).is_ok());
368 assert!(validate_range(0, 1, 10).is_err());
369 assert!(validate_range(15, 1, 10).is_err());
370 }
371
372 #[test]
373 fn test_is_valid_email() {
374 assert!(is_valid_email("user@example.com"));
375 assert!(is_valid_email("user.name@example.co.uk"));
376 assert!(!is_valid_email("invalid"));
377 assert!(!is_valid_email("@example.com"));
378 assert!(!is_valid_email("user@"));
379 assert!(!is_valid_email("user@@example.com"));
380 }
381
382 #[test]
383 fn test_is_valid_url() {
384 assert!(is_valid_url("https://example.com"));
385 assert!(is_valid_url("http://example.com/path"));
386 assert!(is_valid_url("https://example.com/path?q=1#frag"));
387 assert!(!is_valid_url("example.com"));
388 assert!(!is_valid_url("ftp://example.com"));
389 assert!(!is_valid_url("https://"));
390 assert!(!is_valid_url("http://."));
391 assert!(!is_valid_url("https://invalid space.com"));
392 assert!(!is_valid_url("http://.com"));
393 assert!(!is_valid_url("http://com."));
394
395 assert!(is_valid_url("http://localhost"));
397 assert!(is_valid_url("http://localhost:8080"));
398 assert!(is_valid_url("http://localhost/path"));
399 assert!(is_valid_url("http://localhost:8080/path?q=1"));
400 assert!(is_valid_url("https://LOCALHOST"));
401
402 assert!(is_valid_url("http://example.com:8080"));
404
405 assert!(!is_valid_url("http://:8080"));
407 }
408
409 #[test]
410 fn test_is_valid_ip() {
411 assert!(is_valid_ip("192.168.1.1"));
412 assert!(is_valid_ip("::1"));
413 assert!(is_valid_ip("2001:db8::1"));
414 assert!(!is_valid_ip("not an ip"));
415 assert!(!is_valid_ip("256.1.1.1"));
416 }
417
418 #[test]
419 fn test_is_identifier() {
420 assert!(is_identifier("hello"));
421 assert!(is_identifier("_private"));
422 assert!(is_identifier("camelCase"));
423 assert!(is_identifier("snake_case"));
424 assert!(is_identifier("with123"));
425 assert!(!is_identifier("123start"));
426 assert!(!is_identifier("has-dash"));
427 assert!(!is_identifier(""));
428 }
429
430 #[test]
431 fn test_is_valid_semver() {
432 assert!(is_valid_semver("1.0.0"));
433 assert!(is_valid_semver("v1.0.0"));
434 assert!(is_valid_semver("0.1.0"));
435 assert!(is_valid_semver("1.0.0-alpha"));
436 assert!(is_valid_semver("1.0.0-alpha.1"));
437 assert!(is_valid_semver("1.0.0-rc.2"));
438 assert!(is_valid_semver("1.0.0+build.42"));
439 assert!(is_valid_semver("1.0.0-beta+exp.sha.5114f85"));
440 assert!(!is_valid_semver("1.0"));
441 assert!(!is_valid_semver("1"));
442 assert!(!is_valid_semver("a.b.c"));
443 }
444
445 #[test]
446 fn test_validator() {
447 let mut v = Validator::new();
448 v.check("email", || {
449 if is_valid_email("test@example.com") {
450 Ok(())
451 } else {
452 Err(ValidationError::InvalidPattern {
453 pattern: "email".to_string(),
454 })
455 }
456 });
457 assert!(v.is_valid());
458 assert!(v.finish().is_ok());
459 }
460}