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}