Skip to main content

validation_core/rules/
character_limits.rs

1// src/validation/limits.rs
2//! Character limits validation implementation
3
4use crate::ValidationResult;
5use serde::{Deserialize, Serialize};
6use unicode_width::UnicodeWidthStr;
7
8/// Character limits configuration for a field
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct CharacterLimits {
11    /// Maximum number of characters allowed (None = unlimited)
12    max_length: Option<usize>,
13
14    /// Minimum number of characters required (None = no minimum)
15    min_length: Option<usize>,
16
17    /// Warning threshold (warn when approaching max limit)
18    warning_threshold: Option<usize>,
19
20    /// Count mode: characters vs display width
21    count_mode: CountMode,
22}
23
24/// How to count characters for limit checking
25#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
26pub enum CountMode {
27    /// Count actual characters (default)
28    #[default]
29    Characters,
30
31    /// Count display width (useful for CJK characters)
32    DisplayWidth,
33
34    /// Count bytes (rarely used, but available)
35    Bytes,
36}
37
38/// Result of a character limit check
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum LimitCheckResult {
41    /// Within limits
42    Ok,
43
44    /// Approaching limit (warning)
45    Warning { current: usize, max: usize },
46
47    /// At or exceeding limit (error)
48    Exceeded { current: usize, max: usize },
49
50    /// Below minimum length
51    TooShort { current: usize, min: usize },
52}
53
54impl CharacterLimits {
55    /// Create new character limits with just max length
56    pub fn new(max_length: usize) -> Self {
57        Self {
58            max_length: Some(max_length),
59            min_length: None,
60            warning_threshold: None,
61            count_mode: CountMode::default(),
62        }
63    }
64
65    /// Create new character limits with min and max
66    pub fn new_range(min_length: usize, max_length: usize) -> Self {
67        Self {
68            max_length: Some(max_length),
69            min_length: Some(min_length),
70            warning_threshold: None,
71            count_mode: CountMode::default(),
72        }
73    }
74
75    /// Create new character limits with just minimum length
76    pub fn new_min(min_length: usize) -> Self {
77        Self {
78            max_length: None,
79            min_length: Some(min_length),
80            warning_threshold: None,
81            count_mode: CountMode::default(),
82        }
83    }
84
85    /// Create new character limits with only a warning threshold.
86    pub fn new_warning(threshold: usize) -> Self {
87        Self {
88            max_length: None,
89            min_length: None,
90            warning_threshold: Some(threshold),
91            count_mode: CountMode::default(),
92        }
93    }
94
95    /// Set warning threshold (when to show warning before hitting limit)
96    pub fn with_warning_threshold(mut self, threshold: usize) -> Self {
97        self.warning_threshold = Some(threshold);
98        self
99    }
100
101    /// Set count mode (characters vs display width vs bytes)
102    pub fn with_count_mode(mut self, mode: CountMode) -> Self {
103        self.count_mode = mode;
104        self
105    }
106
107    /// Get maximum length
108    pub fn max_length(&self) -> Option<usize> {
109        self.max_length
110    }
111
112    /// Get minimum length
113    pub fn min_length(&self) -> Option<usize> {
114        self.min_length
115    }
116
117    /// Get warning threshold
118    pub fn warning_threshold(&self) -> Option<usize> {
119        self.warning_threshold
120    }
121
122    /// Get count mode
123    pub fn count_mode(&self) -> CountMode {
124        self.count_mode
125    }
126
127    /// Count characters/width/bytes according to the configured mode
128    fn count(&self, text: &str) -> usize {
129        match self.count_mode {
130            CountMode::Characters => text.chars().count(),
131            CountMode::DisplayWidth => text.width(),
132            CountMode::Bytes => text.len(),
133        }
134    }
135
136    /// Check if inserting a character would exceed limits
137    pub fn validate_insertion(
138        &self,
139        current_text: &str,
140        position: usize,
141        character: char,
142    ) -> Option<ValidationResult> {
143        let mut new_text = String::with_capacity(current_text.len() + character.len_utf8());
144        let mut chars = current_text.chars();
145
146        let clamped_pos = position.min(current_text.chars().count());
147        for _ in 0..clamped_pos {
148            if let Some(ch) = chars.next() {
149                new_text.push(ch);
150            }
151        }
152
153        new_text.push(character);
154
155        for ch in chars {
156            new_text.push(ch);
157        }
158
159        let new_count = self.count(&new_text);
160        let current_count = self.count(current_text);
161
162        if let Some(max) = self.max_length {
163            if new_count > max {
164                return Some(ValidationResult::error(format!(
165                    "Character limit exceeded: {new_count}/{max}"
166                )));
167            }
168
169            if let Some(warning_threshold) = self.warning_threshold {
170                if new_count >= warning_threshold && current_count < warning_threshold {
171                    return Some(ValidationResult::warning(format!(
172                        "Approaching character limit: {new_count}/{max}"
173                    )));
174                }
175            }
176        }
177
178        None // No validation issues
179    }
180
181    /// Validate the current content
182    pub fn validate_content(&self, text: &str) -> Option<ValidationResult> {
183        let count = self.count(text);
184
185        if let Some(min) = self.min_length {
186            if count < min {
187                return Some(ValidationResult::warning(format!(
188                    "Minimum length not met: {count}/{min}"
189                )));
190            }
191        }
192
193        if let Some(max) = self.max_length {
194            if count > max {
195                return Some(ValidationResult::error(format!(
196                    "Character limit exceeded: {count}/{max}"
197                )));
198            }
199
200            if let Some(warning_threshold) = self.warning_threshold {
201                if count >= warning_threshold {
202                    return Some(ValidationResult::warning(format!(
203                        "Approaching character limit: {count}/{max}"
204                    )));
205                }
206            }
207        }
208
209        None // No validation issues
210    }
211
212    /// Get the current status of the text against limits
213    pub fn check_limits(&self, text: &str) -> LimitCheckResult {
214        let count = self.count(text);
215
216        if let Some(max) = self.max_length {
217            if count > max {
218                return LimitCheckResult::Exceeded {
219                    current: count,
220                    max,
221                };
222            }
223
224            if let Some(warning_threshold) = self.warning_threshold {
225                if count >= warning_threshold {
226                    return LimitCheckResult::Warning {
227                        current: count,
228                        max,
229                    };
230                }
231            }
232        }
233
234        // Check min length
235        if let Some(min) = self.min_length {
236            if count < min {
237                return LimitCheckResult::TooShort {
238                    current: count,
239                    min,
240                };
241            }
242        }
243
244        LimitCheckResult::Ok
245    }
246
247    /// Get a human-readable status string
248    pub fn status_text(&self, text: &str) -> Option<String> {
249        match self.check_limits(text) {
250            LimitCheckResult::Ok => {
251                // Show current/max if we have a max limit
252                self.max_length
253                    .map(|max| format!("{}/{}", self.count(text), max))
254            }
255            LimitCheckResult::Warning { current, max } => {
256                Some(format!("{current}/{max} (approaching limit)"))
257            }
258            LimitCheckResult::Exceeded { current, max } => {
259                Some(format!("{current}/{max} (exceeded)"))
260            }
261            LimitCheckResult::TooShort { current, min } => Some(format!("{current}/{min} minimum")),
262        }
263    }
264    pub fn allows_field_switch(&self, text: &str) -> bool {
265        if let Some(min) = self.min_length {
266            let count = self.count(text);
267            // Allow switching if field is empty OR meets minimum requirement
268            count == 0 || count >= min
269        } else {
270            true // No minimum requirement, always allow switching
271        }
272    }
273
274    /// Get reason why field switching is not allowed (if any)
275    pub fn field_switch_block_reason(&self, text: &str) -> Option<String> {
276        if let Some(min) = self.min_length {
277            let count = self.count(text);
278            if count > 0 && count < min {
279                return Some(format!(
280                    "Field must be empty or have at least {min} characters (currently: {count})"
281                ));
282            }
283        }
284        None
285    }
286}
287
288pub fn count_text(text: &str, mode: CountMode) -> usize {
289    match mode {
290        CountMode::Characters => text.chars().count(),
291        CountMode::DisplayWidth => text.width(),
292        CountMode::Bytes => text.len(),
293    }
294}
295
296impl Default for CharacterLimits {
297    fn default() -> Self {
298        Self {
299            max_length: Some(30), // Default 30 character limit as specified
300            min_length: None,
301            warning_threshold: None,
302            count_mode: CountMode::default(),
303        }
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn test_character_limits_creation() {
313        let limits = CharacterLimits::new(10);
314        assert_eq!(limits.max_length(), Some(10));
315        assert_eq!(limits.min_length(), None);
316
317        let range_limits = CharacterLimits::new_range(5, 15);
318        assert_eq!(range_limits.min_length(), Some(5));
319        assert_eq!(range_limits.max_length(), Some(15));
320    }
321
322    #[test]
323    fn test_default_limits() {
324        let limits = CharacterLimits::default();
325        assert_eq!(limits.max_length(), Some(30));
326    }
327
328    #[test]
329    fn test_character_counting() {
330        let limits = CharacterLimits::new(5);
331
332        // Test character mode (default)
333        assert_eq!(limits.count("hello"), 5);
334        assert_eq!(limits.count("héllo"), 5); // Accented character counts as 1
335
336        // Test display width mode
337        let limits = limits.with_count_mode(CountMode::DisplayWidth);
338        assert_eq!(limits.count("hello"), 5);
339
340        // Test bytes mode
341        let limits = limits.with_count_mode(CountMode::Bytes);
342        assert_eq!(limits.count("hello"), 5);
343        assert_eq!(limits.count("héllo"), 6); // é takes 2 bytes in UTF-8
344    }
345
346    #[test]
347    fn test_insertion_validation() {
348        let limits = CharacterLimits::new(5);
349
350        // Valid insertion
351        let result = limits.validate_insertion("test", 4, 'x');
352        assert!(result.is_none()); // No validation issues
353
354        // Invalid insertion (would exceed limit)
355        let result = limits.validate_insertion("tests", 5, 'x');
356        assert!(result.is_some());
357        assert!(!result.unwrap().is_acceptable());
358    }
359
360    #[test]
361    fn test_content_validation() {
362        let limits = CharacterLimits::new_range(3, 10);
363
364        // Too short
365        let result = limits.validate_content("hi");
366        assert!(result.is_some());
367        assert!(result.unwrap().is_acceptable()); // Warning, not error
368
369        // Just right
370        let result = limits.validate_content("hello");
371        assert!(result.is_none());
372
373        // Too long
374        let result = limits.validate_content("hello world!");
375        assert!(result.is_some());
376        assert!(!result.unwrap().is_acceptable()); // Error
377    }
378
379    #[test]
380    fn test_warning_threshold() {
381        let limits = CharacterLimits::new(10).with_warning_threshold(8);
382
383        // Below warning threshold
384        let result = limits.validate_insertion("123456", 6, 'x');
385        assert!(result.is_none());
386
387        // At warning threshold
388        let result = limits.validate_insertion("1234567", 7, 'x');
389        assert!(result.is_some()); // This brings us to 8 chars
390        assert!(result.unwrap().is_acceptable()); // Warning, not error
391
392        let result = limits.validate_insertion("12345678", 8, 'x');
393        assert!(result.is_none());
394    }
395
396    #[test]
397    fn test_status_text() {
398        let limits = CharacterLimits::new(10);
399
400        assert_eq!(limits.status_text("hello"), Some("5/10".to_string()));
401
402        let limits = limits.with_warning_threshold(8);
403        assert_eq!(
404            limits.status_text("12345678"),
405            Some("8/10 (approaching limit)".to_string())
406        );
407        assert_eq!(
408            limits.status_text("1234567890x"),
409            Some("11/10 (exceeded)".to_string())
410        );
411    }
412
413    #[test]
414    fn test_field_switch_blocking() {
415        let limits = CharacterLimits::new_range(3, 10);
416
417        // Empty field: should allow switching
418        assert!(limits.allows_field_switch(""));
419        assert!(limits.field_switch_block_reason("").is_none());
420
421        // Field with content below minimum: should block switching
422        assert!(!limits.allows_field_switch("hi"));
423        assert!(limits.field_switch_block_reason("hi").is_some());
424        assert!(limits
425            .field_switch_block_reason("hi")
426            .unwrap()
427            .contains("at least 3 characters"));
428
429        // Field meeting minimum: should allow switching
430        assert!(limits.allows_field_switch("hello"));
431        assert!(limits.field_switch_block_reason("hello").is_none());
432
433        // Field exceeding maximum: should still allow switching (validation shows error but doesn't block)
434        assert!(limits.allows_field_switch("this is way too long"));
435        assert!(limits
436            .field_switch_block_reason("this is way too long")
437            .is_none());
438    }
439
440    #[test]
441    fn test_field_switch_no_minimum() {
442        let limits = CharacterLimits::new(10); // Only max, no minimum
443
444        // Should always allow switching when there's no minimum
445        assert!(limits.allows_field_switch(""));
446        assert!(limits.allows_field_switch("a"));
447        assert!(limits.allows_field_switch("hello"));
448
449        assert!(limits.field_switch_block_reason("").is_none());
450        assert!(limits.field_switch_block_reason("a").is_none());
451    }
452}