Skip to main content

slt/widgets/
validators.rs

1// Included into `crate::widgets::validators` via `include!`. Keep this file
2// free of `use` statements that conflict with the parent module — refer to
3// items by fully-qualified paths where ambiguity is possible.
4
5/// Reject empty or whitespace-only input.
6///
7/// # Example
8///
9/// ```no_run
10/// # use slt::widgets::{FormField, validators};
11/// let field = FormField::new("Name").validate(validators::required("Name is required"));
12/// # let _ = field;
13/// ```
14pub fn required(msg: impl Into<String>) -> impl Fn(&str) -> Result<(), String> {
15    let msg = msg.into();
16    move |value| {
17        if value.trim().is_empty() {
18            Err(msg.clone())
19        } else {
20            Ok(())
21        }
22    }
23}
24
25/// Require at least `n` characters (counted as Unicode scalar values).
26///
27/// # Example
28///
29/// ```no_run
30/// # use slt::widgets::{FormField, validators};
31/// let field = FormField::new("Password").validate(validators::min_len(8, "min 8 chars"));
32/// # let _ = field;
33/// ```
34pub fn min_len(n: usize, msg: impl Into<String>) -> impl Fn(&str) -> Result<(), String> {
35    let msg = msg.into();
36    move |value| {
37        if value.chars().count() >= n {
38            Ok(())
39        } else {
40            Err(msg.clone())
41        }
42    }
43}
44
45/// Require at most `n` characters (counted as Unicode scalar values).
46///
47/// # Example
48///
49/// ```no_run
50/// # use slt::widgets::{FormField, validators};
51/// let field = FormField::new("Bio").validate(validators::max_len(140, "max 140 chars"));
52/// # let _ = field;
53/// ```
54pub fn max_len(n: usize, msg: impl Into<String>) -> impl Fn(&str) -> Result<(), String> {
55    let msg = msg.into();
56    move |value| {
57        if value.chars().count() <= n {
58            Ok(())
59        } else {
60            Err(msg.clone())
61        }
62    }
63}
64
65/// Accept a plausibly-formed email address.
66///
67/// This is a deliberately small structural check — exactly one `@`, a
68/// non-empty local part, a non-empty domain that contains at least one `.`
69/// with non-empty labels on both sides, and no whitespace. It is **not** a
70/// full RFC 5322 parser; pass a stricter [`regex`] if you need one.
71///
72/// # Example
73///
74/// ```no_run
75/// # use slt::widgets::{FormField, validators};
76/// let field = FormField::new("Email").validate(validators::email());
77/// # let _ = field;
78/// ```
79pub fn email() -> impl Fn(&str) -> Result<(), String> {
80    move |value| {
81        if value.chars().any(char::is_whitespace) {
82            return Err("invalid email".to_string());
83        }
84        let mut parts = value.split('@');
85        let local = parts.next().unwrap_or("");
86        let domain = parts.next().unwrap_or("");
87        // Exactly one '@' (no third part) and both sides non-empty.
88        if parts.next().is_some() || local.is_empty() || domain.is_empty() {
89            return Err("invalid email".to_string());
90        }
91        // Domain needs a dot with non-empty labels on each side.
92        match domain.rsplit_once('.') {
93            Some((host, tld)) if !host.is_empty() && !tld.is_empty() => Ok(()),
94            _ => Err("invalid email".to_string()),
95        }
96    }
97}
98
99/// Parse the input as an `i64` and require it to fall within `lo..=hi`.
100///
101/// Non-numeric input fails with `msg`.
102///
103/// # Example
104///
105/// ```no_run
106/// # use slt::widgets::{FormField, validators};
107/// let field = FormField::new("Age").validate(validators::range_i64(0, 120, "0–120 only"));
108/// # let _ = field;
109/// ```
110pub fn range_i64(lo: i64, hi: i64, msg: impl Into<String>) -> impl Fn(&str) -> Result<(), String> {
111    let msg = msg.into();
112    move |value| match value.trim().parse::<i64>() {
113        Ok(n) if (lo..=hi).contains(&n) => Ok(()),
114        _ => Err(msg.clone()),
115    }
116}
117
118/// Parse the input as an `f64` and require it to fall within `lo..=hi`.
119///
120/// Non-numeric or non-finite input fails with `msg`.
121///
122/// # Example
123///
124/// ```no_run
125/// # use slt::widgets::{FormField, validators};
126/// let field = FormField::new("Rate").validate(validators::range_f64(0.0, 1.0, "0.0–1.0 only"));
127/// # let _ = field;
128/// ```
129pub fn range_f64(lo: f64, hi: f64, msg: impl Into<String>) -> impl Fn(&str) -> Result<(), String> {
130    let msg = msg.into();
131    move |value| match value.trim().parse::<f64>() {
132        Ok(n) if n.is_finite() && n >= lo && n <= hi => Ok(()),
133        _ => Err(msg.clone()),
134    }
135}
136
137/// Require the input to be one of the allowed values (exact, case-sensitive).
138///
139/// # Example
140///
141/// ```no_run
142/// # use slt::widgets::{FormField, validators};
143/// let field = FormField::new("Role")
144///     .validate(validators::one_of(&["admin", "user"], "admin or user"));
145/// # let _ = field;
146/// ```
147pub fn one_of(allowed: &[&str], msg: impl Into<String>) -> impl Fn(&str) -> Result<(), String> {
148    let allowed: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
149    let msg = msg.into();
150    move |value| {
151        if allowed.iter().any(|a| a == value) {
152            Ok(())
153        } else {
154            Err(msg.clone())
155        }
156    }
157}
158
159/// Match the input against a minimal glob-style pattern.
160///
161/// This is **not** a full regular-expression engine. The supported syntax is a
162/// small literal matcher:
163///
164/// - `.` matches any single character.
165/// - `*` matches zero or more of any character (greedy, with backtracking).
166/// - `^` at the start anchors to the beginning (implied; always anchored).
167/// - `$` at the end anchors to the end (implied; always anchored).
168/// - any other character matches itself literally.
169///
170/// The whole input must match (the pattern is fully anchored). For real PCRE
171/// support, wrap the `regex` crate in your own closure — SLT intentionally
172/// ships no regex dependency.
173///
174/// # Example
175///
176/// ```no_run
177/// # use slt::widgets::{FormField, validators};
178/// // Three-letter code followed by digits, e.g. "ABC123".
179/// let field = FormField::new("Code").validate(validators::regex("...*", "bad code"));
180/// # let _ = field;
181/// ```
182pub fn regex(
183    pattern: impl Into<String>,
184    msg: impl Into<String>,
185) -> impl Fn(&str) -> Result<(), String> {
186    let mut pattern = pattern.into();
187    let msg = msg.into();
188    // Strip optional explicit anchors; matching is always whole-string.
189    if let Some(stripped) = pattern.strip_prefix('^') {
190        pattern = stripped.to_string();
191    }
192    if let Some(stripped) = pattern.strip_suffix('$') {
193        pattern = stripped.to_string();
194    }
195    let pattern: Vec<char> = pattern.chars().collect();
196    move |value| {
197        let input: Vec<char> = value.chars().collect();
198        if glob_match(&pattern, &input) {
199            Ok(())
200        } else {
201            Err(msg.clone())
202        }
203    }
204}
205
206/// Whole-string glob matcher backing [`regex`]. Supports `.` (any char) and
207/// `*` (zero-or-more, greedy with backtracking). Recursion depth is bounded by
208/// pattern length, which is caller-controlled and small in practice.
209fn glob_match(pattern: &[char], input: &[char]) -> bool {
210    match pattern.split_first() {
211        None => input.is_empty(),
212        Some(('*', rest)) => {
213            // Try to consume 0..=input.len() chars for '*', shortest first.
214            for skip in 0..=input.len() {
215                if glob_match(rest, &input[skip..]) {
216                    return true;
217                }
218            }
219            false
220        }
221        Some(('.', rest)) => !input.is_empty() && glob_match(rest, &input[1..]),
222        Some((lit, rest)) => match input.split_first() {
223            Some((head, tail)) if head == lit => glob_match(rest, tail),
224            _ => false,
225        },
226    }
227}