Skip to main content

validation_core/rules/
pattern_rules.rs

1// src/validation/patterns.rs
2//! Position-based pattern filtering for validation
3
4use serde::{Deserialize, Serialize};
5use std::sync::Arc;
6
7/// A filter that applies to specific character positions in a field
8#[derive(Debug, Clone)]
9pub struct PositionFilter {
10    /// Which positions this filter applies to
11    pub positions: PositionRange,
12    /// What type of character filter to apply
13    pub filter: CharacterFilter,
14}
15
16/// Defines which character positions a filter applies to
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub enum PositionRange {
19    /// Single position (e.g., position 3 only)
20    Single(usize),
21    /// Range of positions (e.g., positions 0-2, inclusive)
22    Range(usize, usize),
23    /// From position onwards (e.g., position 4 and beyond)
24    From(usize),
25    /// Multiple specific positions (e.g., positions 0, 2, 5)
26    Multiple(Vec<usize>),
27}
28
29/// Types of character filters that can be applied
30pub enum CharacterFilter {
31    /// Allow only alphabetic characters (a-z, A-Z)
32    Alphabetic,
33    /// Allow only numeric characters (0-9)
34    Numeric,
35    /// Allow alphanumeric characters (a-z, A-Z, 0-9)
36    Alphanumeric,
37    /// Allow only exact character match
38    Exact(char),
39    /// Allow any character from the provided set
40    OneOf(Vec<char>),
41    /// Custom user-defined filter function
42    Custom(Arc<dyn Fn(char) -> bool + Send + Sync>),
43}
44
45// Manual implementations for Debug and Clone
46impl std::fmt::Debug for CharacterFilter {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        match self {
49            CharacterFilter::Alphabetic => write!(f, "Alphabetic"),
50            CharacterFilter::Numeric => write!(f, "Numeric"),
51            CharacterFilter::Alphanumeric => write!(f, "Alphanumeric"),
52            CharacterFilter::Exact(ch) => write!(f, "Exact('{ch}')"),
53            CharacterFilter::OneOf(chars) => write!(f, "OneOf({chars:?})"),
54            CharacterFilter::Custom(_) => write!(f, "Custom(<function>)"),
55        }
56    }
57}
58
59impl Clone for CharacterFilter {
60    fn clone(&self) -> Self {
61        match self {
62            CharacterFilter::Alphabetic => CharacterFilter::Alphabetic,
63            CharacterFilter::Numeric => CharacterFilter::Numeric,
64            CharacterFilter::Alphanumeric => CharacterFilter::Alphanumeric,
65            CharacterFilter::Exact(ch) => CharacterFilter::Exact(*ch),
66            CharacterFilter::OneOf(chars) => CharacterFilter::OneOf(chars.clone()),
67            CharacterFilter::Custom(func) => CharacterFilter::Custom(Arc::clone(func)),
68        }
69    }
70}
71
72impl PositionRange {
73    /// Check if a position is included in this range
74    pub fn contains(&self, position: usize) -> bool {
75        match self {
76            PositionRange::Single(pos) => position == *pos,
77            PositionRange::Range(start, end) => position >= *start && position <= *end,
78            PositionRange::From(start) => position >= *start,
79            PositionRange::Multiple(positions) => positions.contains(&position),
80        }
81    }
82
83    /// Get all positions up to a given length that this range covers
84    pub fn positions_up_to(&self, max_length: usize) -> Vec<usize> {
85        match self {
86            PositionRange::Single(pos) => {
87                if *pos < max_length {
88                    vec![*pos]
89                } else {
90                    vec![]
91                }
92            }
93            PositionRange::Range(start, end) => {
94                let actual_end = (*end).min(max_length.saturating_sub(1));
95                if *start <= actual_end {
96                    (*start..=actual_end).collect()
97                } else {
98                    vec![]
99                }
100            }
101            PositionRange::From(start) => {
102                if *start < max_length {
103                    (*start..max_length).collect()
104                } else {
105                    vec![]
106                }
107            }
108            PositionRange::Multiple(positions) => positions
109                .iter()
110                .filter(|&&pos| pos < max_length)
111                .copied()
112                .collect(),
113        }
114    }
115}
116
117impl CharacterFilter {
118    /// Test if a character passes this filter
119    pub fn accepts(&self, ch: char) -> bool {
120        match self {
121            CharacterFilter::Alphabetic => ch.is_alphabetic(),
122            CharacterFilter::Numeric => ch.is_numeric(),
123            CharacterFilter::Alphanumeric => ch.is_alphanumeric(),
124            CharacterFilter::Exact(expected) => ch == *expected,
125            CharacterFilter::OneOf(chars) => chars.contains(&ch),
126            CharacterFilter::Custom(func) => func(ch),
127        }
128    }
129
130    /// Get a human-readable description of this filter
131    pub fn description(&self) -> String {
132        match self {
133            CharacterFilter::Alphabetic => "alphabetic characters (a-z, A-Z)".to_string(),
134            CharacterFilter::Numeric => "numeric characters (0-9)".to_string(),
135            CharacterFilter::Alphanumeric => "alphanumeric characters (a-z, A-Z, 0-9)".to_string(),
136            CharacterFilter::Exact(ch) => format!("exactly '{ch}'"),
137            CharacterFilter::OneOf(chars) => {
138                let char_list: String = chars.iter().collect();
139                format!("one of: {char_list}")
140            }
141            CharacterFilter::Custom(_) => "custom filter".to_string(),
142        }
143    }
144}
145
146impl PositionFilter {
147    /// Create a new position filter
148    pub fn new(positions: PositionRange, filter: CharacterFilter) -> Self {
149        Self { positions, filter }
150    }
151
152    /// Validate a character at a specific position
153    pub fn validate_position(&self, position: usize, character: char) -> bool {
154        if self.positions.contains(position) {
155            self.filter.accepts(character)
156        } else {
157            true // Position not covered by this filter, allow any character
158        }
159    }
160
161    /// Get error message for invalid character at position
162    pub fn error_message(&self, position: usize, character: char) -> Option<String> {
163        if self.positions.contains(position) && !self.filter.accepts(character) {
164            Some(format!(
165                "Position {} requires {} but got '{}'",
166                position,
167                self.filter.description(),
168                character
169            ))
170        } else {
171            None
172        }
173    }
174}
175
176/// A collection of position filters for a field
177#[derive(Debug, Clone, Default)]
178pub struct PatternFilters {
179    filters: Vec<PositionFilter>,
180}
181
182impl PatternFilters {
183    /// Create empty pattern filters
184    pub fn new() -> Self {
185        Self::default()
186    }
187
188    /// Add a position filter
189    pub fn add_filter(mut self, filter: PositionFilter) -> Self {
190        self.filters.push(filter);
191        self
192    }
193
194    /// Add multiple filters
195    pub fn add_filters(mut self, filters: Vec<PositionFilter>) -> Self {
196        self.filters.extend(filters);
197        self
198    }
199
200    /// Validate a character at a specific position against all applicable filters
201    pub fn validate_char_at_position(
202        &self,
203        position: usize,
204        character: char,
205    ) -> Result<(), String> {
206        for filter in &self.filters {
207            if let Some(error) = filter.error_message(position, character) {
208                return Err(error);
209            }
210        }
211        Ok(())
212    }
213
214    /// Validate entire text against all filters
215    pub fn validate_text(&self, text: &str) -> Result<(), String> {
216        for (position, character) in text.char_indices() {
217            self.validate_char_at_position(position, character)?
218        }
219        Ok(())
220    }
221
222    /// Check if any filters are configured
223    pub fn has_filters(&self) -> bool {
224        !self.filters.is_empty()
225    }
226
227    /// Get all configured filters
228    pub fn filters(&self) -> &[PositionFilter] {
229        &self.filters
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn test_position_range_contains() {
239        assert!(PositionRange::Single(3).contains(3));
240        assert!(!PositionRange::Single(3).contains(2));
241
242        assert!(PositionRange::Range(1, 4).contains(3));
243        assert!(!PositionRange::Range(1, 4).contains(5));
244
245        assert!(PositionRange::From(2).contains(5));
246        assert!(!PositionRange::From(2).contains(1));
247
248        assert!(PositionRange::Multiple(vec![0, 2, 5]).contains(2));
249        assert!(!PositionRange::Multiple(vec![0, 2, 5]).contains(3));
250    }
251
252    #[test]
253    fn test_position_range_positions_up_to() {
254        assert_eq!(PositionRange::Single(3).positions_up_to(5), vec![3]);
255        assert_eq!(PositionRange::Single(5).positions_up_to(3), vec![]);
256
257        assert_eq!(PositionRange::Range(1, 3).positions_up_to(5), vec![1, 2, 3]);
258        assert_eq!(PositionRange::Range(1, 5).positions_up_to(3), vec![1, 2]);
259
260        assert_eq!(PositionRange::From(2).positions_up_to(5), vec![2, 3, 4]);
261
262        assert_eq!(
263            PositionRange::Multiple(vec![0, 2, 5]).positions_up_to(4),
264            vec![0, 2]
265        );
266    }
267
268    #[test]
269    fn test_character_filter_accepts() {
270        assert!(CharacterFilter::Alphabetic.accepts('a'));
271        assert!(CharacterFilter::Alphabetic.accepts('Z'));
272        assert!(!CharacterFilter::Alphabetic.accepts('1'));
273
274        assert!(CharacterFilter::Numeric.accepts('5'));
275        assert!(!CharacterFilter::Numeric.accepts('a'));
276
277        assert!(CharacterFilter::Alphanumeric.accepts('a'));
278        assert!(CharacterFilter::Alphanumeric.accepts('5'));
279        assert!(!CharacterFilter::Alphanumeric.accepts('-'));
280
281        assert!(CharacterFilter::Exact('x').accepts('x'));
282        assert!(!CharacterFilter::Exact('x').accepts('y'));
283
284        assert!(CharacterFilter::OneOf(vec!['a', 'b', 'c']).accepts('b'));
285        assert!(!CharacterFilter::OneOf(vec!['a', 'b', 'c']).accepts('d'));
286    }
287
288    #[test]
289    fn test_position_filter_validation() {
290        let filter = PositionFilter::new(PositionRange::Range(0, 1), CharacterFilter::Alphabetic);
291
292        assert!(filter.validate_position(0, 'A'));
293        assert!(filter.validate_position(1, 'b'));
294        assert!(!filter.validate_position(0, '1'));
295        assert!(filter.validate_position(2, '1')); // Position 2 not covered, allow anything
296    }
297
298    #[test]
299    fn test_pattern_filters_validation() {
300        let patterns = PatternFilters::new()
301            .add_filter(PositionFilter::new(
302                PositionRange::Range(0, 1),
303                CharacterFilter::Alphabetic,
304            ))
305            .add_filter(PositionFilter::new(
306                PositionRange::Range(2, 4),
307                CharacterFilter::Numeric,
308            ));
309
310        // Valid pattern: AB123
311        assert!(patterns.validate_text("AB123").is_ok());
312
313        // Invalid: number in alphabetic position
314        assert!(patterns.validate_text("A1123").is_err());
315
316        // Invalid: letter in numeric position
317        assert!(patterns.validate_text("AB1A3").is_err());
318    }
319
320    #[test]
321    fn test_custom_filter() {
322        let pattern = PatternFilters::new().add_filter(PositionFilter::new(
323            PositionRange::From(0),
324            CharacterFilter::Custom(Arc::new(|c| c.is_lowercase())),
325        ));
326
327        assert!(pattern.validate_text("hello").is_ok());
328        assert!(pattern.validate_text("Hello").is_err()); // Uppercase not allowed
329    }
330}