tui-canvas-validation-core 0.8.2

Validation core for the tui-canvas
Documentation
// src/validation/patterns.rs
//! Position-based pattern filtering for validation

use serde::{Deserialize, Serialize};
use std::sync::Arc;

/// A filter that applies to specific character positions in a field
#[derive(Debug, Clone)]
pub struct PositionFilter {
    /// Which positions this filter applies to
    pub positions: PositionRange,
    /// What type of character filter to apply
    pub filter: CharacterFilter,
}

/// Defines which character positions a filter applies to
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PositionRange {
    /// Single position (e.g., position 3 only)
    Single(usize),
    /// Range of positions (e.g., positions 0-2, inclusive)
    Range(usize, usize),
    /// From position onwards (e.g., position 4 and beyond)
    From(usize),
    /// Multiple specific positions (e.g., positions 0, 2, 5)
    Multiple(Vec<usize>),
}

/// Types of character filters that can be applied
pub enum CharacterFilter {
    /// Allow only alphabetic characters (a-z, A-Z)
    Alphabetic,
    /// Allow only numeric characters (0-9)
    Numeric,
    /// Allow alphanumeric characters (a-z, A-Z, 0-9)
    Alphanumeric,
    /// Allow only exact character match
    Exact(char),
    /// Allow any character from the provided set
    OneOf(Vec<char>),
    /// Custom user-defined filter function
    Custom(Arc<dyn Fn(char) -> bool + Send + Sync>),
}

// Manual implementations for Debug and Clone
impl std::fmt::Debug for CharacterFilter {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            CharacterFilter::Alphabetic => write!(f, "Alphabetic"),
            CharacterFilter::Numeric => write!(f, "Numeric"),
            CharacterFilter::Alphanumeric => write!(f, "Alphanumeric"),
            CharacterFilter::Exact(ch) => write!(f, "Exact('{ch}')"),
            CharacterFilter::OneOf(chars) => write!(f, "OneOf({chars:?})"),
            CharacterFilter::Custom(_) => write!(f, "Custom(<function>)"),
        }
    }
}

impl Clone for CharacterFilter {
    fn clone(&self) -> Self {
        match self {
            CharacterFilter::Alphabetic => CharacterFilter::Alphabetic,
            CharacterFilter::Numeric => CharacterFilter::Numeric,
            CharacterFilter::Alphanumeric => CharacterFilter::Alphanumeric,
            CharacterFilter::Exact(ch) => CharacterFilter::Exact(*ch),
            CharacterFilter::OneOf(chars) => CharacterFilter::OneOf(chars.clone()),
            CharacterFilter::Custom(func) => CharacterFilter::Custom(Arc::clone(func)),
        }
    }
}

impl PositionRange {
    /// Check if a position is included in this range
    pub fn contains(&self, position: usize) -> bool {
        match self {
            PositionRange::Single(pos) => position == *pos,
            PositionRange::Range(start, end) => position >= *start && position <= *end,
            PositionRange::From(start) => position >= *start,
            PositionRange::Multiple(positions) => positions.contains(&position),
        }
    }

    /// Get all positions up to a given length that this range covers
    pub fn positions_up_to(&self, max_length: usize) -> Vec<usize> {
        match self {
            PositionRange::Single(pos) => {
                if *pos < max_length {
                    vec![*pos]
                } else {
                    vec![]
                }
            }
            PositionRange::Range(start, end) => {
                let actual_end = (*end).min(max_length.saturating_sub(1));
                if *start <= actual_end {
                    (*start..=actual_end).collect()
                } else {
                    vec![]
                }
            }
            PositionRange::From(start) => {
                if *start < max_length {
                    (*start..max_length).collect()
                } else {
                    vec![]
                }
            }
            PositionRange::Multiple(positions) => positions
                .iter()
                .filter(|&&pos| pos < max_length)
                .copied()
                .collect(),
        }
    }
}

impl CharacterFilter {
    /// Test if a character passes this filter
    pub fn accepts(&self, ch: char) -> bool {
        match self {
            CharacterFilter::Alphabetic => ch.is_alphabetic(),
            CharacterFilter::Numeric => ch.is_numeric(),
            CharacterFilter::Alphanumeric => ch.is_alphanumeric(),
            CharacterFilter::Exact(expected) => ch == *expected,
            CharacterFilter::OneOf(chars) => chars.contains(&ch),
            CharacterFilter::Custom(func) => func(ch),
        }
    }

    /// Get a human-readable description of this filter
    pub fn description(&self) -> String {
        match self {
            CharacterFilter::Alphabetic => "alphabetic characters (a-z, A-Z)".to_string(),
            CharacterFilter::Numeric => "numeric characters (0-9)".to_string(),
            CharacterFilter::Alphanumeric => "alphanumeric characters (a-z, A-Z, 0-9)".to_string(),
            CharacterFilter::Exact(ch) => format!("exactly '{ch}'"),
            CharacterFilter::OneOf(chars) => {
                let char_list: String = chars.iter().collect();
                format!("one of: {char_list}")
            }
            CharacterFilter::Custom(_) => "custom filter".to_string(),
        }
    }
}

impl PositionFilter {
    /// Create a new position filter
    pub fn new(positions: PositionRange, filter: CharacterFilter) -> Self {
        Self { positions, filter }
    }

    /// Validate a character at a specific position
    pub fn validate_position(&self, position: usize, character: char) -> bool {
        if self.positions.contains(position) {
            self.filter.accepts(character)
        } else {
            true // Position not covered by this filter, allow any character
        }
    }

