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.
72///
73/// # Errors
74///
75/// Returns `ValidationError::Empty` if the trimmed value is empty.
76pub 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
84/// Validate string length is within bounds.
85///
86/// # Errors
87///
88/// Returns `ValidationError::TooShort` or `ValidationError::TooLong` if out of range.
89pub 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
100/// Validate that a number is within range.
101///
102/// # Errors
103///
104/// Returns `ValidationError::BelowMin` or `ValidationError::AboveMax` if out of range.
105pub 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/// Check if a string looks like a valid email address.
125///
126/// This is a simple check, not RFC 5322 compliant.
127#[must_use]
128pub fn is_valid_email(email: &str) -> bool {
129    let email = email.trim();
130
131    // Must contain exactly one @
132    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    // Local part checks
140    if local.is_empty() || local.len() > 64 {
141        return false;
142    }
143
144    // Domain checks
145    if domain.is_empty() || domain.len() > 255 {
146        return false;
147    }
148
149    // Domain must contain at least one dot
150    if !domain.contains('.') {
151        return false;
152    }
153
154    // No consecutive dots
155    if email.contains("..") {
156        return false;
157    }
158
159    true
160}
161
162/// Check if a string looks like a valid URL.
163///
164/// Rejects whitespace, bare dots (e.g. `http://.`), and URLs without
165/// a meaningful host. For full RFC 3986 compliance, use the
166/// [`url`](https://crates.io/crates/url) crate.
167#[must_use]
168pub fn is_valid_url(url: &str) -> bool {
169    let url = url.trim();
170
171    // Must start with http:// or https://
172    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    // Must have content after the scheme
181    if rest.is_empty() {
182        return false;
183    }
184
185    // No whitespace allowed
186    if rest.contains(char::is_whitespace) {
187        return false;
188    }
189
190    // Extract the host (before any path, query, or fragment)
191    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    // Strip port from host (handle IPv6 bracketed addresses)
196    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    // Allow "localhost" as a valid host
205    if host_without_port.eq_ignore_ascii_case("localhost") {
206        return true;
207    }
208
209    // Host must contain a dot and have content on both sides
210    host_without_port
211        .find('.')
212        .is_some_and(|dot_pos| dot_pos > 0 && dot_pos < host_without_port.len() - 1)
213}
214
215/// Check if a string is a valid IP address (v4 or v6).
216#[must_use]
217pub fn is_valid_ip(ip: &str) -> bool {
218    ip.trim().parse::<IpAddr>().is_ok()
219}
220
221/// Check if a string is a valid IPv4 address.
222#[must_use]
223pub fn is_valid_ipv4(ip: &str) -> bool {
224    ip.trim().parse::<std::net::Ipv4Addr>().is_ok()
225}
226
227/// Check if a string is a valid IPv6 address.
228#[must_use]
229pub fn is_valid_ipv6(ip: &str) -> bool {
230    ip.trim().parse::<std::net::Ipv6Addr>().is_ok()
231}
232
233/// Check if a string contains only alphanumeric characters.
234#[must_use]
235pub fn is_alphanumeric(s: &str) -> bool {
236    !s.is_empty() && s.chars().all(char::is_alphanumeric)
237}
238
239/// Check if a string contains only ASCII alphanumeric characters and underscores.
240#[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    // First character must be letter or underscore
249    match chars.next() {
250        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
251        _ => return false,
252    }
253
254    // Rest can be alphanumeric or underscore
255    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
256}
257
258/// Check if a string is a valid semantic version.
259///
260/// Supports optional `v` prefix, pre-release labels (`-alpha.1`), and
261/// build metadata (`+build.42`). The three core version components
262/// (major, minor, patch) must be non-negative integers.
263#[must_use]
264pub fn is_valid_semver(version: &str) -> bool {
265    let version = version.trim().strip_prefix('v').unwrap_or(version);
266
267    // Split off pre-release and build metadata before parsing the core
268    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
279/// Validate that a value is in an allowed set.
280///
281/// # Errors
282///
283/// Returns `ValidationError::NotInSet` if the value is not in the allowed set.
284pub 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/// Builder for composing multiple validations.
298#[derive(Debug, Default)]
299pub struct Validator {
300    errors: Vec<(String, ValidationError)>,
301}
302
303impl Validator {
304    /// Create a new validator.
305    #[must_use]
306    pub fn new() -> Self {
307        Self::default()
308    }
309
310    /// Add a validation check.
311    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    /// Check if validation passed.
322    #[must_use]
323    pub const fn is_valid(&self) -> bool {
324        self.errors.is_empty()
325    }
326
327    /// Get all errors.
328    #[must_use]
329    pub fn errors(&self) -> &[(String, ValidationError)] {
330        &self.errors
331    }
332
333    /// Finish validation and return result.
334    ///
335    /// # Errors
336    ///
337    /// Returns the list of validation errors if any checks failed.
338    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        // Localhost support
396        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        // Port stripping on regular domains
403        assert!(is_valid_url("http://example.com:8080"));
404
405        // Empty host with port should fail
406        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}