oxedyne/fe2o3/fe2o3_datime/src/validation/rules.rs
19.2 KiB, 70 runs
created by r1870400018:6403, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! Validation rules, and a library of ready-made ones. |
| 2 | //! |
| 3 | //! A rule is a name and up to three closures, one each for a CalClock, a |
| 4 | //! CalendarDate and a ClockTime. Rules combine with all_rules and any_rule. |
| 5 | //! |
| 6 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 7 | //! Anthropic Claude |
| 8 | |
| 9 | use crate::{ |
| 10 | calendar::{Calendar, CalendarDate}, |
| 11 | clock::ClockTime, |
| 12 | time::CalClock, |
| 13 | validation::{ValidationError, ValidationResult}, |
| 14 | }; |
| 15 | |
| 16 | use oxedyne_fe2o3_core::prelude::*; |
| 17 | |
| 18 | use std::{ |
| 19 | collections::HashSet, |
| 20 | time::{SystemTime, UNIX_EPOCH}, |
| 21 | }; |
| 22 | |
| 23 | /// # Examples |
| 24 | /// |
| 25 | /// ```ignore |
| 26 | /// use oxedyne_fe2o3_datime::{ |
| 27 | /// validation::{ValidationRule, CalClockValidator}, |
| 28 | /// time::CalClock, |
| 29 | /// }res!(); |
| 30 | /// |
| 31 | /// // Create a rule that only allows business hours |
| 32 | /// let business_hours_rule = ValidationRule::new("business_hours") |
| 33 | /// .with_time_validator(|time| { |
| 34 | /// let hour = time.hour().of()res!(); |
| 35 | /// if hour >= 9 && hour < 17 { |
| 36 | /// Ok(()) |
| 37 | /// } else { |
| 38 | /// Err(vec![ValidationError::new( |
| 39 | /// "business_hours", |
| 40 | /// "Time must be during business hours (9 AM - 5 PM)" |
| 41 | /// )]) |
| 42 | /// } |
| 43 | /// })res!(); |
| 44 | /// |
| 45 | /// let mut validator = CalClockValidator::new()res!(); |
| 46 | /// validator.add_rule(business_hours_rule)res!(); |
| 47 | /// ``` |
| 48 | pub struct ValidationRule { |
| 49 | name: String, |
| 50 | description: Option<String>, |
| 51 | // A rule may carry any combination of the three, and only the ones set |
| 52 | // are consulted. |
| 53 | calclock_validator: Option<Box<dyn Fn(&CalClock) -> ValidationResult + Send + Sync>>, |
| 54 | date_validator: Option<Box<dyn Fn(&CalendarDate) -> ValidationResult + Send + Sync>>, |
| 55 | time_validator: Option<Box<dyn Fn(&ClockTime) -> ValidationResult + Send + Sync>>, |
| 56 | } |
| 57 | |
| 58 | impl std::fmt::Debug for ValidationRule { |
| 59 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 60 | f.debug_struct("ValidationRule") |
| 61 | .field("name", &self.name) |
| 62 | .field("description", &self.description) |
| 63 | .field("has_calclock_validator", &self.calclock_validator.is_some()) |
| 64 | .field("has_date_validator", &self.date_validator.is_some()) |
| 65 | .field("has_time_validator", &self.time_validator.is_some()) |
| 66 | .finish() |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | impl ValidationRule { |
| 71 | pub fn new<S: Into<String>>(name: S) -> Self { |
| 72 | Self { |
| 73 | name: name.into(), |
| 74 | description: None, |
| 75 | calclock_validator: None, |
| 76 | date_validator: None, |
| 77 | time_validator: None, |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | pub fn description<S: Into<String>>(mut self, description: S) -> Self { |
| 82 | self.description = Some(description.into()); |
| 83 | self |
| 84 | } |
| 85 | |
| 86 | pub fn with_calclock_validator<F>(mut self, validator: F) -> Self |
| 87 | where |
| 88 | F: Fn(&CalClock) -> ValidationResult + Send + Sync + 'static, |
| 89 | { |
| 90 | self.calclock_validator = Some(Box::new(validator)); |
| 91 | self |
| 92 | } |
| 93 | |
| 94 | pub fn with_date_validator<F>(mut self, validator: F) -> Self |
| 95 | where |
| 96 | F: Fn(&CalendarDate) -> ValidationResult + Send + Sync + 'static, |
| 97 | { |
| 98 | self.date_validator = Some(Box::new(validator)); |
| 99 | self |
| 100 | } |
| 101 | |
| 102 | pub fn with_time_validator<F>(mut self, validator: F) -> Self |
| 103 | where |
| 104 | F: Fn(&ClockTime) -> ValidationResult + Send + Sync + 'static, |
| 105 | { |
| 106 | self.time_validator = Some(Box::new(validator)); |
| 107 | self |
| 108 | } |
| 109 | |
| 110 | pub fn name(&self) -> &str { |
| 111 | &self.name |
| 112 | } |
| 113 | |
| 114 | pub fn get_description(&self) -> Option<&str> { |
| 115 | self.description.as_deref() |
| 116 | } |
| 117 | |
| 118 | pub fn validate_calclock(&self, calclock: &CalClock) -> ValidationResult { |
| 119 | let mut errors = Vec::new(); |
| 120 | |
| 121 | // Check calclock-specific validator |
| 122 | if let Some(ref validator) = self.calclock_validator { |
| 123 | if let Err(mut errs) = validator(calclock) { |
| 124 | errors.append(&mut errs); |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | // Also check date validator on the date component |
| 129 | if let Some(ref validator) = self.date_validator { |
| 130 | if let Err(mut errs) = validator(&calclock.date()) { |
| 131 | errors.append(&mut errs); |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | // Also check time validator on the time component |
| 136 | if let Some(ref validator) = self.time_validator { |
| 137 | if let Err(mut errs) = validator(&calclock.time()) { |
| 138 | errors.append(&mut errs); |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | if errors.is_empty() { |
| 143 | Ok(()) |
| 144 | } else { |
| 145 | Err(errors) |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | pub fn validate_date(&self, date: &CalendarDate) -> ValidationResult { |
| 150 | if let Some(ref validator) = self.date_validator { |
| 151 | validator(date) |
| 152 | } else { |
| 153 | Ok(()) |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | pub fn validate_time(&self, time: &ClockTime) -> ValidationResult { |
| 158 | if let Some(ref validator) = self.time_validator { |
| 159 | validator(time) |
| 160 | } else { |
| 161 | Ok(()) |
| 162 | } |
| 163 | } |
| 164 | } |
| 165 | |
| 166 | pub struct ValidationRules; |
| 167 | |
| 168 | impl ValidationRules { |
| 169 | /// 9 AM to 5 PM, Monday to Friday. |
| 170 | pub fn business_hours() -> ValidationRule { |
| 171 | ValidationRule::new("business_hours") |
| 172 | .description("Only allows times during standard business hours (9 AM - 5 PM, Monday-Friday)") |
| 173 | .with_calclock_validator(|calclock| { |
| 174 | // Check if it's a weekday |
| 175 | if !calclock.date().is_business_day() { |
| 176 | return Err(vec![ValidationError::new( |
| 177 | "business_hours", |
| 178 | "Date must be a business day (Monday-Friday)" |
| 179 | )]); |
| 180 | } |
| 181 | |
| 182 | // Check if time is within business hours |
| 183 | let hour = calclock.time().hour().of(); |
| 184 | if hour < 9 || hour >= 17 { |
| 185 | return Err(vec![ValidationError::new( |
| 186 | "business_hours", |
| 187 | "Time must be during business hours (9 AM - 5 PM)" |
| 188 | ).field("hour") |
| 189 | .value(hour.to_string()) |
| 190 | .expected("9 to 16")]); |
| 191 | } |
| 192 | |
| 193 | Ok(()) |
| 194 | }) |
| 195 | } |
| 196 | |
| 197 | pub fn weekends_only() -> ValidationRule { |
| 198 | ValidationRule::new("weekends_only") |
| 199 | .description("Only allows weekend dates (Saturday and Sunday)") |
| 200 | .with_date_validator(|date| { |
| 201 | if !date.is_weekend() { |
| 202 | Err(vec![ValidationError::new( |
| 203 | "weekends_only", |
| 204 | "Date must be a weekend (Saturday or Sunday)" |
| 205 | ).field("day_of_week") |
| 206 | .value(date.day_of_week().long_name().to_string()) |
| 207 | .expected("Saturday or Sunday")]) |
| 208 | } else { |
| 209 | Ok(()) |
| 210 | } |
| 211 | }) |
| 212 | } |
| 213 | |
| 214 | pub fn no_holidays() -> ValidationRule { |
| 215 | ValidationRule::new("no_holidays") |
| 216 | .description("Excludes common holidays") |
| 217 | .with_date_validator(|date| { |
| 218 | // Simple holiday check - this should be expanded based on requirements |
| 219 | let is_common_holiday = (date.month() == 12 && date.day() == 25) || // Christmas |
| 220 | (date.month() == 1 && date.day() == 1) || // New Year |
| 221 | (date.month() == 7 && date.day() == 4); // Independence Day (US) |
| 222 | |
| 223 | if is_common_holiday { |
| 224 | Err(vec![ValidationError::new( |
| 225 | "no_holidays", |
| 226 | "Date cannot be a holiday" |
| 227 | ).field("date") |
| 228 | .value(date.to_string()) |
| 229 | .expected("Non-holiday date")]) |
| 230 | } else { |
| 231 | Ok(()) |
| 232 | } |
| 233 | }) |
| 234 | } |
| 235 | |
| 236 | pub fn allowed_weekdays(allowed_days: HashSet<crate::constant::DayOfWeek>) -> ValidationRule { |
| 237 | ValidationRule::new("allowed_weekdays") |
| 238 | .description("Only allows specific days of the week") |
| 239 | .with_date_validator(move |date| { |
| 240 | if !allowed_days.contains(&date.day_of_week()) { |
| 241 | let allowed_names: Vec<String> = allowed_days |
| 242 | .iter() |
| 243 | .map(|day| day.long_name().to_string()) |
| 244 | .collect(); |
| 245 | |
| 246 | Err(vec![ValidationError::new( |
| 247 | "allowed_weekdays", |
| 248 | "Date must be one of the allowed weekdays" |
| 249 | ).field("day_of_week") |
| 250 | .value(date.day_of_week().long_name().to_string()) |
| 251 | .expected(allowed_names.join(", "))]) |
| 252 | } else { |
| 253 | Ok(()) |
| 254 | } |
| 255 | }) |
| 256 | } |
| 257 | |
| 258 | /// Both hours are inclusive. |
| 259 | pub fn hour_range(min_hour: u8, max_hour: u8) -> ValidationRule { |
| 260 | ValidationRule::new("hour_range") |
| 261 | .description(format!("Only allows times between {} and {} hours", min_hour, max_hour)) |
| 262 | .with_time_validator(move |time| { |
| 263 | let hour = time.hour().of(); |
| 264 | if hour < min_hour || hour > max_hour { |
| 265 | Err(vec![ValidationError::new( |
| 266 | "hour_range", |
| 267 | "Hour must be within allowed range" |
| 268 | ).field("hour") |
| 269 | .value(hour.to_string()) |
| 270 | .expected(format!("{} to {}", min_hour, max_hour))]) |
| 271 | } else { |
| 272 | Ok(()) |
| 273 | } |
| 274 | }) |
| 275 | } |
| 276 | |
| 277 | pub fn min_date(min_date: CalendarDate) -> ValidationRule { |
| 278 | ValidationRule::new("min_date") |
| 279 | .description(format!("Only allows dates on or after {}", min_date)) |
| 280 | .with_date_validator(move |date| { |
| 281 | if date < &min_date { |
| 282 | Err(vec![ValidationError::new( |
| 283 | "min_date", |
| 284 | "Date must be on or after minimum date" |
| 285 | ).field("date") |
| 286 | .value(date.to_string()) |
| 287 | .expected(format!("{} or later", min_date))]) |
| 288 | } else { |
| 289 | Ok(()) |
| 290 | } |
| 291 | }) |
| 292 | } |
| 293 | |
| 294 | pub fn max_date(max_date: CalendarDate) -> ValidationRule { |
| 295 | ValidationRule::new("max_date") |
| 296 | .description(format!("Only allows dates on or before {}", max_date)) |
| 297 | .with_date_validator(move |date| { |
| 298 | if date > &max_date { |
| 299 | Err(vec![ValidationError::new( |
| 300 | "max_date", |
| 301 | "Date must be on or before maximum date" |
| 302 | ).field("date") |
| 303 | .value(date.to_string()) |
| 304 | .expected(format!("{} or earlier", max_date))]) |
| 305 | } else { |
| 306 | Ok(()) |
| 307 | } |
| 308 | }) |
| 309 | } |
| 310 | |
| 311 | /// Both years are inclusive. |
| 312 | pub fn year_range(min_year: i32, max_year: i32) -> ValidationRule { |
| 313 | ValidationRule::new("year_range") |
| 314 | .description(format!("Only allows years between {} and {}", min_year, max_year)) |
| 315 | .with_date_validator(move |date| { |
| 316 | let year = date.year(); |
| 317 | if year < min_year || year > max_year { |
| 318 | Err(vec![ValidationError::new( |
| 319 | "year_range", |
| 320 | "Year must be within allowed range" |
| 321 | ).field("year") |
| 322 | .value(year.to_string()) |
| 323 | .expected(format!("{} to {}", min_year, max_year))]) |
| 324 | } else { |
| 325 | Ok(()) |
| 326 | } |
| 327 | }) |
| 328 | } |
| 329 | |
| 330 | pub fn allowed_months(allowed_months: HashSet<crate::constant::MonthOfYear>) -> ValidationRule { |
| 331 | ValidationRule::new("allowed_months") |
| 332 | .description("Only allows specific months") |
| 333 | .with_date_validator(move |date| { |
| 334 | if !allowed_months.contains(&date.month_of_year()) { |
| 335 | let allowed_names: Vec<String> = allowed_months |
| 336 | .iter() |
| 337 | .map(|month| month.long_name().to_string()) |
| 338 | .collect(); |
| 339 | |
| 340 | Err(vec![ValidationError::new( |
| 341 | "allowed_months", |
| 342 | "Month must be one of the allowed months" |
| 343 | ).field("month") |
| 344 | .value(date.month_of_year().long_name().to_string()) |
| 345 | .expected(allowed_names.join(", "))]) |
| 346 | } else { |
| 347 | Ok(()) |
| 348 | } |
| 349 | }) |
| 350 | } |
| 351 | |
| 352 | pub fn whole_seconds_only() -> ValidationRule { |
| 353 | ValidationRule::new("whole_seconds_only") |
| 354 | .description("Only allows times with zero nanoseconds (whole seconds)") |
| 355 | .with_time_validator(|time| { |
| 356 | if time.nanosecond().of() != 0 { |
| 357 | Err(vec![ValidationError::new( |
| 358 | "whole_seconds_only", |
| 359 | "Time must have zero nanoseconds (whole seconds only)" |
| 360 | ).field("nanosecond") |
| 361 | .value(time.nanosecond().of().to_string()) |
| 362 | .expected("0")]) |
| 363 | } else { |
| 364 | Ok(()) |
| 365 | } |
| 366 | }) |
| 367 | } |
| 368 | |
| 369 | pub fn whole_minutes_only() -> ValidationRule { |
| 370 | ValidationRule::new("whole_minutes_only") |
| 371 | .description("Only allows times with zero seconds and nanoseconds (whole minutes)") |
| 372 | .with_time_validator(|time| { |
| 373 | if time.second().of() != 0 || time.nanosecond().of() != 0 { |
| 374 | Err(vec![ValidationError::new( |
| 375 | "whole_minutes_only", |
| 376 | "Time must have zero seconds and nanoseconds (whole minutes only)" |
| 377 | ).field("time_precision") |
| 378 | .value(format!("{}s {}ns", time.second().of(), time.nanosecond().of())) |
| 379 | .expected("0s 0ns")]) |
| 380 | } else { |
| 381 | Ok(()) |
| 382 | } |
| 383 | }) |
| 384 | } |
| 385 | |
| 386 | fn get_current_year() -> i32 { |
| 387 | let now = SystemTime::now(); |
| 388 | match now.duration_since(UNIX_EPOCH) { |
| 389 | Ok(duration) => { |
| 390 | // Convert seconds since Unix epoch to current year |
| 391 | // Approximate: 365.25 days per year, 86400 seconds per day |
| 392 | let years_since_1970 = duration.as_secs() / (365 * 24 * 60 * 60); |
| 393 | 1970 + years_since_1970 as i32 |
| 394 | }, |
| 395 | Err(_) => { |
| 396 | // Fallback to a reasonable default if system time is before Unix epoch |
| 397 | 2024 |
| 398 | } |
| 399 | } |
| 400 | } |
| 401 | |
| 402 | pub fn not_too_old(max_years_ago: u32) -> ValidationRule { |
| 403 | ValidationRule::new("not_too_old") |
| 404 | .description(format!("Prevents dates more than {} years in the past", max_years_ago)) |
| 405 | .with_date_validator(move |date| { |
| 406 | let current_year = Self::get_current_year(); |
| 407 | let min_allowed_year = current_year - max_years_ago as i32; |
| 408 | |
| 409 | if date.year() < min_allowed_year { |
| 410 | Err(vec![ValidationError::new( |
| 411 | "not_too_old", |
| 412 | "Date cannot be too far in the past" |
| 413 | ).field("year") |
| 414 | .value(date.year().to_string()) |
| 415 | .expected(format!("{} or later", min_allowed_year))]) |
| 416 | } else { |
| 417 | Ok(()) |
| 418 | } |
| 419 | }) |
| 420 | } |
| 421 | |
| 422 | pub fn not_too_future(max_years_ahead: u32) -> ValidationRule { |
| 423 | ValidationRule::new("not_too_future") |
| 424 | .description(format!("Prevents dates more than {} years in the future", max_years_ahead)) |
| 425 | .with_date_validator(move |date| { |
| 426 | let current_year = Self::get_current_year(); |
| 427 | let max_allowed_year = current_year + max_years_ahead as i32; |
| 428 | |
| 429 | if date.year() > max_allowed_year { |
| 430 | Err(vec![ValidationError::new( |
| 431 | "not_too_future", |
| 432 | "Date cannot be too far in the future" |
| 433 | ).field("year") |
| 434 | .value(date.year().to_string()) |
| 435 | .expected(format!("{} or earlier", max_allowed_year))]) |
| 436 | } else { |
| 437 | Ok(()) |
| 438 | } |
| 439 | }) |
| 440 | } |
| 441 | } |
| 442 | |
| 443 | // ======================================================================== |
| 444 | // Rule Combinators |
| 445 | // ======================================================================== |
| 446 | |
| 447 | /// Every rule must pass, and the errors of all that fail are returned. |
| 448 | pub fn all_rules(rules: Vec<ValidationRule>) -> ValidationRule { |
| 449 | ValidationRule::new("combined_all") |
| 450 | .description("All specified rules must pass") |
| 451 | .with_calclock_validator(move |calclock| { |
| 452 | let mut all_errors = Vec::new(); |
| 453 | |
| 454 | for rule in &rules { |
| 455 | if let Err(mut errors) = rule.validate_calclock(calclock) { |
| 456 | all_errors.append(&mut errors); |
| 457 | } |
| 458 | } |
| 459 | |
| 460 | if all_errors.is_empty() { |
| 461 | Ok(()) |
| 462 | } else { |
| 463 | Err(all_errors) |
| 464 | } |
| 465 | }) |
| 466 | } |
| 467 | |
| 468 | /// One rule passing is enough. |
| 469 | pub fn any_rule(rules: Vec<ValidationRule>) -> ValidationRule { |
| 470 | ValidationRule::new("combined_any") |
| 471 | .description("At least one of the specified rules must pass") |
| 472 | .with_calclock_validator(move |calclock| { |
| 473 | for rule in &rules { |
| 474 | if rule.validate_calclock(calclock).is_ok() { |
| 475 | return Ok(()); |
| 476 | } |
| 477 | } |
| 478 | |
| 479 | Err(vec![ValidationError::new( |
| 480 | "combined_any", |
| 481 | "None of the alternative validation rules passed" |
| 482 | )]) |
| 483 | }) |
| 484 | } |
| 485 | |
| 486 | // ======================================================================== |
| 487 | // Common Rule Combinations |
| 488 | // ======================================================================== |
| 489 | |
| 490 | impl ValidationRules { |
| 491 | pub fn strict_business() -> ValidationRule { |
| 492 | let mut weekdays = HashSet::new(); |
| 493 | weekdays.insert(crate::constant::DayOfWeek::Monday); |
| 494 | weekdays.insert(crate::constant::DayOfWeek::Tuesday); |
| 495 | weekdays.insert(crate::constant::DayOfWeek::Wednesday); |
| 496 | weekdays.insert(crate::constant::DayOfWeek::Thursday); |
| 497 | weekdays.insert(crate::constant::DayOfWeek::Friday); |
| 498 | |
| 499 | all_rules(vec![ |
| 500 | Self::business_hours(), |
| 501 | Self::no_holidays(), |
| 502 | Self::allowed_weekdays(weekdays), |
| 503 | ]) |
| 504 | } |
| 505 | |
| 506 | pub fn appointment_scheduling() -> ValidationRule { |
| 507 | all_rules(vec![ |
| 508 | Self::business_hours(), |
| 509 | Self::whole_minutes_only(), |
| 510 | Self::not_too_old(1), |
| 511 | Self::not_too_future(2), |
| 512 | ]) |
| 513 | } |
| 514 | |
| 515 | pub fn historical_data() -> ValidationRule { |
| 516 | all_rules(vec![ |
| 517 | Self::max_date({ |
| 518 | // TODO: Replace with proper error handling for static initialization |
| 519 | let calendar = Calendar::new(); |
| 520 | let utc_zone = crate::time::CalClockZone::utc(); |
| 521 | match calendar.date(2024, 12, 31, utc_zone) { |
| 522 | Ok(date) => date, |
| 523 | Err(_) => panic!("Failed to create validation date"), |
| 524 | } |
| 525 | }), |
| 526 | Self::not_too_old(100), |
| 527 | ]) |
| 528 | } |
| 529 | } |