validation core as a dependency
This commit is contained in:
12
Cargo.lock
generated
12
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
2
canvas
2
canvas
Submodule canvas updated: d6e8ff58d5...e76cda8e0c
18
validation-core/Cargo.toml
Normal file
18
validation-core/Cargo.toml
Normal file
@@ -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"]
|
||||
79
validation-core/docs/architecture/validation.md
Normal file
79
validation-core/docs/architecture/validation.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Validation Architecture
|
||||
|
||||
Validation is split into three ownership layers.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Server[server<br/>stores simple settings<br/>binds table fields<br/>enforces writes]
|
||||
Core[validation-core<br/>owns meaning<br/>resolves recipes<br/>runs pure validation]
|
||||
Canvas[canvas<br/>editor integration<br/>masking while typing<br/>UI feedback]
|
||||
Common[common/proto<br/>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<br/>serializable data]
|
||||
Recipe[ValidationRecipe<br/>named reusable settings]
|
||||
Package[ValidationPackage<br/>distributable recipes]
|
||||
Config[ValidationConfig<br/>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<br/>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.
|
||||
226
validation-core/src/config.rs
Normal file
226
validation-core/src/config.rs
Normal file
@@ -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<String>,
|
||||
pub allow_empty: bool,
|
||||
pub case_insensitive: bool,
|
||||
}
|
||||
|
||||
impl AllowedValues {
|
||||
pub fn new(values: Vec<String>) -> 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<FormatterOption>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[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<char>),
|
||||
}
|
||||
|
||||
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<PositionFilterSettings>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
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<CharacterLimits>,
|
||||
pub pattern: Option<PatternSettings>,
|
||||
pub allowed_values: Option<AllowedValues>,
|
||||
pub display_mask: Option<DisplayMask>,
|
||||
pub formatter: Option<FormatterSettings>,
|
||||
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<CharacterLimits>,
|
||||
pub pattern_filters: Option<PatternFilters>,
|
||||
pub allowed_values: Option<AllowedValues>,
|
||||
pub display_mask: Option<DisplayMask>,
|
||||
pub formatter: Option<FormatterSettings>,
|
||||
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<String>) -> Self {
|
||||
Self::Warning {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(message: impl Into<String>) -> Self {
|
||||
Self::Error {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
16
validation-core/src/lib.rs
Normal file
16
validation-core/src/lib.rs
Normal file
@@ -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,
|
||||
};
|
||||
63
validation-core/src/recipe.rs
Normal file
63
validation-core/src/recipe.rs
Normal file
@@ -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<String>,
|
||||
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<String>,
|
||||
pub recipes: Vec<ValidationRecipe>,
|
||||
pub dependencies: Vec<PackageRequirement>,
|
||||
}
|
||||
|
||||
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<RecipeReference>,
|
||||
pub settings: ValidationSettings,
|
||||
}
|
||||
|
||||
impl AppliedValidation {
|
||||
pub fn resolve(&self) -> ValidationConfig {
|
||||
self.settings.resolve()
|
||||
}
|
||||
}
|
||||
415
validation-core/src/rules/character_limits.rs
Normal file
415
validation-core/src/rules/character_limits.rs
Normal file
@@ -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<usize>,
|
||||
|
||||
/// Minimum number of characters required (None = no minimum)
|
||||
min_length: Option<usize>,
|
||||
|
||||
/// Warning threshold (warn when approaching max limit)
|
||||
warning_threshold: Option<usize>,
|
||||
|
||||
/// 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<usize> {
|
||||
self.max_length
|
||||
}
|
||||
|
||||
/// Get minimum length
|
||||
pub fn min_length(&self) -> Option<usize> {
|
||||
self.min_length
|
||||
}
|
||||
|
||||
/// Get warning threshold
|
||||
pub fn warning_threshold(&self) -> Option<usize> {
|
||||
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<ValidationResult> {
|
||||
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<ValidationResult> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
342
validation-core/src/rules/display_mask.rs
Normal file
342
validation-core/src/rules/display_mask.rs
Normal file
@@ -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<String>, 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<usize> {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
7
validation-core/src/rules/mod.rs
Normal file
7
validation-core/src/rules/mod.rs
Normal file
@@ -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};
|
||||
324
validation-core/src/rules/pattern_rules.rs
Normal file
324
validation-core/src/rules/pattern_rules.rs
Normal file
@@ -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<usize>),
|
||||
}
|
||||
|
||||
/// 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<char>),
|
||||
/// Custom user-defined filter function
|
||||
Custom(Arc<dyn Fn(char) -> 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(<function>)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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<usize> {
|
||||
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<String> {
|
||||
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<PositionFilter>,
|
||||
}
|
||||
|
||||
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<PositionFilter>) -> 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user