diff --git a/Cargo.lock b/Cargo.lock index 72b66c7..5ecdb0b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -512,6 +512,7 @@ dependencies = [ "tracing", "tracing-subscriber", "unicode-width 0.2.0", + "validation-core", ] [[package]] @@ -3254,6 +3255,7 @@ dependencies = [ "tracing-subscriber", "unicode-width 0.2.0", "uuid", + "validation-core", "validator", ] @@ -4545,6 +4547,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "validation-core" +version = "0.6.7" +dependencies = [ + "regex", + "serde", + "thiserror 2.0.12", + "unicode-width 0.2.0", +] + [[package]] name = "validator" version = "0.20.0" diff --git a/Cargo.toml b/Cargo.toml index 85f0cd0..8016a74 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["client", "server", "common", "search", "canvas"] +members = ["client", "server", "common", "search", "canvas", "validation-core"] resolver = "2" [workspace.package] @@ -53,3 +53,4 @@ toml = "0.8.20" unicode-width = "0.2.0" common = { path = "./common" } +validation-core = { path = "./validation-core" } diff --git a/canvas b/canvas index d6e8ff5..e76cda8 160000 --- a/canvas +++ b/canvas @@ -1 +1 @@ -Subproject commit d6e8ff58d50893d27a5fd8845442b881ae6e4972 +Subproject commit e76cda8e0c0ef2ef3e59525ebfbe70a56ba16b71 diff --git a/validation-core/Cargo.toml b/validation-core/Cargo.toml new file mode 100644 index 0000000..d11d19b --- /dev/null +++ b/validation-core/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "validation-core" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +description = "Shared validation primitives, recipes, and package metadata." +repository.workspace = true + +[dependencies] +serde = { workspace = true } +thiserror = { workspace = true } +unicode-width = { workspace = true } +regex = { workspace = true, optional = true } + +[features] +default = [] +regex = ["dep:regex"] diff --git a/validation-core/docs/architecture/validation.md b/validation-core/docs/architecture/validation.md new file mode 100644 index 0000000..e2f0a39 --- /dev/null +++ b/validation-core/docs/architecture/validation.md @@ -0,0 +1,79 @@ +# Validation Architecture + +Validation is split into three ownership layers. + +```mermaid +flowchart LR + Server[server
stores simple settings
binds table fields
enforces writes] + Core[validation-core
owns meaning
resolves recipes
runs pure validation] + Canvas[canvas
editor integration
masking while typing
UI feedback] + Common[common/proto
wire format] + + Server --> Core + Canvas --> Core + Server --> Common + Canvas --> Common +``` + +## Rule + +`server` stores dumb, serializable settings. `validation-core` owns what those +settings mean. `canvas` uses the resolved result for editing behavior. + +```mermaid +flowchart TD + Settings[ValidationSettings
serializable data] + Recipe[ValidationRecipe
named reusable settings] + Package[ValidationPackage
distributable recipes] + Config[ValidationConfig
resolved runtime config] + Result[ValidationResult] + + Package --> Recipe + Recipe --> Settings + Settings --> Config + Config --> Result +``` + +## Current Data Flow + +```mermaid +sequenceDiagram + participant DB as server DB + participant Server as server + participant Core as validation-core + participant Client as client/canvas + + DB->>Server: stored field validation settings + Server->>Core: interpret shared validation primitives + Server->>Client: gRPC validation config + Client->>Core: resolve/use shared primitives + Client->>Client: canvas editing, masks, errors +``` + +## Future Package Flow + +```mermaid +flowchart LR + Registry[validation package registry] + Package[phone package] + Recipe[phone.e164 recipe] + Assignment[column assignment] + Stored[server stored settings
recipe ref + resolved config] + Runtime[server/canvas runtime] + + Registry --> Package + Package --> Recipe + Recipe --> Assignment + Assignment --> Stored + Stored --> Runtime +``` + +The server may store both the recipe reference and the resolved settings: + +```text +field customer_phone uses phone.e164@1.0.0 +resolved settings = {...} +``` + +That keeps package imports inspectable and versioned while preserving stable +backend enforcement even if a package changes later. diff --git a/validation-core/src/config.rs b/validation-core/src/config.rs new file mode 100644 index 0000000..c6d1fe5 --- /dev/null +++ b/validation-core/src/config.rs @@ -0,0 +1,226 @@ +use crate::rules::{ + CharacterFilter, CharacterLimits, DisplayMask, PatternFilters, PositionFilter, PositionRange, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AllowedValues { + pub values: Vec, + pub allow_empty: bool, + pub case_insensitive: bool, +} + +impl AllowedValues { + pub fn new(values: Vec) -> Self { + Self { + values, + allow_empty: true, + case_insensitive: false, + } + } + + pub fn allow_empty(mut self, allow_empty: bool) -> Self { + self.allow_empty = allow_empty; + self + } + + pub fn case_insensitive(mut self, case_insensitive: bool) -> Self { + self.case_insensitive = case_insensitive; + self + } + + pub fn matches(&self, text: &str) -> bool { + if self.case_insensitive { + self.values + .iter() + .any(|allowed| allowed.eq_ignore_ascii_case(text)) + } else { + self.values.iter().any(|allowed| allowed == text) + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FormatterSettings { + pub formatter_type: String, + pub options: Vec, + pub description: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FormatterOption { + pub key: String, + pub value: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum CharacterFilterSettings { + Alphabetic, + Numeric, + Alphanumeric, + Exact(char), + OneOf(Vec), +} + +impl CharacterFilterSettings { + pub fn resolve(&self) -> CharacterFilter { + match self { + Self::Alphabetic => CharacterFilter::Alphabetic, + Self::Numeric => CharacterFilter::Numeric, + Self::Alphanumeric => CharacterFilter::Alphanumeric, + Self::Exact(ch) => CharacterFilter::Exact(*ch), + Self::OneOf(chars) => CharacterFilter::OneOf(chars.clone()), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PositionFilterSettings { + pub positions: PositionRange, + pub filter: CharacterFilterSettings, +} + +impl PositionFilterSettings { + pub fn resolve(&self) -> PositionFilter { + PositionFilter::new(self.positions.clone(), self.filter.resolve()) + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct PatternSettings { + pub filters: Vec, + pub description: Option, +} + +impl PatternSettings { + pub fn resolve(&self) -> PatternFilters { + PatternFilters::new().add_filters( + self.filters + .iter() + .map(PositionFilterSettings::resolve) + .collect(), + ) + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ValidationSettings { + pub required: bool, + pub character_limits: Option, + pub pattern: Option, + pub allowed_values: Option, + pub display_mask: Option, + pub formatter: Option, + pub external_validation_enabled: bool, +} + +impl ValidationSettings { + pub fn resolve(&self) -> ValidationConfig { + ValidationConfig { + required: self.required, + character_limits: self.character_limits.clone(), + pattern_filters: self.pattern.as_ref().map(PatternSettings::resolve), + allowed_values: self.allowed_values.clone(), + display_mask: self.display_mask.clone(), + formatter: self.formatter.clone(), + external_validation_enabled: self.external_validation_enabled, + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct ValidationConfig { + pub required: bool, + pub character_limits: Option, + pub pattern_filters: Option, + pub allowed_values: Option, + pub display_mask: Option, + pub formatter: Option, + pub external_validation_enabled: bool, +} + +impl ValidationConfig { + pub fn validate_content(&self, text: &str) -> ValidationResult { + if text.is_empty() { + if self.required { + return ValidationResult::error("Value required"); + } + + if let Some(allowed_values) = &self.allowed_values { + if !allowed_values.allow_empty { + return ValidationResult::error("Empty value is not allowed"); + } + } + + return ValidationResult::Valid; + } + + if let Some(limits) = &self.character_limits { + if let Some(result) = limits.validate_content(text) { + if !result.is_acceptable() { + return result; + } + } + } + + if let Some(pattern_filters) = &self.pattern_filters { + if let Err(message) = pattern_filters.validate_text(text) { + return ValidationResult::error(message); + } + } + + if let Some(allowed_values) = &self.allowed_values { + if !allowed_values.matches(text) { + return ValidationResult::error("Value must be one of the allowed options"); + } + } + + ValidationResult::Valid + } + + pub fn has_validation(&self) -> bool { + self.required + || self.character_limits.is_some() + || self.pattern_filters.is_some() + || self.allowed_values.is_some() + || self.display_mask.is_some() + || self.formatter.is_some() + || self.external_validation_enabled + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ValidationResult { + Valid, + Warning { message: String }, + Error { message: String }, +} + +impl ValidationResult { + pub fn is_acceptable(&self) -> bool { + matches!(self, Self::Valid | Self::Warning { .. }) + } + + pub fn is_error(&self) -> bool { + matches!(self, Self::Error { .. }) + } + + pub fn message(&self) -> Option<&str> { + match self { + Self::Valid => None, + Self::Warning { message } | Self::Error { message } => Some(message), + } + } + + pub fn warning(message: impl Into) -> Self { + Self::Warning { + message: message.into(), + } + } + + pub fn error(message: impl Into) -> Self { + Self::Error { + message: message.into(), + } + } +} diff --git a/validation-core/src/lib.rs b/validation-core/src/lib.rs new file mode 100644 index 0000000..8b3e469 --- /dev/null +++ b/validation-core/src/lib.rs @@ -0,0 +1,16 @@ +pub mod config; +pub mod recipe; +pub mod rules; + +pub use config::{ + AllowedValues, CharacterFilterSettings, FormatterOption, FormatterSettings, PatternSettings, + PositionFilterSettings, ValidationConfig, ValidationResult, ValidationSettings, +}; +pub use recipe::{ + AppliedValidation, PackageId, PackageRequirement, RecipeId, RecipeReference, + ValidationPackage, ValidationRecipe, +}; +pub use rules::{ + count_text, CharacterFilter, CharacterLimits, CountMode, DisplayMask, LimitCheckResult, + MaskDisplayMode, PatternFilters, PositionFilter, PositionRange, +}; diff --git a/validation-core/src/recipe.rs b/validation-core/src/recipe.rs new file mode 100644 index 0000000..3231e43 --- /dev/null +++ b/validation-core/src/recipe.rs @@ -0,0 +1,63 @@ +use crate::{ValidationConfig, ValidationSettings}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct PackageId(pub String); + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct RecipeId(pub String); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ValidationRecipe { + pub id: RecipeId, + pub name: String, + pub description: Option, + pub settings: ValidationSettings, +} + +impl ValidationRecipe { + pub fn resolve(&self) -> ValidationConfig { + self.settings.resolve() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ValidationPackage { + pub id: PackageId, + pub name: String, + pub version: String, + pub description: Option, + pub recipes: Vec, + pub dependencies: Vec, +} + +impl ValidationPackage { + pub fn recipe(&self, id: &RecipeId) -> Option<&ValidationRecipe> { + self.recipes.iter().find(|recipe| &recipe.id == id) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PackageRequirement { + pub package_id: PackageId, + pub version_requirement: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecipeReference { + pub package_id: PackageId, + pub recipe_id: RecipeId, + pub version: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppliedValidation { + pub source: Option, + pub settings: ValidationSettings, +} + +impl AppliedValidation { + pub fn resolve(&self) -> ValidationConfig { + self.settings.resolve() + } +} diff --git a/validation-core/src/rules/character_limits.rs b/validation-core/src/rules/character_limits.rs new file mode 100644 index 0000000..5aa931b --- /dev/null +++ b/validation-core/src/rules/character_limits.rs @@ -0,0 +1,415 @@ +// src/validation/limits.rs +//! Character limits validation implementation + +use crate::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)] +#[derive(Default)] +pub enum CountMode { + /// Count actual characters (default) + #[default] + Characters, + + /// Count display width (useful for CJK characters) + DisplayWidth, + + /// Count bytes (rarely used, but available) + Bytes, +} + + +/// 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 mut new_text = String::with_capacity(current_text.len() + character.len_utf8()); + let mut chars = current_text.chars(); + + let clamped_pos = position.min(current_text.chars().count()); + for _ in 0..clamped_pos { + if let Some(ch) = chars.next() { + new_text.push(ch); + } + } + + new_text.push(character); + + for ch in chars { + new_text.push(ch); + } + + let new_count = self.count(&new_text); + let current_count = self.count(current_text); + + if let Some(max) = self.max_length { + if new_count > max { + return Some(ValidationResult::error(format!( + "Character limit exceeded: {new_count}/{max}" + ))); + } + + 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); + + if let Some(min) = self.min_length { + if count < min { + return Some(ValidationResult::warning(format!( + "Minimum length not met: {count}/{min}" + ))); + } + } + + if let Some(max) = self.max_length { + if count > max { + return Some(ValidationResult::error(format!( + "Character limit exceeded: {count}/{max}" + ))); + } + + 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); + + if let Some(max) = self.max_length { + if count > max { + return LimitCheckResult::Exceeded { current: count, max }; + } + + 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 + self.max_length.map(|max| format!("{}/{}", self.count(text), max)) + }, + LimitCheckResult::Warning { current, max } => { + Some(format!("{current}/{max} (approaching limit)")) + }, + LimitCheckResult::Exceeded { current, max } => { + Some(format!("{current}/{max} (exceeded)")) + }, + LimitCheckResult::TooShort { current, min } => { + Some(format!("{current}/{min} minimum")) + }, + } + } + 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 {min} characters (currently: {count})" + )); + } + } + None + } +} + +pub fn count_text(text: &str, mode: CountMode) -> usize { + match mode { + CountMode::Characters => text.chars().count(), + CountMode::DisplayWidth => text.width(), + CountMode::Bytes => text.len(), + } +} + +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("123456", 6, 'x'); + assert!(result.is_none()); + + // At warning threshold + let result = limits.validate_insertion("1234567", 7, 'x'); + assert!(result.is_some()); // This brings us to 8 chars + assert!(result.unwrap().is_acceptable()); // Warning, not error + + let result = limits.validate_insertion("12345678", 8, 'x'); + assert!(result.is_none()); + } + + #[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()); + } +} diff --git a/validation-core/src/rules/display_mask.rs b/validation-core/src/rules/display_mask.rs new file mode 100644 index 0000000..3db82c1 --- /dev/null +++ b/validation-core/src/rules/display_mask.rs @@ -0,0 +1,342 @@ +// src/validation/mask.rs +//! Pure display mask system - user-defined patterns only + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Default)] +pub enum MaskDisplayMode { + /// Only show separators as user types + /// Example: "" → "", "123" → "123", "12345" → "(123) 45" + #[default] + Dynamic, + + /// Show full template with placeholders from start + /// Example: "" → "(___) ___-____", "123" → "(123) ___-____" + Template { + /// Character to use as placeholder for empty input positions + placeholder: char + }, +} + + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DisplayMask { + /// Mask pattern like "##-##-####" where # = input position, others are visual separators + pattern: String, + /// Character used to represent input positions (usually '#') + input_char: char, + /// How to display the mask (dynamic vs template) + display_mode: MaskDisplayMode, +} + +impl DisplayMask { + /// Create a new display mask with dynamic mode (current behavior) + /// + /// # Arguments + /// * `pattern` - The mask pattern (e.g., "##-##-####", "(###) ###-####") + /// * `input_char` - Character representing input positions (usually '#') + /// + /// # Examples + /// ``` + /// use validation_core::DisplayMask; + /// + /// // Phone number format + /// let phone_mask = DisplayMask::new("(###) ###-####", '#'); + /// + /// // Date format + /// let date_mask = DisplayMask::new("##/##/####", '#'); + /// + /// // Custom business format + /// let employee_id = DisplayMask::new("EMP-####-##", '#'); + /// ``` + pub fn new(pattern: impl Into, input_char: char) -> Self { + Self { + pattern: pattern.into(), + input_char, + display_mode: MaskDisplayMode::Dynamic, + } + } + + /// Set the display mode for this mask + /// + /// # Examples + /// ``` + /// use validation_core::{DisplayMask, MaskDisplayMode}; + /// + /// let dynamic_mask = DisplayMask::new("##-##", '#') + /// .with_mode(MaskDisplayMode::Dynamic); + /// + /// let template_mask = DisplayMask::new("##-##", '#') + /// .with_mode(MaskDisplayMode::Template { placeholder: '_' }); + /// ``` + pub fn with_mode(mut self, mode: MaskDisplayMode) -> Self { + self.display_mode = mode; + self + } + + /// Set template mode with custom placeholder + /// + /// # Examples + /// ``` + /// use validation_core::DisplayMask; + /// + /// let phone_template = DisplayMask::new("(###) ###-####", '#') + /// .with_template('_'); // Shows "(___) ___-____" when empty + /// + /// let date_dots = DisplayMask::new("##/##/####", '#') + /// .with_template('•'); // Shows "••/••/••••" when empty + /// ``` + pub fn with_template(self, placeholder: char) -> Self { + self.with_mode(MaskDisplayMode::Template { placeholder }) + } + + /// Apply mask to raw input, showing visual separators and handling display mode + pub fn apply_to_display(&self, raw_input: &str) -> String { + match &self.display_mode { + MaskDisplayMode::Dynamic => self.apply_dynamic(raw_input), + MaskDisplayMode::Template { placeholder } => self.apply_template(raw_input, *placeholder), + } + } + + /// Dynamic mode - only show separators as user types + fn apply_dynamic(&self, raw_input: &str) -> String { + if raw_input.is_empty() { + return String::new(); + } + + let mut result = String::new(); + let mut raw_chars = raw_input.chars(); + + for pattern_char in self.pattern.chars() { + if pattern_char == self.input_char { + // Input position - take from raw input + if let Some(input_char) = raw_chars.next() { + result.push(input_char); + } else { + // No more input - stop here in dynamic mode + break; + } + } else { + // Visual separator - always show + result.push(pattern_char); + } + } + + // Append any remaining raw characters that don't fit the pattern + for remaining_char in raw_chars { + result.push(remaining_char); + } + + result + } + + /// Template mode - show full pattern with placeholders + fn apply_template(&self, raw_input: &str, placeholder: char) -> String { + let mut result = String::new(); + let mut raw_chars = raw_input.chars().peekable(); + + for pattern_char in self.pattern.chars() { + if pattern_char == self.input_char { + // Input position - take from raw input or use placeholder + if let Some(input_char) = raw_chars.next() { + result.push(input_char); + } else { + // No more input - use placeholder to show template + result.push(placeholder); + } + } else { + // Visual separator - always show in template mode + result.push(pattern_char); + } + } + + // In template mode, we don't append extra characters beyond the pattern + // This keeps the template consistent + result + } + + /// Check if a display position should accept cursor/input + pub fn is_input_position(&self, display_position: usize) -> bool { + self.pattern.chars() + .nth(display_position) + .map(|c| c == self.input_char) + .unwrap_or(true) // Beyond pattern = accept input + } + + /// Map display position to raw position + pub fn display_pos_to_raw_pos(&self, display_pos: usize) -> usize { + let mut raw_pos = 0; + + for (i, pattern_char) in self.pattern.chars().enumerate() { + if i >= display_pos { + break; + } + if pattern_char == self.input_char { + raw_pos += 1; + } + } + + raw_pos + } + + /// Map raw position to display position + pub fn raw_pos_to_display_pos(&self, raw_pos: usize) -> usize { + let mut input_positions_seen = 0; + + for (display_pos, pattern_char) in self.pattern.chars().enumerate() { + if pattern_char == self.input_char { + if input_positions_seen == raw_pos { + return display_pos; + } + input_positions_seen += 1; + } + } + + // Beyond pattern, return position after pattern + self.pattern.len() + (raw_pos - input_positions_seen) + } + + /// Find next input position at or after the given display position + pub fn next_input_position(&self, display_pos: usize) -> usize { + for (i, pattern_char) in self.pattern.chars().enumerate().skip(display_pos) { + if pattern_char == self.input_char { + return i; + } + } + // Beyond pattern = all positions are input positions + display_pos.max(self.pattern.len()) + } + + /// Find previous input position at or before the given display position + pub fn prev_input_position(&self, display_pos: usize) -> Option { + // Collect pattern chars with indices first, then search backwards + let pattern_chars: Vec<(usize, char)> = self.pattern.chars().enumerate().collect(); + + // Search backwards from display_pos + for &(i, pattern_char) in pattern_chars.iter().rev() { + if i <= display_pos && pattern_char == self.input_char { + return Some(i); + } + } + None + } + + /// Get the display mode + pub fn display_mode(&self) -> &MaskDisplayMode { + &self.display_mode + } + + /// Check if this mask uses template mode + pub fn is_template_mode(&self) -> bool { + matches!(self.display_mode, MaskDisplayMode::Template { .. }) + } + + /// Get the pattern string + pub fn pattern(&self) -> &str { + &self.pattern + } + + /// Get the position of the first input character in the pattern + pub fn first_input_position(&self) -> usize { + for (pos, ch) in self.pattern.chars().enumerate() { + if ch == self.input_char { + return pos; + } + } + 0 + } +} + +impl Default for DisplayMask { + fn default() -> Self { + Self::new("", '#') + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_user_defined_phone_mask() { + // User creates their own phone mask + let dynamic = DisplayMask::new("(###) ###-####", '#'); + let template = DisplayMask::new("(###) ###-####", '#').with_template('_'); + + // Dynamic mode + assert_eq!(dynamic.apply_to_display(""), ""); + assert_eq!(dynamic.apply_to_display("1234567890"), "(123) 456-7890"); + + // Template mode + assert_eq!(template.apply_to_display(""), "(___) ___-____"); + assert_eq!(template.apply_to_display("123"), "(123) ___-____"); + } + + #[test] + fn test_user_defined_date_mask() { + // User creates their own date formats + let us_date = DisplayMask::new("##/##/####", '#'); + let eu_date = DisplayMask::new("##.##.####", '#'); + let iso_date = DisplayMask::new("####-##-##", '#'); + + assert_eq!(us_date.apply_to_display("12252024"), "12/25/2024"); + assert_eq!(eu_date.apply_to_display("25122024"), "25.12.2024"); + assert_eq!(iso_date.apply_to_display("20241225"), "2024-12-25"); + } + + #[test] + fn test_user_defined_business_formats() { + // User creates custom business formats + let employee_id = DisplayMask::new("EMP-####-##", '#'); + let product_code = DisplayMask::new("###-###-###", '#'); + let invoice = DisplayMask::new("INV####/##", '#'); + + assert_eq!(employee_id.apply_to_display("123456"), "EMP-1234-56"); + assert_eq!(product_code.apply_to_display("123456789"), "123-456-789"); + assert_eq!(invoice.apply_to_display("123456"), "INV1234/56"); + } + + #[test] + fn test_custom_input_characters() { + // User can define their own input character + let mask_with_x = DisplayMask::new("XXX-XX-XXXX", 'X'); + let mask_with_hash = DisplayMask::new("###-##-####", '#'); + let mask_with_n = DisplayMask::new("NNN-NN-NNNN", 'N'); + + assert_eq!(mask_with_x.apply_to_display("123456789"), "123-45-6789"); + assert_eq!(mask_with_hash.apply_to_display("123456789"), "123-45-6789"); + assert_eq!(mask_with_n.apply_to_display("123456789"), "123-45-6789"); + } + + #[test] + fn test_custom_placeholders() { + // User can define custom placeholder characters + let underscores = DisplayMask::new("##-##", '#').with_template('_'); + let dots = DisplayMask::new("##-##", '#').with_template('•'); + let dashes = DisplayMask::new("##-##", '#').with_template('-'); + + assert_eq!(underscores.apply_to_display(""), "__-__"); + assert_eq!(dots.apply_to_display(""), "••-••"); + assert_eq!(dashes.apply_to_display(""), "-----"); // Note: dashes blend with separator + } + + #[test] + fn test_position_mapping_user_patterns() { + let custom = DisplayMask::new("ABC-###-XYZ", '#'); + + // Position mapping should work correctly with any pattern + assert_eq!(custom.raw_pos_to_display_pos(0), 4); // First # at position 4 + assert_eq!(custom.raw_pos_to_display_pos(1), 5); // Second # at position 5 + assert_eq!(custom.raw_pos_to_display_pos(2), 6); // Third # at position 6 + + assert_eq!(custom.display_pos_to_raw_pos(4), 0); // Position 4 -> first input + assert_eq!(custom.display_pos_to_raw_pos(5), 1); // Position 5 -> second input + assert_eq!(custom.display_pos_to_raw_pos(6), 2); // Position 6 -> third input + + assert!(!custom.is_input_position(0)); // A + assert!(!custom.is_input_position(3)); // - + assert!(custom.is_input_position(4)); // # + assert!(!custom.is_input_position(8)); // Y + } +} diff --git a/validation-core/src/rules/mod.rs b/validation-core/src/rules/mod.rs new file mode 100644 index 0000000..b5920b4 --- /dev/null +++ b/validation-core/src/rules/mod.rs @@ -0,0 +1,7 @@ +pub mod character_limits; +pub mod display_mask; +pub mod pattern_rules; + +pub use character_limits::{count_text, CharacterLimits, CountMode, LimitCheckResult}; +pub use display_mask::{DisplayMask, MaskDisplayMode}; +pub use pattern_rules::{CharacterFilter, PatternFilters, PositionFilter, PositionRange}; diff --git a/validation-core/src/rules/pattern_rules.rs b/validation-core/src/rules/pattern_rules.rs new file mode 100644 index 0000000..07ae225 --- /dev/null +++ b/validation-core/src/rules/pattern_rules.rs @@ -0,0 +1,324 @@ +// 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), +} + +/// 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), + /// Custom user-defined filter function + Custom(Arc 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()"), + } + } +} + +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 { + 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 { + 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, +} + +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) -> 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 + } +}