    /// Get error message for invalid character at position
    pub fn error_message(&self, position: usize, character: char) -> Option<String> {
        if self.positions.contains(position) && !self.filter.accepts(character) {
            Some(format!(
                "Position {} requires {} but got '{}'",
                position,
                self.filter.description(),
                character
            ))
        } else {
            None
        }
    }
}

/// A collection of position filters for a field
#[derive(Debug, Clone, Default)]
pub struct PatternFilters {
    filters: Vec<PositionFilter>,
}

impl PatternFilters {
    /// Create empty pattern filters
    pub fn new() -> Self {
        Self::default()
    }

    /// Add a position filter
    pub fn add_filter(mut self, filter: PositionFilter) -> Self {
        self.filters.push(filter);
        self
    }

    /// Add multiple filters
    pub fn add_filters(mut self, filters: Vec<PositionFilter>) -> Self {
        self.filters.extend(filters);
        self
    }

    /// Validate a character at a specific position against all applicable filters
    pub fn validate_char_at_position(
        &self,
        position: usize,
        character: char,
    ) -> Result<(), String> {
        for filter in &self.filters {
            if let Some(error) = filter.error_message(position, character) {
                return Err(error);
            }
        }
        Ok(())
    }

    /// Validate entire text against all filters
    pub fn validate_text(&self, text: &str) -> Result<(), String> {
        for (position, character) in text.char_indices() {
            self.validate_char_at_position(position, character)?
        }
        Ok(())
    }

    /// Check if any filters are configured
    pub fn has_filters(&self) -> bool {
        !self.filters.is_empty()
    }

    /// Get all configured filters
    pub fn filters(&self) -> &[PositionFilter] {
        &self.filters
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_position_range_contains() {
        assert!(PositionRange::Single(3).contains(3));
        assert!(!PositionRange::Single(3).contains(2));

        assert!(PositionRange::Range(1, 4).contains(3));
        assert!(!PositionRange::Range(1, 4).contains(5));

        assert!(PositionRange::From(2).contains(5));
        assert!(!PositionRange::From(2).contains(1));

        assert!(PositionRange::Multiple(vec![0, 2, 5]).contains(2));
        assert!(!PositionRange::Multiple(vec![0, 2, 5]).contains(3));
    }

    #[test]
    fn test_position_range_positions_up_to() {
        assert_eq!(PositionRange::Single(3).positions_up_to(5), vec![3]);
        assert_eq!(PositionRange::Single(5).positions_up_to(3), vec![]);

        assert_eq!(PositionRange::Range(1, 3).positions_up_to(5), vec![1, 2, 3]);
        assert_eq!(PositionRange::Range(1, 5).positions_up_to(3), vec![1, 2]);

        assert_eq!(PositionRange::From(2).positions_up_to(5), vec![2, 3, 4]);

        assert_eq!(
            PositionRange::Multiple(vec![0, 2, 5]).positions_up_to(4),
            vec![0, 2]
        );
    }

    #[test]
    fn test_character_filter_accepts() {
        assert!(CharacterFilter::Alphabetic.accepts('a'));
        assert!(CharacterFilter::Alphabetic.accepts('Z'));
        assert!(!CharacterFilter::Alphabetic.accepts('1'));

        assert!(CharacterFilter::Numeric.accepts('5'));
        assert!(!CharacterFilter::Numeric.accepts('a'));

        assert!(CharacterFilter::Alphanumeric.accepts('a'));
        assert!(CharacterFilter::Alphanumeric.accepts('5'));
        assert!(!CharacterFilter::Alphanumeric.accepts('-'));

        assert!(CharacterFilter::Exact('x').accepts('x'));
        assert!(!CharacterFilter::Exact('x').accepts('y'));

        assert!(CharacterFilter::OneOf(vec!['a', 'b', 'c']).accepts('b'));
        assert!(!CharacterFilter::OneOf(vec!['a', 'b', 'c']).accepts('d'));
    }

    #[test]
    fn test_position_filter_validation() {
        let filter = PositionFilter::new(PositionRange::Range(0, 1), CharacterFilter::Alphabetic);

        assert!(filter.validate_position(0, 'A'));
        assert!(filter.validate_position(1, 'b'));
        assert!(!filter.validate_position(0, '1'));
        assert!(filter.validate_position(2, '1')); // Position 2 not covered, allow anything
    }

    #[test]
    fn test_pattern_filters_validation() {
        let patterns = PatternFilters::new()
            .add_filter(PositionFilter::new(
                PositionRange::Range(0, 1),
                CharacterFilter::Alphabetic,
            ))
            .add_filter(PositionFilter::new(
                PositionRange::Range(2, 4),
                CharacterFilter::Numeric,
            ));

        // Valid pattern: AB123
        assert!(patterns.validate_text("AB123").is_ok());

        // Invalid: number in alphabetic position
        assert!(patterns.validate_text("A1123").is_err());

        // Invalid: letter in numeric position
        assert!(patterns.validate_text("AB1A3").is_err());
    }

    #[test]
    fn test_custom_filter() {
        let pattern = PatternFilters::new().add_filter(PositionFilter::new(
            PositionRange::From(0),
            CharacterFilter::Custom(Arc::new(|c| c.is_lowercase())),
        ));

        assert!(pattern.validate_text("hello").is_ok());
        assert!(pattern.validate_text("Hello").is_err()); // Uppercase not allowed
    }
}