Skip to main content

cli_ui/prompt/
validate.rs

1//! Composable input validators — ports `@clack/core`'s `runValidation` with a
2//! library-of-rules on top, and ports `utils/string.ts`-style helpers.
3//!
4//! A validator returns `Ok(())` if the value is acceptable, or `Err(msg)` with
5//! the error string to show below the input. Validators compose with `&` (AND)
6//! and `|` (OR) and can be tagged with custom messages via [`Validator::msg`].
7//!
8//! # Example — composing rules in user code
9//! ```no_run
10//! use cli_ui::prompt::{secret, text, min_chars, has_upper, has_lower,
11//!     has_digit, has_special, word_count};
12//!
13//! let password = secret("New password")
14//!     .rule(
15//!         min_chars(12)
16//!             .and(has_upper())
17//!             .and(has_lower())
18//!             .and(has_digit())
19//!             .and(has_special())
20//!             .msg("≥12 chars with upper/lower/digit/special required"),
21//!     )
22//!     .run()?;
23//!
24//! let seed = text("Recovery phrase")
25//!     .rule(word_count(12))
26//!     .run()?;
27//! # Ok::<(), cli_ui::prompt::PromptError>(())
28//! ```
29//!
30//! # Integrating an external validation crate
31//!
32//! Implement the [`Validate`] trait for any wrapper around your library of
33//! choice (`validator`, `garde`, etc.):
34//!
35//! ```no_run
36//! use cli_ui::prompt::validate::Validator;
37//!
38//! fn from_external(rule: impl Fn(&str) -> Result<(), String> + Send + Sync + 'static) -> Validator {
39//!     Validator::new(rule)
40//! }
41//! ```
42
43use std::sync::Arc;
44
45/// Anything that can decide whether an input string is acceptable.
46///
47/// Implement this for any external validation library to bridge it into
48/// the prompt's `.rule(...)` chain.
49pub trait Validate: Send + Sync + 'static {
50    /// Return `Ok(())` to accept the value, `Err(msg)` to reject and show
51    /// `msg` as the user-facing error.
52    fn check(&self, value: &str) -> Result<(), String>;
53}
54
55impl<F> Validate for F
56where
57    F: Fn(&str) -> Result<(), String> + Send + Sync + 'static,
58{
59    fn check(&self, value: &str) -> Result<(), String> {
60        self(value)
61    }
62}
63
64/// Validator handle returned by the helpers in this module. Composes with
65/// [`Validator::and`] / [`Validator::or`] / [`Validator::msg`].
66#[derive(Clone)]
67pub struct Validator(Arc<dyn Validate>);
68
69impl Validator {
70    /// Wrap any [`Validate`] implementation — including a plain closure
71    /// `|s: &str| -> Result<(), String>` — into a [`Validator`].
72    pub fn new<V: Validate>(v: V) -> Self {
73        Self(Arc::new(v))
74    }
75
76    /// Run the validator. Mirrors [`Validate::check`].
77    pub fn check(&self, v: &str) -> Result<(), String> {
78        self.0.check(v)
79    }
80
81    /// Both must pass. Returns the first error encountered.
82    pub fn and(self, other: Validator) -> Validator {
83        let a = self.0;
84        let b = other.0;
85        Validator::new(move |v: &str| {
86            a.check(v)?;
87            b.check(v)
88        })
89    }
90
91    /// At least one must pass. Returns the *second* error if both fail
92    /// (usually more user-friendly than "you failed multiple things").
93    pub fn or(self, other: Validator) -> Validator {
94        let a = self.0;
95        let b = other.0;
96        Validator::new(move |v: &str| {
97            if a.check(v).is_ok() {
98                return Ok(());
99            }
100            b.check(v)
101        })
102    }
103
104    /// Replace the error message produced by this validator.
105    pub fn msg(self, message: impl Into<String>) -> Validator {
106        let inner = self.0;
107        let msg = message.into();
108        Validator::new(move |v: &str| inner.check(v).map_err(|_| msg.clone()))
109    }
110}
111
112impl Validate for Validator {
113    fn check(&self, v: &str) -> Result<(), String> {
114        self.0.check(v)
115    }
116}
117
118impl<F> From<F> for Validator
119where
120    F: Fn(&str) -> Result<(), String> + Send + Sync + 'static,
121{
122    fn from(f: F) -> Self {
123        Validator::new(f)
124    }
125}
126
127// ── Rule builders ────────────────────────────────────────────────────────────
128
129/// Reject empty input.
130pub fn required() -> Validator {
131    Validator::new(|v: &str| {
132        if v.trim().is_empty() {
133            Err("Value is required".into())
134        } else {
135            Ok(())
136        }
137    })
138}
139
140/// At least `n` characters (counted by Unicode chars).
141pub fn min_chars(n: usize) -> Validator {
142    Validator::new(move |v: &str| {
143        if v.chars().count() < n {
144            Err(format!("Must be at least {n} characters"))
145        } else {
146            Ok(())
147        }
148    })
149}
150
151/// At most `n` characters.
152pub fn max_chars(n: usize) -> Validator {
153    Validator::new(move |v: &str| {
154        if v.chars().count() > n {
155            Err(format!("Must be at most {n} characters"))
156        } else {
157            Ok(())
158        }
159    })
160}
161
162/// Exact length in characters.
163pub fn exact_chars(n: usize) -> Validator {
164    Validator::new(move |v: &str| {
165        let c = v.chars().count();
166        if c != n {
167            Err(format!("Must be exactly {n} characters (got {c})"))
168        } else {
169            Ok(())
170        }
171    })
172}
173
174/// Exactly `n` whitespace-separated words.
175pub fn word_count(n: usize) -> Validator {
176    Validator::new(move |v: &str| {
177        let c = v.split_whitespace().count();
178        if c != n {
179            Err(format!("Must be exactly {n} words (got {c})"))
180        } else {
181            Ok(())
182        }
183    })
184}
185
186/// At least one of `min..=max` words.
187pub fn words_between(min: usize, max: usize) -> Validator {
188    Validator::new(move |v: &str| {
189        let c = v.split_whitespace().count();
190        if c < min || c > max {
191            Err(format!("Must be {min}–{max} words (got {c})"))
192        } else {
193            Ok(())
194        }
195    })
196}
197
198/// At least one ASCII uppercase character.
199pub fn has_upper() -> Validator {
200    Validator::new(|v: &str| {
201        if v.chars().any(|c| c.is_ascii_uppercase()) {
202            Ok(())
203        } else {
204            Err("Must contain an uppercase letter".into())
205        }
206    })
207}
208
209/// At least one ASCII lowercase character.
210pub fn has_lower() -> Validator {
211    Validator::new(|v: &str| {
212        if v.chars().any(|c| c.is_ascii_lowercase()) {
213            Ok(())
214        } else {
215            Err("Must contain a lowercase letter".into())
216        }
217    })
218}
219
220/// At least one ASCII digit.
221pub fn has_digit() -> Validator {
222    Validator::new(|v: &str| {
223        if v.chars().any(|c| c.is_ascii_digit()) {
224            Ok(())
225        } else {
226            Err("Must contain a digit".into())
227        }
228    })
229}
230
231/// At least one ASCII punctuation/special character.
232pub fn has_special() -> Validator {
233    Validator::new(|v: &str| {
234        if v.chars().any(|c| c.is_ascii_punctuation()) {
235            Ok(())
236        } else {
237            Err("Must contain a special character".into())
238        }
239    })
240}
241
242/// Letters only (Unicode alphabetic).
243pub fn alpha_only() -> Validator {
244    Validator::new(|v: &str| {
245        if v.chars().all(|c| c.is_alphabetic()) {
246            Ok(())
247        } else {
248            Err("Letters only".into())
249        }
250    })
251}
252
253/// Letters and digits only.
254pub fn alphanumeric() -> Validator {
255    Validator::new(|v: &str| {
256        if v.chars().all(|c| c.is_alphanumeric()) {
257            Ok(())
258        } else {
259            Err("Letters and digits only".into())
260        }
261    })
262}
263
264/// Restrict to a given set of characters.
265pub fn only_chars(allowed: &'static str) -> Validator {
266    Validator::new(move |v: &str| {
267        if v.chars().all(|c| allowed.contains(c)) {
268            Ok(())
269        } else {
270            Err(format!("Allowed characters: {allowed}"))
271        }
272    })
273}
274
275/// Reject if any of the given characters appear.
276pub fn forbid_chars(forbidden: &'static str) -> Validator {
277    Validator::new(move |v: &str| {
278        if let Some(c) = v.chars().find(|c| forbidden.contains(*c)) {
279            Err(format!("'{c}' is not allowed"))
280        } else {
281            Ok(())
282        }
283    })
284}
285
286/// Parsable as integer in `[min, max]`.
287pub fn int_between(min: i64, max: i64) -> Validator {
288    Validator::new(move |v: &str| match v.trim().parse::<i64>() {
289        Ok(n) if (min..=max).contains(&n) => Ok(()),
290        Ok(n) => Err(format!("Must be between {min} and {max} (got {n})")),
291        Err(_) => Err("Must be an integer".into()),
292    })
293}
294
295/// Parsable as float in `[min, max]`.
296pub fn float_between(min: f64, max: f64) -> Validator {
297    Validator::new(move |v: &str| match v.trim().parse::<f64>() {
298        Ok(n) if n >= min && n <= max => Ok(()),
299        Ok(n) => Err(format!("Must be between {min} and {max} (got {n})")),
300        Err(_) => Err("Must be a number".into()),
301    })
302}
303
304/// Looks like an email (very permissive: `something@something.something`).
305pub fn email() -> Validator {
306    Validator::new(|v: &str| {
307        let s = v.trim();
308        let parts: Vec<&str> = s.splitn(2, '@').collect();
309        if parts.len() == 2
310            && !parts[0].is_empty()
311            && parts[1].contains('.')
312            && !parts[1].starts_with('.')
313            && !parts[1].ends_with('.')
314        {
315            Ok(())
316        } else {
317            Err("Must be a valid email".into())
318        }
319    })
320}
321
322/// Starts with `prefix`.
323pub fn starts_with(prefix: &'static str) -> Validator {
324    Validator::new(move |v: &str| {
325        if v.starts_with(prefix) {
326            Ok(())
327        } else {
328            Err(format!("Must start with `{prefix}`"))
329        }
330    })
331}
332
333/// Ends with `suffix`.
334pub fn ends_with(suffix: &'static str) -> Validator {
335    Validator::new(move |v: &str| {
336        if v.ends_with(suffix) {
337            Ok(())
338        } else {
339            Err(format!("Must end with `{suffix}`"))
340        }
341    })
342}
343
344/// One of the listed values, case-sensitive.
345pub fn one_of(values: &'static [&'static str]) -> Validator {
346    Validator::new(move |v: &str| {
347        if values.contains(&v) {
348            Ok(())
349        } else {
350            Err(format!("Must be one of: {}", values.join(", ")))
351        }
352    })
353}
354
355// ── End of primitives ────────────────────────────────────────────────────────
356//
357// Domain-specific bundles (BIP-39 seed phrases, password-strength policies,
358// US zip codes, ICAO airport codes, …) belong in *user* code or downstream
359// crates — composing the primitives above with `.and()` / `.or()` / `.msg()`
360// keeps this module general-purpose.