Skip to main content

commons/
validation.rs

1//! Input validation utilities.
2//!
3//! Provides common validation functions for strings, numbers, and other types.
4//!
5//! # Example
6//!
7//! ```rust
8//! use commons::validation::{is_valid_email, is_valid_url, validate_length};
9//!
10//! assert!(is_valid_email("user@example.com"));
11//! assert!(is_valid_url("https://example.com"));
12//! assert!(validate_length("hello", 1, 10).is_ok());
13//! ```
14
15use std::net::IpAddr;
16
17/// Validation error types.
18#[derive(Debug, Clone, PartialEq, Eq)]
19#[allow(missing_docs)]
20pub enum ValidationError {
21    /// Value is empty when it shouldn't be.
22    Empty,
23    /// Value is too short.
24    TooShort { min: usize, actual: usize },
25    /// Value is too long.
26    TooLong { max: usize, actual: usize },
27    /// Value is below minimum.
28    BelowMin { min: String, actual: String },
29    /// Value is above maximum.
30    AboveMax { max: String, actual: String },
31    /// Value doesn't match expected pattern.
32    InvalidPattern { pattern: String },
33    /// Value is not in allowed set.
34    NotInSet { allowed: Vec<String> },
35    /// Custom validation error.
36    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
68/// Result type for validation operations.
69pub type ValidationResult<T> = Result<T, ValidationError>;
70
71/// Validate that a string is not empty.
72pub fn validate_not_empty(value: &str) -> ValidationResult<&str> {
73    if value.trim().is_empty() {
74        Err(ValidationError::Empty)
75    } else {
76        Ok(value)
77    }
78}
79
80/// Validate string length is within bounds.
81pub fn validate_length(value: &str, min: usize, max: usize) -> ValidationResult<&str> {
82    let len = value.len();
83    if len < min {
84        Err(ValidationError::TooShort { min, actual: len })
85    } else if len > max {
86        Err(ValidationError::TooLong { max, actual: len })
87    } else {
88        Ok(value)
89    }
90}
91
92/// Validate that a number is within range.
93pub fn validate_range<T>(value: T, min: T, max: T) -> ValidationResult<T>
94where
95    T: PartialOrd + std::fmt::Display + Copy,
96{
97    if value < min {
98        Err(ValidationError::BelowMin {
99            min: min.to_string(),
100            actual: value.to_string(),
101        })
102    } else if value > max {
103        Err(ValidationError::AboveMax {
104            max: max.to_string(),
105            actual: value.to_string(),
106        })
107    } else {
108        Ok(value)
109    }
110}
111
112/// Check if a string looks like a valid email address.
113///
114/// This is a simple check, not RFC 5322 compliant.
115#[must_use]
116pub fn is_valid_email(email: &str) -> bool {
117    let email = email.trim();
118
119    // Must contain exactly one @
120    let parts: Vec<&str> = email.split('@').collect();
121    if parts.len() != 2 {
122        return false;
123    }
124
125    let (local, domain) = (parts[0], parts[1]);
126
127    // Local part checks
128    if local.is_empty() || local.len() > 64 {
129        return false;
130    }
131
132    // Domain checks
133    if domain.is_empty() || domain.len() > 255 {
134        return false;
135    }
136
137    // Domain must contain at least one dot
138    if !domain.contains('.') {
139        return false;
140    }
141
142    // No consecutive dots
143    if email.contains("..") {
144        return false;
145    }
146
147    true
148}
149
150/// Check if a string looks like a valid URL.
151#[must_use]
152pub fn is_valid_url(url: &str) -> bool {
153    let url = url.trim();
154
155    // Must start with http:// or https://
156    if !url.starts_with("http://") && !url.starts_with("https://") {
157        return false;
158    }
159
160    // Must have something after the protocol
161    let rest = url
162        .strip_prefix("https://")
163        .or_else(|| url.strip_prefix("http://"));
164    match rest {
165        Some(r) => !r.is_empty() && r.contains('.'),
166        None => false,
167    }
168}
169
170/// Check if a string is a valid IP address (v4 or v6).
171#[must_use]
172pub fn is_valid_ip(ip: &str) -> bool {
173    ip.trim().parse::<IpAddr>().is_ok()
174}
175
176/// Check if a string is a valid IPv4 address.
177#[must_use]
178pub fn is_valid_ipv4(ip: &str) -> bool {
179    ip.trim().parse::<std::net::Ipv4Addr>().is_ok()
180}
181
182/// Check if a string is a valid IPv6 address.
183#[must_use]
184pub fn is_valid_ipv6(ip: &str) -> bool {
185    ip.trim().parse::<std::net::Ipv6Addr>().is_ok()
186}
187
188/// Check if a string contains only alphanumeric characters.
189#[must_use]
190pub fn is_alphanumeric(s: &str) -> bool {
191    !s.is_empty() && s.chars().all(|c| c.is_alphanumeric())
192}
193
194/// Check if a string contains only ASCII alphanumeric characters and underscores.
195#[must_use]
196pub fn is_identifier(s: &str) -> bool {
197    if s.is_empty() {
198        return false;
199    }
200
201    let mut chars = s.chars();
202
203    // First character must be letter or underscore
204    match chars.next() {
205        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
206        _ => return false,
207    }
208
209    // Rest can be alphanumeric or underscore
210    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
211}
212
213/// Check if a string is a valid semantic version.
214#[must_use]
215pub fn is_valid_semver(version: &str) -> bool {
216    let version = version.trim().strip_prefix('v').unwrap_or(version);
217    let parts: Vec<&str> = version.split('.').collect();
218
219    if parts.len() != 3 {
220        return false;
221    }
222
223    parts.iter().all(|part| {
224        let clean = part.split('-').next().unwrap_or(part);
225        clean.parse::<u64>().is_ok()
226    })
227}
228
229/// Validate that a value is in an allowed set.
230pub fn validate_in_set<T>(value: &T, allowed: &[T]) -> ValidationResult<()>
231where
232    T: PartialEq + std::fmt::Display,
233{
234    if allowed.contains(value) {
235        Ok(())
236    } else {
237        Err(ValidationError::NotInSet {
238            allowed: allowed.iter().map(|v| v.to_string()).collect(),
239        })
240    }
241}
242
243/// Builder for composing multiple validations.
244#[derive(Debug, Default)]
245pub struct Validator {
246    errors: Vec<(String, ValidationError)>,
247}
248
249impl Validator {
250    /// Create a new validator.
251    #[must_use]
252    pub fn new() -> Self {
253        Self::default()
254    }
255
256    /// Add a validation check.
257    pub fn check<F>(&mut self, field: &str, validation: F) -> &mut Self
258    where
259        F: FnOnce() -> Result<(), ValidationError>,
260    {
261        if let Err(e) = validation() {
262            self.errors.push((field.to_string(), e));
263        }
264        self
265    }
266
267    /// Check if validation passed.
268    #[must_use]
269    pub fn is_valid(&self) -> bool {
270        self.errors.is_empty()
271    }
272
273    /// Get all errors.
274    #[must_use]
275    pub fn errors(&self) -> &[(String, ValidationError)] {
276        &self.errors
277    }
278
279    /// Finish validation and return result.
280    pub fn finish(self) -> Result<(), Vec<(String, ValidationError)>> {
281        if self.errors.is_empty() {
282            Ok(())
283        } else {
284            Err(self.errors)
285        }
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    #[test]
294    fn test_validate_not_empty() {
295        assert!(validate_not_empty("hello").is_ok());
296        assert!(validate_not_empty("").is_err());
297        assert!(validate_not_empty("   ").is_err());
298    }
299
300    #[test]
301    fn test_validate_length() {
302        assert!(validate_length("hello", 1, 10).is_ok());
303        assert!(validate_length("hi", 5, 10).is_err());
304        assert!(validate_length("hello world!", 1, 5).is_err());
305    }
306
307    #[test]
308    fn test_validate_range() {
309        assert!(validate_range(5, 1, 10).is_ok());
310        assert!(validate_range(0, 1, 10).is_err());
311        assert!(validate_range(15, 1, 10).is_err());
312    }
313
314    #[test]
315    fn test_is_valid_email() {
316        assert!(is_valid_email("user@example.com"));
317        assert!(is_valid_email("user.name@example.co.uk"));
318        assert!(!is_valid_email("invalid"));
319        assert!(!is_valid_email("@example.com"));
320        assert!(!is_valid_email("user@"));
321        assert!(!is_valid_email("user@@example.com"));
322    }
323
324    #[test]
325    fn test_is_valid_url() {
326        assert!(is_valid_url("https://example.com"));
327        assert!(is_valid_url("http://example.com/path"));
328        assert!(!is_valid_url("example.com"));
329        assert!(!is_valid_url("ftp://example.com"));
330        assert!(!is_valid_url("https://"));
331    }
332
333    #[test]
334    fn test_is_valid_ip() {
335        assert!(is_valid_ip("192.168.1.1"));
336        assert!(is_valid_ip("::1"));
337        assert!(is_valid_ip("2001:db8::1"));
338        assert!(!is_valid_ip("not an ip"));
339        assert!(!is_valid_ip("256.1.1.1"));
340    }
341
342    #[test]
343    fn test_is_identifier() {
344        assert!(is_identifier("hello"));
345        assert!(is_identifier("_private"));
346        assert!(is_identifier("camelCase"));
347        assert!(is_identifier("snake_case"));
348        assert!(is_identifier("with123"));
349        assert!(!is_identifier("123start"));
350        assert!(!is_identifier("has-dash"));
351        assert!(!is_identifier(""));
352    }
353
354    #[test]
355    fn test_is_valid_semver() {
356        assert!(is_valid_semver("1.0.0"));
357        assert!(is_valid_semver("v1.0.0"));
358        assert!(is_valid_semver("0.1.0"));
359        assert!(is_valid_semver("1.0.0-alpha"));
360        assert!(!is_valid_semver("1.0"));
361        assert!(!is_valid_semver("1"));
362        assert!(!is_valid_semver("a.b.c"));
363    }
364
365    #[test]
366    fn test_validator() {
367        let mut v = Validator::new();
368        v.check("email", || {
369            if is_valid_email("test@example.com") {
370                Ok(())
371            } else {
372                Err(ValidationError::InvalidPattern {
373                    pattern: "email".to_string(),
374                })
375            }
376        });
377        assert!(v.is_valid());
378        assert!(v.finish().is_ok());
379    }
380}