Skip to main content

textual/validation/
mod.rs

1use std::sync::Arc;
2
3#[derive(Debug, Clone, Default)]
4pub struct ValidationResult {
5    pub is_valid: bool,
6    pub failure_descriptions: Vec<String>,
7}
8
9impl ValidationResult {
10    pub fn success() -> Self {
11        Self {
12            is_valid: true,
13            failure_descriptions: Vec::new(),
14        }
15    }
16
17    pub fn failure(message: impl Into<String>) -> Self {
18        Self {
19            is_valid: false,
20            failure_descriptions: vec![message.into()],
21        }
22    }
23}
24
25pub trait Validator {
26    fn validate(&self, value: &str) -> ValidationResult;
27}
28
29pub type ValidatorRef = Arc<dyn Validator + Send + Sync>;
30
31pub struct Function {
32    predicate: Arc<dyn Fn(&str) -> bool + Send + Sync>,
33    message: String,
34}
35
36impl Function {
37    pub fn new(
38        predicate: impl Fn(&str) -> bool + Send + Sync + 'static,
39        message: impl Into<String>,
40    ) -> Self {
41        Self {
42            predicate: Arc::new(predicate),
43            message: message.into(),
44        }
45    }
46}
47
48impl Validator for Function {
49    fn validate(&self, value: &str) -> ValidationResult {
50        if (self.predicate)(value) {
51            ValidationResult::success()
52        } else {
53            ValidationResult::failure(self.message.clone())
54        }
55    }
56}
57
58#[derive(Debug, Clone, Copy)]
59pub struct Number {
60    minimum: Option<f64>,
61    maximum: Option<f64>,
62}
63
64impl Default for Number {
65    fn default() -> Self {
66        Self::new()
67    }
68}
69
70impl Number {
71    pub fn new() -> Self {
72        Self {
73            minimum: None,
74            maximum: None,
75        }
76    }
77
78    pub fn minimum(mut self, value: f64) -> Self {
79        self.minimum = Some(value);
80        self
81    }
82
83    pub fn maximum(mut self, value: f64) -> Self {
84        self.maximum = Some(value);
85        self
86    }
87}
88
89impl Validator for Number {
90    fn validate(&self, value: &str) -> ValidationResult {
91        let value = value.trim();
92        if value.is_empty() {
93            return ValidationResult::failure("Value is required.");
94        }
95
96        let Ok(num) = value.parse::<f64>() else {
97            return ValidationResult::failure("Value is not a number.");
98        };
99
100        if num.is_nan() || num.is_infinite() {
101            return ValidationResult::failure("Value is not a number.");
102        }
103
104        if let Some(min) = self.minimum {
105            if num < min {
106                return ValidationResult::failure(format!("Value must be >= {min}."));
107            }
108        }
109        if let Some(max) = self.maximum {
110            if num > max {
111                return ValidationResult::failure(format!("Value must be <= {max}."));
112            }
113        }
114
115        ValidationResult::success()
116    }
117}
118
119/// Validates that a string is a valid integer, optionally within a range.
120///
121/// Mirrors Python Textual's `Integer` validator: first validates as a number
122/// (with optional min/max range), then ensures the value is an integer.
123#[derive(Debug, Clone, Copy)]
124pub struct Integer {
125    minimum: Option<i64>,
126    maximum: Option<i64>,
127}
128
129impl Default for Integer {
130    fn default() -> Self {
131        Self::new()
132    }
133}
134
135impl Integer {
136    pub fn new() -> Self {
137        Self {
138            minimum: None,
139            maximum: None,
140        }
141    }
142
143    pub fn minimum(mut self, value: i64) -> Self {
144        self.minimum = Some(value);
145        self
146    }
147
148    pub fn maximum(mut self, value: i64) -> Self {
149        self.maximum = Some(value);
150        self
151    }
152}
153
154impl Validator for Integer {
155    fn validate(&self, value: &str) -> ValidationResult {
156        let value = value.trim();
157        if value.is_empty() {
158            return ValidationResult::failure("Must be a valid integer.");
159        }
160
161        let Ok(num) = value.parse::<i64>() else {
162            return ValidationResult::failure("Must be a valid integer.");
163        };
164
165        if let Some(min) = self.minimum {
166            if num < min {
167                return ValidationResult::failure(format!(
168                    "Must be greater than or equal to {min}."
169                ));
170            }
171        }
172        if let Some(max) = self.maximum {
173            if num > max {
174                return ValidationResult::failure(format!("Must be less than or equal to {max}."));
175            }
176        }
177
178        ValidationResult::success()
179    }
180}
181
182/// Validates that a string's length falls within a range (inclusive).
183///
184/// Mirrors Python Textual's `Length` validator.
185#[derive(Debug, Clone, Copy)]
186pub struct Length {
187    minimum: Option<usize>,
188    maximum: Option<usize>,
189}
190
191impl Default for Length {
192    fn default() -> Self {
193        Self::new()
194    }
195}
196
197impl Length {
198    pub fn new() -> Self {
199        Self {
200            minimum: None,
201            maximum: None,
202        }
203    }
204
205    pub fn minimum(mut self, value: usize) -> Self {
206        self.minimum = Some(value);
207        self
208    }
209
210    pub fn maximum(mut self, value: usize) -> Self {
211        self.maximum = Some(value);
212        self
213    }
214}
215
216impl Validator for Length {
217    fn validate(&self, value: &str) -> ValidationResult {
218        let len = value.len();
219        let too_short = self.minimum.is_some_and(|min| len < min);
220        let too_long = self.maximum.is_some_and(|max| len > max);
221
222        if too_short || too_long {
223            let msg = match (self.minimum, self.maximum) {
224                (Some(min), None) => format!("Must be longer than {min} characters."),
225                (None, Some(max)) => format!("Must be shorter than {max} characters."),
226                (Some(min), Some(max)) => format!("Must be between {min} and {max} characters."),
227                _ => "Invalid length.".to_string(),
228            };
229            return ValidationResult::failure(msg);
230        }
231
232        ValidationResult::success()
233    }
234}
235
236/// Validates that a string is a valid URL (has both a scheme and host).
237///
238/// Mirrors Python Textual's `URL` validator. Uses basic parsing without
239/// external crates — checks for `scheme://host` pattern.
240#[derive(Debug, Clone, Copy)]
241pub struct Url;
242
243impl Default for Url {
244    fn default() -> Self {
245        Self::new()
246    }
247}
248
249impl Url {
250    pub fn new() -> Self {
251        Self
252    }
253}
254
255impl Validator for Url {
256    fn validate(&self, value: &str) -> ValidationResult {
257        let value = value.trim();
258        // Check for scheme://netloc pattern (mirrors Python's urlparse check)
259        let Some((scheme, rest)) = value.split_once("://") else {
260            return ValidationResult::failure("Must be a valid URL.");
261        };
262        if scheme.is_empty() {
263            return ValidationResult::failure("Must be a valid URL.");
264        }
265        // netloc must be non-empty (could be followed by /, ?, #, or end)
266        let netloc = rest.split(&['/', '?', '#'][..]).next().unwrap_or("");
267        if netloc.is_empty() {
268            return ValidationResult::failure("Must be a valid URL.");
269        }
270        ValidationResult::success()
271    }
272}
273
274/// Validates that a string fully matches a regular expression.
275///
276/// Mirrors Python Textual's `Regex` validator (uses `re.fullmatch` semantics).
277/// The pattern must match the entire string.
278pub struct Regex {
279    pattern: regex::Regex,
280}
281
282impl Regex {
283    pub fn new(pattern: regex::Regex) -> Self {
284        Self { pattern }
285    }
286
287    /// Create from a pattern string. Panics if the pattern is invalid.
288    pub fn compile(pattern: &str) -> Self {
289        Self {
290            pattern: regex::Regex::new(pattern).expect("invalid regex pattern"),
291        }
292    }
293
294    /// Try to create from a pattern string, returning `None` if invalid.
295    pub fn try_compile(pattern: &str) -> Option<Self> {
296        regex::Regex::new(pattern).ok().map(|r| Self { pattern: r })
297    }
298}
299
300impl std::fmt::Debug for Regex {
301    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
302        f.debug_struct("Regex")
303            .field("pattern", &self.pattern.as_str())
304            .finish()
305    }
306}
307
308impl Validator for Regex {
309    fn validate(&self, value: &str) -> ValidationResult {
310        // fullmatch semantics: the pattern must match the entire string
311        if self
312            .pattern
313            .find(value)
314            .is_some_and(|m| m.start() == 0 && m.end() == value.len())
315        {
316            ValidationResult::success()
317        } else {
318            ValidationResult::failure(format!(
319                "Must match regular expression {:?}.",
320                self.pattern.as_str()
321            ))
322        }
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    #[test]
331    fn integer_valid() {
332        let v = Integer::new();
333        assert!(v.validate("42").is_valid);
334        assert!(v.validate("-7").is_valid);
335        assert!(v.validate("0").is_valid);
336    }
337
338    #[test]
339    fn integer_invalid() {
340        let v = Integer::new();
341        assert!(!v.validate("3.5").is_valid);
342        assert!(!v.validate("abc").is_valid);
343        assert!(!v.validate("").is_valid);
344    }
345
346    #[test]
347    fn integer_range() {
348        let v = Integer::new().minimum(0).maximum(100);
349        assert!(v.validate("50").is_valid);
350        assert!(!v.validate("-1").is_valid);
351        assert!(!v.validate("101").is_valid);
352    }
353
354    #[test]
355    fn length_valid() {
356        let v = Length::new().minimum(2).maximum(5);
357        assert!(v.validate("ab").is_valid);
358        assert!(v.validate("abcde").is_valid);
359    }
360
361    #[test]
362    fn length_invalid() {
363        let v = Length::new().minimum(2).maximum(5);
364        assert!(!v.validate("a").is_valid);
365        assert!(!v.validate("abcdef").is_valid);
366    }
367
368    #[test]
369    fn length_unbounded_min() {
370        let v = Length::new().maximum(3);
371        assert!(v.validate("").is_valid);
372        assert!(v.validate("abc").is_valid);
373        assert!(!v.validate("abcd").is_valid);
374    }
375
376    #[test]
377    fn length_unbounded_max() {
378        let v = Length::new().minimum(2);
379        assert!(!v.validate("a").is_valid);
380        assert!(v.validate("ab").is_valid);
381        assert!(v.validate("abcdef").is_valid);
382    }
383
384    #[test]
385    fn url_valid() {
386        let v = Url::new();
387        assert!(v.validate("https://example.com").is_valid);
388        assert!(v.validate("http://foo.bar/path").is_valid);
389        assert!(v.validate("ftp://files.example.com").is_valid);
390    }
391
392    #[test]
393    fn url_invalid() {
394        let v = Url::new();
395        assert!(!v.validate("not-a-url").is_valid);
396        assert!(!v.validate("://missing-scheme").is_valid);
397        assert!(!v.validate("http://").is_valid);
398        assert!(!v.validate("").is_valid);
399    }
400
401    #[test]
402    fn regex_valid() {
403        let v = Regex::compile(r"\d{3}-\d{4}");
404        assert!(v.validate("123-4567").is_valid);
405    }
406
407    #[test]
408    fn regex_invalid() {
409        let v = Regex::compile(r"\d{3}-\d{4}");
410        assert!(!v.validate("12-4567").is_valid);
411        assert!(!v.validate("abc").is_valid);
412    }
413
414    #[test]
415    fn regex_fullmatch_semantics() {
416        // Pattern should match the entire string, not just a substring
417        let v = Regex::compile(r"\d+");
418        assert!(v.validate("123").is_valid);
419        assert!(!v.validate("123abc").is_valid);
420        assert!(!v.validate("abc123").is_valid);
421    }
422
423    #[test]
424    fn regex_try_compile_invalid() {
425        assert!(Regex::try_compile("[invalid").is_none());
426    }
427
428    #[test]
429    fn number_rejects_nan_inf() {
430        let v = Number::new();
431        assert!(!v.validate("NaN").is_valid);
432        assert!(!v.validate("inf").is_valid);
433        assert!(!v.validate("-inf").is_valid);
434        assert!(!v.validate("infinity").is_valid);
435    }
436}