// src/validation/limits.rs //! Character limits validation implementation use crate::validation::ValidationResult; use serde::{Deserialize, Serialize}; use unicode_width::UnicodeWidthStr; /// Character limits configuration for a field #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CharacterLimits { /// Maximum number of characters allowed (None = unlimited) max_length: Option, /// Minimum number of characters required (None = no minimum) min_length: Option, /// Warning threshold (warn when approaching max limit) warning_threshold: Option, /// Count mode: characters vs display width count_mode: CountMode, } /// How to count characters for limit checking #[derive(Debug, Clone, Copy, Serialize, Deserialize)] pub enum CountMode { /// Count actual characters (default) Characters, /// Count display width (useful for CJK characters) DisplayWidth, /// Count bytes (rarely used, but available) Bytes, } impl Default for CountMode { fn default() -> Self { CountMode::Characters } } /// Result of a character limit check #[derive(Debug, Clone, PartialEq, Eq)] pub enum LimitCheckResult { /// Within limits Ok, /// Approaching limit (warning) Warning { current: usize, max: usize }, /// At or exceeding limit (error) Exceeded { current: usize, max: usize }, /// Below minimum length TooShort { current: usize, min: usize }, } impl CharacterLimits { /// Create new character limits with just max length pub fn new(max_length: usize) -> Self { Self { max_length: Some(max_length), min_length: None, warning_threshold: None, count_mode: CountMode::default(), } } /// Create new character limits with min and max pub fn new_range(min_length: usize, max_length: usize) -> Self { Self { max_length: Some(max_length), min_length: Some(min_length), warning_threshold: None, count_mode: CountMode::default(), } } /// Set warning threshold (when to show warning before hitting limit) pub fn with_warning_threshold(mut self, threshold: usize) -> Self { self.warning_threshold = Some(threshold); self } /// Set count mode (characters vs display width vs bytes) pub fn with_count_mode(mut self, mode: CountMode) -> Self { self.count_mode = mode; self } /// Get maximum length pub fn max_length(&self) -> Option { self.max_length } /// Get minimum length pub fn min_length(&self) -> Option { self.min_length } /// Get warning threshold pub fn warning_threshold(&self) -> Option { self.warning_threshold } /// Get count mode pub fn count_mode(&self) -> CountMode { self.count_mode } /// Count characters/width/bytes according to the configured mode fn count(&self, text: &str) -> usize { match self.count_mode { CountMode::Characters => text.chars().count(), CountMode::DisplayWidth => text.width(), CountMode::Bytes => text.len(), } } /// Check if inserting a character would exceed limits pub fn validate_insertion( &self, current_text: &str, _position: usize, character: char, ) -> Option { let current_count = self.count(current_text); let char_count = match self.count_mode { CountMode::Characters => 1, CountMode::DisplayWidth => { let char_str = character.to_string(); char_str.width() }, CountMode::Bytes => character.len_utf8(), }; let new_count = current_count + char_count; // Check max length if let Some(max) = self.max_length { if new_count > max { return Some(ValidationResult::error(format!( "Character limit exceeded: {}/{}", new_count, max ))); } // Check warning threshold if let Some(warning_threshold) = self.warning_threshold { if new_count >= warning_threshold && current_count < warning_threshold { return Some(ValidationResult::warning(format!( "Approaching character limit: {}/{}", new_count, max ))); } } } None // No validation issues } /// Validate the current content pub fn validate_content(&self, text: &str) -> Option { let count = self.count(text); // Check minimum length if let Some(min) = self.min_length { if count < min { return Some(ValidationResult::warning(format!( "Minimum length not met: {}/{}", count, min ))); } } // Check maximum length if let Some(max) = self.max_length { if count > max { return Some(ValidationResult::error(format!( "Character limit exceeded: {}/{}", count, max ))); } // Check warning threshold if let Some(warning_threshold) = self.warning_threshold { if count >= warning_threshold { return Some(ValidationResult::warning(format!( "Approaching character limit: {}/{}", count, max ))); } } } None // No validation issues } /// Get the current status of the text against limits pub fn check_limits(&self, text: &str) -> LimitCheckResult { let count = self.count(text); // Check max length first if let Some(max) = self.max_length { if count > max { return LimitCheckResult::Exceeded { current: count, max }; } // Check warning threshold if let Some(warning_threshold) = self.warning_threshold { if count >= warning_threshold { return LimitCheckResult::Warning { current: count, max }; } } } // Check min length if let Some(min) = self.min_length { if count < min { return LimitCheckResult::TooShort { current: count, min }; } } LimitCheckResult::Ok } /// Get a human-readable status string pub fn status_text(&self, text: &str) -> Option { match self.check_limits(text) { LimitCheckResult::Ok => { // Show current/max if we have a max limit if let Some(max) = self.max_length { Some(format!("{}/{}", self.count(text), max)) } else { None } }, LimitCheckResult::Warning { current, max } => { Some(format!("{}/{} (approaching limit)", current, max)) }, LimitCheckResult::Exceeded { current, max } => { Some(format!("{}/{} (exceeded)", current, max)) }, LimitCheckResult::TooShort { current, min } => { Some(format!("{}/{} minimum", current, min)) }, } } pub fn allows_field_switch(&self, text: &str) -> bool { if let Some(min) = self.min_length { let count = self.count(text); // Allow switching if field is empty OR meets minimum requirement count == 0 || count >= min } else { true // No minimum requirement, always allow switching } } /// Get reason why field switching is not allowed (if any) pub fn field_switch_block_reason(&self, text: &str) -> Option { if let Some(min) = self.min_length { let count = self.count(text); if count > 0 && count < min { return Some(format!( "Field must be empty or have at least {} characters (currently: {})", min, count )); } } None } } impl Default for CharacterLimits { fn default() -> Self { Self { max_length: Some(30), // Default 30 character limit as specified min_length: None, warning_threshold: None, count_mode: CountMode::default(), } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_character_limits_creation() { let limits = CharacterLimits::new(10); assert_eq!(limits.max_length(), Some(10)); assert_eq!(limits.min_length(), None); let range_limits = CharacterLimits::new_range(5, 15); assert_eq!(range_limits.min_length(), Some(5)); assert_eq!(range_limits.max_length(), Some(15)); } #[test] fn test_default_limits() { let limits = CharacterLimits::default(); assert_eq!(limits.max_length(), Some(30)); } #[test] fn test_character_counting() { let limits = CharacterLimits::new(5); // Test character mode (default) assert_eq!(limits.count("hello"), 5); assert_eq!(limits.count("héllo"), 5); // Accented character counts as 1 // Test display width mode let limits = limits.with_count_mode(CountMode::DisplayWidth); assert_eq!(limits.count("hello"), 5); // Test bytes mode let limits = limits.with_count_mode(CountMode::Bytes); assert_eq!(limits.count("hello"), 5); assert_eq!(limits.count("héllo"), 6); // é takes 2 bytes in UTF-8 } #[test] fn test_insertion_validation() { let limits = CharacterLimits::new(5); // Valid insertion let result = limits.validate_insertion("test", 4, 'x'); assert!(result.is_none()); // No validation issues // Invalid insertion (would exceed limit) let result = limits.validate_insertion("tests", 5, 'x'); assert!(result.is_some()); assert!(!result.unwrap().is_acceptable()); } #[test] fn test_content_validation() { let limits = CharacterLimits::new_range(3, 10); // Too short let result = limits.validate_content("hi"); assert!(result.is_some()); assert!(result.unwrap().is_acceptable()); // Warning, not error // Just right let result = limits.validate_content("hello"); assert!(result.is_none()); // Too long let result = limits.validate_content("hello world!"); assert!(result.is_some()); assert!(!result.unwrap().is_acceptable()); // Error } #[test] fn test_warning_threshold() { let limits = CharacterLimits::new(10).with_warning_threshold(8); // Below warning threshold let result = limits.validate_insertion("1234567", 7, 'x'); assert!(result.is_none()); // At warning threshold let result = limits.validate_insertion("1234567", 7, 'x'); assert!(result.is_none()); // This brings us to 8 chars let result = limits.validate_insertion("12345678", 8, 'x'); assert!(result.is_some()); assert!(result.unwrap().is_acceptable()); // Warning, not error } #[test] fn test_status_text() { let limits = CharacterLimits::new(10); assert_eq!(limits.status_text("hello"), Some("5/10".to_string())); let limits = limits.with_warning_threshold(8); assert_eq!(limits.status_text("12345678"), Some("8/10 (approaching limit)".to_string())); assert_eq!(limits.status_text("1234567890x"), Some("11/10 (exceeded)".to_string())); } #[test] fn test_field_switch_blocking() { let limits = CharacterLimits::new_range(3, 10); // Empty field: should allow switching assert!(limits.allows_field_switch("")); assert!(limits.field_switch_block_reason("").is_none()); // Field with content below minimum: should block switching assert!(!limits.allows_field_switch("hi")); assert!(limits.field_switch_block_reason("hi").is_some()); assert!(limits.field_switch_block_reason("hi").unwrap().contains("at least 3 characters")); // Field meeting minimum: should allow switching assert!(limits.allows_field_switch("hello")); assert!(limits.field_switch_block_reason("hello").is_none()); // Field exceeding maximum: should still allow switching (validation shows error but doesn't block) assert!(limits.allows_field_switch("this is way too long")); assert!(limits.field_switch_block_reason("this is way too long").is_none()); } #[test] fn test_field_switch_no_minimum() { let limits = CharacterLimits::new(10); // Only max, no minimum // Should always allow switching when there's no minimum assert!(limits.allows_field_switch("")); assert!(limits.allows_field_switch("a")); assert!(limits.allows_field_switch("hello")); assert!(limits.field_switch_block_reason("").is_none()); assert!(limits.field_switch_block_reason("a").is_none()); } }