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
+ }
+}