entrust-dialog 0.6.0

Interactive terminal dialogs for entrust
Documentation
use crate::input::InputDialog;
use std::borrow::Cow;
use std::fmt::{Debug, Formatter};
use std::ops::Add;

pub trait ValidatorFn<'f>: 'f + Fn(&[char]) -> Option<Cow<'f, str>> {}
impl<'f, F> ValidatorFn<'f> for F where F: 'f + Fn(&[char]) -> Option<Cow<'f, str>> {}

pub struct Validator<'f> {
    function: Box<dyn ValidatorFn<'f>>,
}

impl<'f> Validator<'f> {
    pub fn new(function: impl ValidatorFn<'f>) -> Self {
        Validator {
            function: Box::new(function),
        }
    }
    pub fn not_empty(message: &'f str) -> Self {
        Validator::new(validate_not_empty(message))
    }
    pub fn filename() -> Validator<'static> {
        Validator::new(validate_filename(false))
    }
    pub fn filename_cross_platform() -> Validator<'static> {
        Validator::new(validate_filename(true))
    }
}

pub fn validate_not_empty(message: &str) -> impl ValidatorFn<'_> {
    |chars| {
        if chars.is_empty() {
            Some(Cow::Borrowed(message))
        } else {
            None
        }
    }
}

pub fn validate_filename(cross_platform: bool) -> impl ValidatorFn<'static> {
    const WINDOWS_ILLEGAL_CHARS: &str = r#":*?"<>|"#;
    move |chars| {
        if chars.is_empty() {
            return Some("Filename must not be empty".into());
        }
        if chars.last() == Some(&'/') {
            return Some("Filename must not end with '/'".into());
        }
        if cross_platform || cfg!(windows) {
            let contains_invalid = chars
                .iter()
                .any(|char| WINDOWS_ILLEGAL_CHARS.contains(*char));
            if contains_invalid {
                return Some(format!("Filename must not contain any of the following characters: {WINDOWS_ILLEGAL_CHARS}").into());
            }
        }
        if cross_platform || cfg!(unix) {
            let bytes_len = chars.iter().fold(0, |acc, e| acc + e.len_utf8());
            if bytes_len > 255 {
                return Some("Filename must not be longer than 255 bytes".into());
            }
        }
        None
    }
}

impl<'f, F> From<F> for Validator<'f>
where
    F: ValidatorFn<'f>,
{
    fn from(value: F) -> Self {
        Validator::new(value)
    }
}

impl Default for Validator<'_> {
    fn default() -> Self {
        Validator::new(|_| None)
    }
}

impl<'f> Add for Validator<'f> {
    type Output = Validator<'f>;

    fn add(self, rhs: Self) -> Self::Output {
        Validator::new(combine(self.function, rhs.function))
    }
}

pub fn combine<'a>(
    val_fn_1: impl ValidatorFn<'a>,
    val_fn_2: impl ValidatorFn<'a>,
) -> impl ValidatorFn<'a> {
    move |chars| val_fn_1(chars).or(val_fn_2(chars))
}

impl<'p, 'c> InputDialog<'p, 'c> {
    pub fn validation_message(&self) -> Option<Cow<'static, str>> {
        (self.validator.function)(&self.content)
    }
}

impl Debug for Validator<'_> {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        write!(f, "Validator")
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::dialog::{Dialog, DialogState};
    use crate::input::Update;
    use crate::input::Update::InsertChar;

    #[test]
    fn test_not_empty() {
        let mut state =
            InputDialog::default().with_validator(Validator::not_empty("must not be empty"));

        assert_eq!(Some("must not be empty".into()), state.validation_message());

        state.perform_update(Update::Confirm).unwrap();
        assert_eq!(DialogState::Pending, state.state);

        state.perform_update(InsertChar('a')).unwrap();
        assert_eq!(None, state.validation_message());

        state.perform_update(Update::Confirm).unwrap();
        assert_eq!(DialogState::Completed, state.state);
    }
}