stalkermap/utils/
sanitize.rs

1//! # Input Sanitization & Validation
2//!
3//! This module provides a small yet flexible input validation framework for
4//! interactive CLI applications. It defines a set of composable validation
5//! filters (`Sanitize`) that can be applied to user-provided strings. Filters
6//! run in order and short-circuit on the first failure, returning a friendly
7//! error message describing what went wrong.
8//!
9//! ## Features
10//! - Type validation for common Rust primitives via [`DesiredType`]
11//! - Exact string matching with [`Sanitize::MatchString`]
12//! - Multiple-option matching with [`Sanitize::MatchStrings`]
13//! - Inclusive range validation with [`Sanitize::IsBetween`]
14//! - Human-readable error messages for invalid input
15//!
16//! ## When to use
17//! Use this module whenever you collect raw user input (e.g. via
18//! [`crate::utils::Terminal::ask`]) and need to ensure it matches certain
19//! constraints before proceeding.
20//!
21//! ## Examples
22//!
23//! ### Validate types
24//! ```rust,no_run
25//! use stalkermap::utils::{DesiredType, Sanitize, Terminal};
26//!
27//! let input = Terminal::ask(
28//!     "Enter a boolean (true/false):",
29//!     &[Sanitize::IsType(DesiredType::Bool)],
30//! );
31//! println!("Accepted: {}", input.answer);
32//! ```
33//!
34//! ### Match exact strings or options
35//! ```rust,no_run
36//! use stalkermap::utils::{DesiredType, Sanitize, Terminal};
37//!
38//! // Exact match
39//! let yes = Terminal::ask(
40//!     "Type yes to continue:",
41//!     &[
42//!         Sanitize::IsType(DesiredType::String),
43//!         Sanitize::MatchString("yes".to_string()),
44//!     ],
45//! );
46//!
47//! // Any of the options
48//! let yn = Terminal::ask(
49//!     "Continue? (y/n):",
50//!     &[
51//!         Sanitize::IsType(DesiredType::String),
52//!         Sanitize::MatchStrings(vec!["y".to_string(), "n".to_string()]),
53//!     ],
54//! );
55//! println!("{} {}", yes.answer, yn.answer);
56//! ```
57//!
58//! ### Validate numeric range
59//! ```rust,no_run
60//! use stalkermap::utils::{Sanitize, Terminal};
61//!
62//! // Ensure input is an integer between 1 and 10 (inclusive)
63//! let number = Terminal::ask(
64//!     "Enter a number between 1 and 10:",
65//!     &[Sanitize::IsBetween(1, 10)],
66//! );
67//! println!("In range: {}", number.answer);
68//! ```
69use std::{error::Error, fmt::Display};
70
71/// Represents a validation filter that can be applied to user input.
72///
73/// - `MatchString`: ensures that the input matches a specific string.
74/// - `MatchStrings`: ensures that the input matches one of the given options.
75/// - `IsType`: ensures that the input can be parsed into a certain [`DesiredType`].
76/// - `IsBetween`: ensures that a numeric input is within an inclusive range `[min, max]`.
77pub enum Sanitize {
78    MatchString(String),
79    MatchStrings(Vec<String>),
80    IsBetween(isize, isize),
81    IsType(DesiredType),
82}
83
84/// Trait for input validation.  
85/// Any type that implements this can validate a string input and return
86/// either `Ok(())` if the input is valid or a [`FilterErrorNot`] on failure.
87trait Validate {
88    fn validate(&self, input: &str) -> Result<(), FilterErrorNot>;
89}
90
91/// Represents an error that occurs when input validation fails.
92///
93/// Each variant describes why the input was rejected:
94/// - [`Number`]: could not parse as the expected numeric type.
95/// - [`String`]: could not parse as string.
96/// - [`Bool`]: could not parse as boolean.
97/// - [`MatchString`]: did not match the required string.
98/// - [`MatchStrings`]: did not match any of the given options.
99/// - [`Between`]: did not match between the values given.
100#[derive(Debug)]
101pub(crate) enum FilterErrorNot {
102    Number(DesiredType),
103    String(DesiredType),
104    Bool(DesiredType),
105    MatchString(String),
106    MatchStrings(Vec<String>),
107    Between(isize, isize),
108}
109
110impl Display for FilterErrorNot {
111    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112        match self {
113            Self::Number(t) => write!(f, "The value is not a {}, try again!", t),
114            Self::String(t) => write!(f, "The value is not {}, try again!", t),
115            Self::Bool(t) => write!(f, "The value is not a {}, try again!", t),
116            Self::MatchString(s) => write!(f, "The value doesn't match with {}, try again!", s),
117            Self::MatchStrings(v) => write!(
118                f,
119                "The value doesn't match with the options: {}, try again!",
120                v.join(", ")
121            ),
122            Self::Between(n1, n2) => {
123                write!(f, "The value is not between {} and {}, try again!", n1, n2)
124            }
125        }
126    }
127}
128
129impl Error for FilterErrorNot {}
130
131/// Macro helper that validates if an input string can be parsed into the given Rust type.  
132/// Expands into a `Result<(), expr>`.
133///
134/// # Parameters
135/// - `$input`: The input string to parse.
136/// - `$t`: The Rust type (e.g. `u8`, `i32`, `bool`).
137/// - `$err`: The error to return if parsing fails.
138///
139/// # Example
140/// ```rust,ignore
141///
142/// let input = "42";
143/// check_type!(input, u8, Err(FilterErrorNot::Number(DesiredType::U8)));
144/// ```
145#[macro_export]
146macro_rules! check_type {
147    ($input:expr, $t:ty, $err:expr) => {
148        match $input.parse::<$t>() {
149            Ok(_) => Ok(()),
150            Err(_) => $err,
151        }
152    };
153}
154
155impl Sanitize {
156    /// Executes all provided filters against the given answer.
157    ///
158    /// - Trims whitespace before validation.
159    /// - Stops and returns the first error encountered.
160    /// - Returns the cleaned string if all filters pass.
161    pub(crate) fn execute(answer: &str, filters: &[Sanitize]) -> Result<String, FilterErrorNot> {
162        let clean_answer = answer.trim();
163
164        for filter in filters {
165            match filter.validate(clean_answer) {
166                Ok(_) => continue,
167                Err(e) => return Err(e),
168            }
169        }
170        Ok(clean_answer.to_string())
171    }
172}
173
174impl Validate for Sanitize {
175    fn validate(&self, input: &str) -> Result<(), FilterErrorNot> {
176        match self {
177            Sanitize::IsType(ty) => ty.parse(input),
178            Sanitize::MatchString(s) => {
179                if input == s {
180                    Ok(())
181                } else {
182                    Err(FilterErrorNot::MatchString(s.to_string()))
183                }
184            }
185            Sanitize::MatchStrings(options) => {
186                if options.contains(&input.to_string()) {
187                    Ok(())
188                } else {
189                    Err(FilterErrorNot::MatchStrings(options.clone()))
190                }
191            }
192            Sanitize::IsBetween(n1, n2) => match DesiredType::Isize.parse(input) {
193                Ok(_) => {
194                    let input_parsed: isize = input.parse().unwrap_or_default();
195                    if input_parsed >= *n1 && input_parsed <= *n2 {
196                        Ok(())
197                    } else {
198                        Err(FilterErrorNot::Between(*n1, *n2))
199                    }
200                }
201                Err(e) => Err(e),
202            },
203        }
204    }
205}
206
207/// Represents the desired type to which the input should be parsed.
208///
209/// Used together with [`Sanitize::IsType`] to validate primitive values.
210///
211/// Currently supports:
212/// - `String`
213/// - `Bool`
214/// - Unsigned integers: `U8`, `U16`, `U32`, `U64`, `U128`
215/// - Signed integers: `I8`, `I16`, `I32`, `I64`, `I128`
216/// - Platform-sized integer: `Isize`
217#[derive(Debug)]
218pub enum DesiredType {
219    String,
220    Bool,
221    U8,
222    U16,
223    U32,
224    U64,
225    U128,
226    I8,
227    I16,
228    I32,
229    I64,
230    I128,
231    Isize,
232}
233
234impl std::str::FromStr for DesiredType {
235    type Err = DesiredTypeFromStrErr;
236    fn from_str(s: &str) -> Result<Self, Self::Err> {
237        match s {
238            "String" => Ok(DesiredType::String),
239            "bool" => Ok(DesiredType::Bool),
240            "u8" => Ok(DesiredType::U8),
241            "u16" => Ok(DesiredType::U16),
242            "u32" => Ok(DesiredType::U32),
243            "u64" => Ok(DesiredType::U64),
244            "u128" => Ok(DesiredType::U128),
245            "i8" => Ok(DesiredType::I8),
246            "i16" => Ok(DesiredType::I16),
247            "i32" => Ok(DesiredType::I32),
248            "i64" => Ok(DesiredType::I64),
249            "i128" => Ok(DesiredType::I128),
250            "isize" => Ok(DesiredType::Isize),
251            s => Err(DesiredTypeFromStrErr::UnknownType(s.to_string())),
252        }
253    }
254}
255
256/// Allows fallible conversion from a `&str` into a [`DesiredType`].
257///
258/// This is a convenience wrapper around the `FromStr` implementation,
259/// letting you use `DesiredType::try_from("...")` instead of
260/// `"..."`.`parse::<DesiredType>`().
261///
262/// # Errors
263/// Returns [`DesiredTypeFromStrErr::UnknownType`] if the input string
264/// does not match any valid [`DesiredType`].
265///
266/// # Example
267/// ```rust,no_run
268/// use stalkermap::utils::sanitize::{DesiredType, DesiredTypeFromStrErr};
269/// use std::convert::TryFrom;
270///
271/// let t = DesiredType::try_from("u8").unwrap();
272/// assert!(matches!(t, DesiredType::U8));
273///
274/// let err = DesiredType::try_from("banana");
275/// assert!(matches!(err, Err(DesiredTypeFromStrErr::UnknownType(_))));
276/// ```
277impl TryFrom<&str> for DesiredType {
278    type Error = DesiredTypeFromStrErr;
279    fn try_from(value: &str) -> Result<Self, Self::Error> {
280        value.parse::<DesiredType>()
281    }
282}
283
284/// Error type returned when parsing a [`DesiredType`] from a string fails.
285///
286/// This is used by the `FromStr` and [`TryFrom<&str>`] implementations
287/// of [`DesiredType`].
288///
289/// # Variants
290/// - `UnknownType`: the provided string did not match any known [`DesiredType`].
291///
292/// # Example
293/// ```rust,no_run
294/// use stalkermap::utils::sanitize::{DesiredType, DesiredTypeFromStrErr};
295/// use std::str::FromStr;
296///
297/// let res = DesiredType::from_str("banana");
298/// assert!(matches!(res, Err(DesiredTypeFromStrErr::UnknownType(_))));
299/// ```
300#[derive(Debug)]
301pub enum DesiredTypeFromStrErr {
302    /// The provided string does not correspond to any valid [`DesiredType`].
303    UnknownType(String),
304}
305
306/// Implements a human-readable description of the [`DesiredTypeFromStrErr`] error.
307///
308/// This is mainly used for displaying friendly messages when invalid
309/// type names are provided by the user.
310///
311/// # Example
312/// ```rust
313/// use stalkermap::utils::sanitize::{DesiredType, DesiredTypeFromStrErr};
314/// use std::str::FromStr;
315///
316/// let err = DesiredType::from_str("banana").unwrap_err();
317/// assert_eq!(
318///     format!("{}", err),
319///     "The value banana is not available as a type, try again!"
320/// );
321/// ```
322impl Display for DesiredTypeFromStrErr {
323    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
324        match self {
325            Self::UnknownType(s) => {
326                write!(f, "The value {} is not available as a type, try again!", s)
327            }
328        }
329    }
330}
331
332/// Marker implementation that allows [`DesiredTypeFromStrErr`] to integrate with
333/// the standard [`std::error::Error`] trait ecosystem.
334///
335/// This enables the use of the `?` operator with functions that return
336/// `Result<T, Box<dyn Error>>` or similar.
337///
338/// No extra functionality is added here.
339impl Error for DesiredTypeFromStrErr {}
340
341impl DesiredType {
342    /// Matches a [`DesiredType`] variant and applies the corresponding [`check_type!`] validation.
343    ///
344    /// Expands into a `match` that checks the input string against
345    /// all supported [`DesiredType`] variants (string, bool, integers).
346    ///
347    /// # Example
348    /// ```rust,ignore
349    /// use stalkermap::utils::DesiredType;
350    ///
351    /// let input = "true";
352    /// let desired = DesiredType::Bool;
353    ///
354    /// desired.parse(input)? // succeeds if input parses as bool
355    /// ```
356    fn parse(&self, input: &str) -> Result<(), FilterErrorNot> {
357        match self {
358            DesiredType::String => {
359                check_type!(
360                    input,
361                    String,
362                    Err(FilterErrorNot::String(DesiredType::String))
363                )
364            }
365            DesiredType::Bool => {
366                check_type!(input, bool, Err(FilterErrorNot::Bool(DesiredType::Bool)))
367            }
368            DesiredType::U8 => check_type!(input, u8, Err(FilterErrorNot::Number(DesiredType::U8))),
369            DesiredType::U16 => {
370                check_type!(input, u16, Err(FilterErrorNot::Number(DesiredType::U16)))
371            }
372            DesiredType::U32 => {
373                check_type!(input, u32, Err(FilterErrorNot::Number(DesiredType::U32)))
374            }
375            DesiredType::U64 => {
376                check_type!(input, u64, Err(FilterErrorNot::Number(DesiredType::U64)))
377            }
378            DesiredType::U128 => {
379                check_type!(input, u128, Err(FilterErrorNot::Number(DesiredType::U128)))
380            }
381            DesiredType::I8 => check_type!(input, i8, Err(FilterErrorNot::Number(DesiredType::I8))),
382            DesiredType::I16 => {
383                check_type!(input, i16, Err(FilterErrorNot::Number(DesiredType::I16)))
384            }
385            DesiredType::I32 => {
386                check_type!(input, i32, Err(FilterErrorNot::Number(DesiredType::I32)))
387            }
388            DesiredType::I64 => {
389                check_type!(input, i64, Err(FilterErrorNot::Number(DesiredType::I64)))
390            }
391            DesiredType::I128 => {
392                check_type!(input, i128, Err(FilterErrorNot::Number(DesiredType::I128)))
393            }
394            DesiredType::Isize => check_type!(
395                input,
396                isize,
397                Err(FilterErrorNot::Number(DesiredType::Isize))
398            ),
399        }
400    }
401}
402
403impl Display for DesiredType {
404    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
405        match self {
406            Self::String => write!(f, "string"),
407            Self::Bool => write!(f, "bool"),
408            Self::U8 => write!(f, "u8"),
409            Self::U16 => write!(f, "u16"),
410            Self::U32 => write!(f, "u32"),
411            Self::U64 => write!(f, "u64"),
412            Self::U128 => write!(f, "u128"),
413            Self::I8 => write!(f, "i8"),
414            Self::I16 => write!(f, "i16"),
415            Self::I32 => write!(f, "i32"),
416            Self::I64 => write!(f, "i64"),
417            Self::I128 => write!(f, "i128"),
418            Self::Isize => write!(f, "isize"),
419        }
420    }
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    #[test]
428    fn test_sanitize_match_string_sucess() {
429        let filter = Sanitize::MatchString("hello".to_string());
430        assert!(filter.validate("hello").is_ok());
431    }
432
433    #[test]
434    fn test_sanitize_match_string_fail() {
435        let filter = Sanitize::MatchString("hello".to_string());
436        let res = filter.validate("world");
437        assert!(res.is_err());
438        if let Err(e) = res {
439            assert_eq!(
440                format!("{}", e),
441                "The value doesn't match with hello, try again!"
442            );
443        }
444    }
445
446    #[test]
447    fn match_sanitize_match_strings_sucess() {
448        let filter = Sanitize::MatchStrings(vec!["A".to_string(), "B".to_string()]);
449        assert!(filter.validate("A").is_ok());
450        assert!(filter.validate("B").is_ok());
451    }
452
453    #[test]
454    fn test_sanitize_match_strings_fail() {
455        let filter = Sanitize::MatchStrings(vec!["A".to_string(), "B".to_string()]);
456        let res = filter.validate("C");
457        assert!(res.is_err());
458        if let Err(e) = res {
459            assert_eq!(
460                format!("{}", e),
461                "The value doesn't match with the options: A, B, try again!"
462            );
463        }
464    }
465
466    #[test]
467    fn test_sanitize_is_type_bool() {
468        let filter = Sanitize::IsType(DesiredType::Bool);
469        assert!(filter.validate("true").is_ok());
470        assert!(filter.validate("false").is_ok());
471        assert!(filter.validate("maybe").is_err());
472    }
473
474    #[test]
475    fn test_sanitize_is_type_u8() {
476        let filter = Sanitize::IsType(DesiredType::U8);
477        assert!(filter.validate("42").is_ok());
478        assert!(filter.validate("-42").is_err());
479        assert!(filter.validate("256").is_err()); // u8 max is 255
480        assert!(filter.validate("abc").is_err());
481    }
482
483    #[test]
484    fn test_sanitize_is_type_i32() {
485        let filter = Sanitize::IsType(DesiredType::I32);
486        assert!(filter.validate("-123").is_ok());
487        assert!(filter.validate("2147483647").is_ok()); // i32 max
488        assert!(filter.validate("2147483648").is_err()); // overflow
489    }
490
491    #[test]
492    fn test_sanitize_is_type_isize() {
493        let filter = Sanitize::IsBetween(10, 20);
494        assert!(filter.validate("15").is_ok());
495        assert!(filter.validate("25").is_err()); // Over 20 
496        assert!(filter.validate("-20").is_err()); // overflow
497    }
498
499    #[test]
500    fn test_sanitize_is_type_from_str() {
501        let filter = Sanitize::IsType("u8".parse::<DesiredType>().unwrap());
502        assert!(filter.validate("42").is_ok());
503        assert!(filter.validate("-42").is_err());
504        assert!(filter.validate("256").is_err()); // u8 max is 255
505        assert!(filter.validate("abc").is_err());
506    }
507
508    #[test]
509    fn test_sanitize_is_type_try_from_str() {
510        let filter = Sanitize::IsType(DesiredType::try_from("u8").unwrap());
511        assert!(filter.validate("42").is_ok());
512        assert!(filter.validate("-42").is_err());
513        assert!(filter.validate("256").is_err()); // u8 max is 255
514        assert!(filter.validate("abc").is_err());
515    }
516
517    #[test]
518    fn test_sanitize_execute_filters_success() {
519        let filters = vec![
520            Sanitize::IsType(DesiredType::String),
521            Sanitize::MatchString("Hello".to_string()),
522        ];
523        let res = Sanitize::execute("Hello", &filters);
524        assert!(res.is_ok());
525        assert_eq!(res.unwrap(), "Hello".to_string());
526    }
527
528    #[test]
529    fn test_sanitize_execute_filters_fail() {
530        let filters = vec![
531            Sanitize::MatchString("Hello".to_string()),
532            Sanitize::IsType(DesiredType::Bool),
533        ];
534        let res = Sanitize::execute("Hello", &filters);
535        assert!(res.is_err());
536        if let Err(e) = res {
537            assert_eq!(format!("{}", e), "The value is not a bool, try again!");
538        }
539    }
540
541    #[test]
542    fn test_sanitize_execute_filters_fail2() {
543        let filters = vec![
544            Sanitize::IsType(DesiredType::Bool),
545            Sanitize::IsType(DesiredType::U8),
546        ];
547        let res = Sanitize::execute("true", &filters);
548        assert!(res.is_err());
549        if let Err(e) = res {
550            assert_eq!(format!("{}", e), "The value is not a u8, try again!");
551        }
552    }
553
554    #[test]
555    fn test_sanitize_execute_filters_fail3() {
556        let filters = vec![
557            Sanitize::IsType(DesiredType::U8),
558            Sanitize::IsType(DesiredType::Bool),
559        ];
560        let res = Sanitize::execute("true", &filters);
561        assert!(res.is_err());
562        if let Err(e) = res {
563            assert_eq!(format!("{}", e), "The value is not a u8, try again!");
564        }
565    }
566
567    #[test]
568    fn test_sanitize_execute_filters_fail4() {
569        let filters = vec![
570            Sanitize::IsType(DesiredType::String),
571            Sanitize::MatchStrings(vec![String::from("banana"), String::from("orange")]),
572        ];
573        let res = Sanitize::execute("watermelon", &filters);
574        assert!(res.is_err());
575        if let Err(e) = res {
576            assert_eq!(
577                format!("{}", e),
578                "The value doesn't match with the options: banana, orange, try again!"
579            );
580        }
581    }
582}