oxedyne/fe2o3/fe2o3_datime/src/parser/relative.rs
32.5 KiB, 74 runs
created by r1870400018:8486, 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 | //! Comprehensive relative date parsing for natural language expressions. |
| 2 | //! |
| 3 | //! This module handles complex relative date expressions such as: |
| 4 | //! - "next Tuesday", "last Friday", "this Monday" |
| 5 | //! - "in 2 weeks", "in 3 days", "2 months ago" |
| 6 | //! - "next month", "last year", "this week" |
| 7 | //! - "3 days from now", "2 weeks from today" |
| 8 | //! - "end of this month", "beginning of next year" |
| 9 | //! - "this coming Monday", "the Tuesday after next" |
| 10 | //! |
| 11 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 12 | //! Anthropic Claude |
| 13 | |
| 14 | use crate::{ |
| 15 | calendar::CalendarDate, |
| 16 | constant::{DayOfWeek, MonthOfYear}, |
| 17 | time::CalClockZone, |
| 18 | }; |
| 19 | |
| 20 | use oxedyne_fe2o3_core::prelude::*; |
| 21 | |
| 22 | use std::collections::HashMap; |
| 23 | |
| 24 | #[derive(Clone, Debug, PartialEq)] |
| 25 | pub enum RelativeReference { |
| 26 | // The word the expression opened with. |
| 27 | Next, |
| 28 | Last, |
| 29 | This, |
| 30 | Coming, // "this coming", "upcoming" |
| 31 | AfterNext, // "the Tuesday after next" |
| 32 | BeforeLast, // "the Tuesday before last" |
| 33 | } |
| 34 | |
| 35 | #[derive(Clone, Debug, PartialEq)] |
| 36 | pub enum RelativeUnit { |
| 37 | Day, |
| 38 | Week, |
| 39 | Month, |
| 40 | Year, |
| 41 | DayOfWeek(DayOfWeek), |
| 42 | PeriodBoundary(PeriodType, BoundaryType), // "end of month" |
| 43 | } |
| 44 | |
| 45 | #[derive(Clone, Debug, PartialEq)] |
| 46 | pub enum PeriodType { |
| 47 | Week, |
| 48 | Month, |
| 49 | Quarter, |
| 50 | Year, |
| 51 | } |
| 52 | |
| 53 | #[derive(Clone, Debug, PartialEq)] |
| 54 | pub enum BoundaryType { |
| 55 | Beginning, |
| 56 | End, |
| 57 | Middle, |
| 58 | } |
| 59 | |
| 60 | #[derive(Clone, Debug, PartialEq)] |
| 61 | pub enum Direction { |
| 62 | Forward, |
| 63 | Backward, |
| 64 | } |
| 65 | |
| 66 | #[derive(Clone, Debug, PartialEq)] |
| 67 | pub struct RelativeExpression { |
| 68 | pub reference: RelativeReference, |
| 69 | pub unit: RelativeUnit, |
| 70 | pub quantity: Option<i32>, // the 2 in "2 weeks" |
| 71 | pub direction: Direction, |
| 72 | pub context: Option<String>, // "from now", "from today" |
| 73 | } |
| 74 | |
| 75 | pub struct RelativeDateParser { |
| 76 | // The vocabularies, each mapping a word to the thing it means. |
| 77 | day_names: HashMap<String, DayOfWeek>, |
| 78 | month_names: HashMap<String, MonthOfYear>, |
| 79 | reference_words: HashMap<String, RelativeReference>, |
| 80 | unit_words: HashMap<String, RelativeUnit>, |
| 81 | direction_words: HashMap<String, Direction>, |
| 82 | } |
| 83 | |
| 84 | impl Default for RelativeDateParser { |
| 85 | fn default() -> Self { |
| 86 | Self::new() |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | impl RelativeDateParser { |
| 91 | pub fn new() -> Self { |
| 92 | let mut parser = Self { |
| 93 | day_names: HashMap::new(), |
| 94 | month_names: HashMap::new(), |
| 95 | reference_words: HashMap::new(), |
| 96 | unit_words: HashMap::new(), |
| 97 | direction_words: HashMap::new(), |
| 98 | }; |
| 99 | |
| 100 | parser.initialise_vocabularies(); |
| 101 | parser |
| 102 | } |
| 103 | |
| 104 | fn initialise_vocabularies(&mut self) { |
| 105 | self.initialise_day_names(); |
| 106 | self.initialise_month_names(); |
| 107 | self.initialise_reference_words(); |
| 108 | self.initialise_unit_words(); |
| 109 | self.initialise_direction_words(); |
| 110 | } |
| 111 | |
| 112 | fn initialise_day_names(&mut self) { |
| 113 | let days = [ |
| 114 | (DayOfWeek::Sunday, vec!["sunday", "sun"]), |
| 115 | (DayOfWeek::Monday, vec!["monday", "mon"]), |
| 116 | (DayOfWeek::Tuesday, vec!["tuesday", "tue", "tues"]), |
| 117 | (DayOfWeek::Wednesday, vec!["wednesday", "wed"]), |
| 118 | (DayOfWeek::Thursday, vec!["thursday", "thu", "thurs"]), |
| 119 | (DayOfWeek::Friday, vec!["friday", "fri"]), |
| 120 | (DayOfWeek::Saturday, vec!["saturday", "sat"]), |
| 121 | ]; |
| 122 | |
| 123 | for (day, names) in days.iter() { |
| 124 | for name in names { |
| 125 | self.day_names.insert(name.to_string(), *day); |
| 126 | } |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | fn initialise_month_names(&mut self) { |
| 131 | let months = [ |
| 132 | (MonthOfYear::January, vec!["january", "jan"]), |
| 133 | (MonthOfYear::February, vec!["february", "feb"]), |
| 134 | (MonthOfYear::March, vec!["march", "mar"]), |
| 135 | (MonthOfYear::April, vec!["april", "apr"]), |
| 136 | (MonthOfYear::May, vec!["may"]), |
| 137 | (MonthOfYear::June, vec!["june", "jun"]), |
| 138 | (MonthOfYear::July, vec!["july", "jul"]), |
| 139 | (MonthOfYear::August, vec!["august", "aug"]), |
| 140 | (MonthOfYear::September, vec!["september", "sep", "sept"]), |
| 141 | (MonthOfYear::October, vec!["october", "oct"]), |
| 142 | (MonthOfYear::November, vec!["november", "nov"]), |
| 143 | (MonthOfYear::December, vec!["december", "dec"]), |
| 144 | ]; |
| 145 | |
| 146 | for (month, names) in months.iter() { |
| 147 | for name in names { |
| 148 | self.month_names.insert(name.to_string(), *month); |
| 149 | } |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | fn initialise_reference_words(&mut self) { |
| 154 | let references = [ |
| 155 | (RelativeReference::Next, vec!["next", "following", "upcoming"]), |
| 156 | (RelativeReference::Last, vec!["last", "previous", "past", "prior"]), |
| 157 | (RelativeReference::This, vec!["this", "current"]), |
| 158 | (RelativeReference::Coming, vec!["coming", "approaching"]), |
| 159 | (RelativeReference::AfterNext, vec!["after"]), |
| 160 | (RelativeReference::BeforeLast, vec!["before"]), |
| 161 | ]; |
| 162 | |
| 163 | for (reference, words) in references.iter() { |
| 164 | for word in words { |
| 165 | self.reference_words.insert(word.to_string(), reference.clone()); |
| 166 | } |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | fn initialise_unit_words(&mut self) { |
| 171 | let units = [ |
| 172 | (RelativeUnit::Day, vec!["day", "days"]), |
| 173 | (RelativeUnit::Week, vec!["week", "weeks"]), |
| 174 | (RelativeUnit::Month, vec!["month", "months"]), |
| 175 | (RelativeUnit::Year, vec!["year", "years"]), |
| 176 | ]; |
| 177 | |
| 178 | for (unit, words) in units.iter() { |
| 179 | for word in words { |
| 180 | self.unit_words.insert(word.to_string(), unit.clone()); |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | // Note: Period boundaries are handled by multi-token pattern matching, |
| 185 | // not individual token parsing, so we don't add them to unit_words. |
| 186 | } |
| 187 | |
| 188 | fn initialise_direction_words(&mut self) { |
| 189 | let directions = [ |
| 190 | (Direction::Forward, vec!["from", "after", "hence", "later"]), |
| 191 | (Direction::Backward, vec!["ago", "back", "before", "earlier"]), |
| 192 | ]; |
| 193 | |
| 194 | for (direction, words) in directions.iter() { |
| 195 | for word in words { |
| 196 | self.direction_words.insert(word.to_string(), direction.clone()); |
| 197 | } |
| 198 | } |
| 199 | } |
| 200 | |
| 201 | /// # Examples |
| 202 | /// |
| 203 | /// ```ignore |
| 204 | /// let parser = RelativeDateParser::new(); |
| 205 | /// |
| 206 | /// // Simple relative dates |
| 207 | /// let expr = parser.parse("next Tuesday").unwrap(); |
| 208 | /// let expr = parser.parse("last Friday").unwrap(); |
| 209 | /// let expr = parser.parse("this Monday").unwrap(); |
| 210 | /// |
| 211 | /// // Quantified relative dates |
| 212 | /// let expr = parser.parse("in 2 weeks").unwrap(); |
| 213 | /// let expr = parser.parse("3 days ago").unwrap(); |
| 214 | /// let expr = parser.parse("2 months from now").unwrap(); |
| 215 | /// |
| 216 | /// // Complex expressions |
| 217 | /// let expr = parser.parse("the Tuesday after next").unwrap(); |
| 218 | /// let expr = parser.parse("end of this month").unwrap(); |
| 219 | /// let expr = parser.parse("beginning of next year").unwrap(); |
| 220 | /// ``` |
| 221 | pub fn parse(&self, input: &str) -> Outcome<RelativeExpression> { |
| 222 | let normalized = self.normalize_input(input); |
| 223 | let tokens = self.tokenize(&normalized); |
| 224 | self.parse_tokens(&tokens) |
| 225 | } |
| 226 | |
| 227 | fn normalize_input(&self, input: &str) -> String { |
| 228 | input |
| 229 | .to_lowercase() |
| 230 | .replace(" ", " ") |
| 231 | .replace("from now", "") |
| 232 | .replace("from today", "") |
| 233 | .trim() |
| 234 | .to_string() |
| 235 | } |
| 236 | |
| 237 | fn tokenize(&self, input: &str) -> Vec<String> { |
| 238 | input |
| 239 | .split_whitespace() |
| 240 | .filter(|word| !word.is_empty()) |
| 241 | .filter(|word| !matches!(*word, "the" | "a" | "an" | "in" | "on" | "at")) |
| 242 | .map(|word| word.to_string()) |
| 243 | .collect() |
| 244 | } |
| 245 | |
| 246 | fn parse_tokens(&self, tokens: &[String]) -> Outcome<RelativeExpression> { |
| 247 | if tokens.is_empty() { |
| 248 | return Err(err!("Empty input for relative date parsing"; Invalid, Input)); |
| 249 | } |
| 250 | |
| 251 | let mut expression = RelativeExpression { |
| 252 | reference: RelativeReference::This, |
| 253 | unit: RelativeUnit::Day, |
| 254 | quantity: None, |
| 255 | direction: Direction::Forward, |
| 256 | context: None, |
| 257 | }; |
| 258 | |
| 259 | let mut i = 0; |
| 260 | while i < tokens.len() { |
| 261 | let token = &tokens[i]; |
| 262 | |
| 263 | // Try to parse quantity (numbers). |
| 264 | if let Ok(num) = token.parse::<i32>() { |
| 265 | expression.quantity = Some(num); |
| 266 | i += 1; |
| 267 | continue; |
| 268 | } |
| 269 | |
| 270 | // Handle period boundaries FIRST before individual token parsing |
| 271 | // This prevents "end" and "month" from being parsed separately |
| 272 | if i + 2 < tokens.len() && tokens[i + 1] == "of" { |
| 273 | // Try direct pattern: "end of month" |
| 274 | if let Some(boundary_unit) = self.parse_period_boundary(token, &tokens[i + 2]) { |
| 275 | expression.unit = boundary_unit; |
| 276 | i += 3; |
| 277 | continue; |
| 278 | } |
| 279 | |
| 280 | // Try with reference word: "end of this month", "beginning of next year" |
| 281 | if i + 3 < tokens.len() { |
| 282 | if let Some(reference) = self.reference_words.get(&tokens[i + 2]) { |
| 283 | if let Some(boundary_unit) = self.parse_period_boundary(token, &tokens[i + 3]) { |
| 284 | expression.reference = reference.clone(); |
| 285 | expression.unit = boundary_unit; |
| 286 | i += 4; |
| 287 | continue; |
| 288 | } |
| 289 | } |
| 290 | } |
| 291 | } |
| 292 | |
| 293 | // Try to parse relative reference. |
| 294 | if let Some(reference) = self.reference_words.get(token) { |
| 295 | expression.reference = reference.clone(); |
| 296 | i += 1; |
| 297 | continue; |
| 298 | } |
| 299 | |
| 300 | // Try to parse day of week. |
| 301 | if let Some(day) = self.day_names.get(token) { |
| 302 | expression.unit = RelativeUnit::DayOfWeek(*day); |
| 303 | i += 1; |
| 304 | continue; |
| 305 | } |
| 306 | |
| 307 | // Try to parse unit. |
| 308 | if let Some(unit) = self.unit_words.get(token) { |
| 309 | expression.unit = unit.clone(); |
| 310 | i += 1; |
| 311 | continue; |
| 312 | } |
| 313 | |
| 314 | // Try to parse direction. |
| 315 | if let Some(direction) = self.direction_words.get(token) { |
| 316 | expression.direction = direction.clone(); |
| 317 | i += 1; |
| 318 | continue; |
| 319 | } |
| 320 | |
| 321 | // Handle special multi-word patterns. |
| 322 | if token == "after" && i + 1 < tokens.len() && tokens[i + 1] == "next" { |
| 323 | expression.reference = RelativeReference::AfterNext; |
| 324 | i += 2; |
| 325 | continue; |
| 326 | } |
| 327 | |
| 328 | if token == "before" && i + 1 < tokens.len() && tokens[i + 1] == "last" { |
| 329 | expression.reference = RelativeReference::BeforeLast; |
| 330 | i += 2; |
| 331 | continue; |
| 332 | } |
| 333 | |
| 334 | |
| 335 | // Skip unknown tokens. |
| 336 | i += 1; |
| 337 | } |
| 338 | |
| 339 | // Apply some intelligent defaults and validation. |
| 340 | ok!(self.validate_and_adjust_expression(&mut expression)); |
| 341 | |
| 342 | Ok(expression) |
| 343 | } |
| 344 | |
| 345 | pub fn parse_period_boundary(&self, boundary_word: &str, period_word: &str) -> Option<RelativeUnit> { |
| 346 | let boundary_type = match boundary_word { |
| 347 | "beginning" | "start" => BoundaryType::Beginning, |
| 348 | "end" => BoundaryType::End, |
| 349 | "middle" => BoundaryType::Middle, |
| 350 | _ => return None, |
| 351 | }; |
| 352 | |
| 353 | let period_type = match period_word { |
| 354 | "week" => PeriodType::Week, |
| 355 | "month" => PeriodType::Month, |
| 356 | "quarter" => PeriodType::Quarter, |
| 357 | "year" => PeriodType::Year, |
| 358 | _ => return None, |
| 359 | }; |
| 360 | |
| 361 | Some(RelativeUnit::PeriodBoundary(period_type, boundary_type)) |
| 362 | } |
| 363 | |
| 364 | fn validate_and_adjust_expression(&self, expr: &mut RelativeExpression) -> Outcome<()> { |
| 365 | // If we have a quantity but no clear direction, infer from context. |
| 366 | if expr.quantity.is_some() { |
| 367 | // If we have "ago" or similar, it's backward. |
| 368 | if expr.direction == Direction::Backward { |
| 369 | // Already correct. |
| 370 | } else if matches!(expr.reference, RelativeReference::Last) { |
| 371 | expr.direction = Direction::Backward; |
| 372 | } else { |
| 373 | // Default to forward for quantified expressions. |
| 374 | expr.direction = Direction::Forward; |
| 375 | } |
| 376 | } |
| 377 | |
| 378 | // Adjust direction based on reference type for all unit types. |
| 379 | match expr.reference { |
| 380 | RelativeReference::Next | RelativeReference::Coming => { |
| 381 | expr.direction = Direction::Forward; |
| 382 | }, |
| 383 | RelativeReference::Last => { |
| 384 | expr.direction = Direction::Backward; |
| 385 | }, |
| 386 | RelativeReference::This => { |
| 387 | // For "this X" expressions, determine direction based on context. |
| 388 | if expr.direction == Direction::Backward { |
| 389 | // Keep backward if explicitly specified. |
| 390 | } else { |
| 391 | // Default to forward for "this" expressions. |
| 392 | expr.direction = Direction::Forward; |
| 393 | } |
| 394 | }, |
| 395 | RelativeReference::AfterNext => { |
| 396 | expr.direction = Direction::Forward; |
| 397 | }, |
| 398 | RelativeReference::BeforeLast => { |
| 399 | expr.direction = Direction::Backward; |
| 400 | }, |
| 401 | } |
| 402 | |
| 403 | Ok(()) |
| 404 | } |
| 405 | |
| 406 | pub fn calculate_date(&self, expr: &RelativeExpression, base_date: &CalendarDate, zone: CalClockZone) -> Outcome<CalendarDate> { |
| 407 | match &expr.unit { |
| 408 | RelativeUnit::Day => self.calculate_day_offset(expr, base_date), |
| 409 | RelativeUnit::Week => self.calculate_week_offset(expr, base_date), |
| 410 | RelativeUnit::Month => self.calculate_month_offset(expr, base_date), |
| 411 | RelativeUnit::Year => self.calculate_year_offset(expr, base_date), |
| 412 | RelativeUnit::DayOfWeek(target_day) => self.calculate_day_of_week(expr, base_date, *target_day, zone), |
| 413 | RelativeUnit::PeriodBoundary(period, boundary) => self.calculate_period_boundary(expr, base_date, period, boundary, zone), |
| 414 | } |
| 415 | } |
| 416 | |
| 417 | fn calculate_day_offset(&self, expr: &RelativeExpression, base_date: &CalendarDate) -> Outcome<CalendarDate> { |
| 418 | let quantity = expr.quantity.unwrap_or(1); |
| 419 | let offset = match expr.direction { |
| 420 | Direction::Forward => quantity, |
| 421 | Direction::Backward => -quantity, |
| 422 | }; |
| 423 | |
| 424 | base_date.add_days(offset) |
| 425 | } |
| 426 | |
| 427 | fn calculate_week_offset(&self, expr: &RelativeExpression, base_date: &CalendarDate) -> Outcome<CalendarDate> { |
| 428 | let quantity = expr.quantity.unwrap_or(1); |
| 429 | let offset = match expr.direction { |
| 430 | Direction::Forward => quantity * 7, |
| 431 | Direction::Backward => -quantity * 7, |
| 432 | }; |
| 433 | |
| 434 | base_date.add_days(offset) |
| 435 | } |
| 436 | |
| 437 | fn calculate_month_offset(&self, expr: &RelativeExpression, base_date: &CalendarDate) -> Outcome<CalendarDate> { |
| 438 | let quantity = expr.quantity.unwrap_or(1); |
| 439 | let offset = match expr.direction { |
| 440 | Direction::Forward => quantity, |
| 441 | Direction::Backward => -quantity, |
| 442 | }; |
| 443 | |
| 444 | base_date.add_months(offset) |
| 445 | } |
| 446 | |
| 447 | fn calculate_year_offset(&self, expr: &RelativeExpression, base_date: &CalendarDate) -> Outcome<CalendarDate> { |
| 448 | let quantity = expr.quantity.unwrap_or(1); |
| 449 | let offset = match expr.direction { |
| 450 | Direction::Forward => quantity, |
| 451 | Direction::Backward => -quantity, |
| 452 | }; |
| 453 | |
| 454 | base_date.add_years(offset) |
| 455 | } |
| 456 | |
| 457 | /// "This Tuesday" can land in the past, since it means the Tuesday of the |
| 458 | /// current week. "Next" and "last" never return the base date itself, |
| 459 | /// even when it falls on the target day. |
| 460 | fn calculate_day_of_week(&self, expr: &RelativeExpression, base_date: &CalendarDate, target_day: DayOfWeek, _zone: CalClockZone) -> Outcome<CalendarDate> { |
| 461 | let current_day = base_date.day_of_week(); |
| 462 | let current_day_num = current_day.of() as i32; |
| 463 | let target_day_num = target_day.of() as i32; |
| 464 | |
| 465 | let mut days_to_target = target_day_num - current_day_num; |
| 466 | |
| 467 | match expr.reference { |
| 468 | RelativeReference::This => { |
| 469 | // This Tuesday: if today is Tuesday, return today; otherwise find the Tuesday in this week |
| 470 | if days_to_target == 0 { |
| 471 | return Ok(base_date.clone()); |
| 472 | } |
| 473 | // For "this", we want the occurrence within this week, whether past or future |
| 474 | // No adjustment needed - days_to_target will be negative for past days in the week |
| 475 | }, |
| 476 | RelativeReference::Next | RelativeReference::Coming => { |
| 477 | // Next Tuesday: find the next occurrence (not today even if today is Tuesday) |
| 478 | if days_to_target <= 0 { |
| 479 | days_to_target += 7; |
| 480 | } |
| 481 | }, |
| 482 | RelativeReference::Last => { |
| 483 | // Last Tuesday: find the previous occurrence (not today even if today is Tuesday) |
| 484 | if days_to_target >= 0 { |
| 485 | days_to_target -= 7; |
| 486 | } |
| 487 | }, |
| 488 | RelativeReference::AfterNext => { |
| 489 | // The Tuesday after next: find the occurrence after next Tuesday |
| 490 | if days_to_target <= 0 { |
| 491 | days_to_target += 7; |
| 492 | } |
| 493 | days_to_target += 7; // Add another week |
| 494 | }, |
| 495 | RelativeReference::BeforeLast => { |
| 496 | // The Tuesday before last: find the occurrence before last Tuesday |
| 497 | if days_to_target >= 0 { |
| 498 | days_to_target -= 7; |
| 499 | } |
| 500 | days_to_target -= 7; // Subtract another week |
| 501 | }, |
| 502 | } |
| 503 | |
| 504 | base_date.add_days(days_to_target) |
| 505 | } |
| 506 | |
| 507 | fn calculate_period_boundary(&self, expr: &RelativeExpression, base_date: &CalendarDate, period: &PeriodType, boundary: &BoundaryType, zone: CalClockZone) -> Outcome<CalendarDate> { |
| 508 | match period { |
| 509 | PeriodType::Week => self.calculate_week_boundary(expr, base_date, boundary), |
| 510 | PeriodType::Month => self.calculate_month_boundary(expr, base_date, boundary, zone), |
| 511 | PeriodType::Quarter => self.calculate_quarter_boundary(expr, base_date, boundary, zone), |
| 512 | PeriodType::Year => self.calculate_year_boundary(expr, base_date, boundary, zone), |
| 513 | } |
| 514 | } |
| 515 | |
| 516 | fn calculate_week_boundary(&self, expr: &RelativeExpression, base_date: &CalendarDate, boundary: &BoundaryType) -> Outcome<CalendarDate> { |
| 517 | let current_day_num = base_date.day_of_week().of() as i32; |
| 518 | |
| 519 | let target_date = match expr.reference { |
| 520 | RelativeReference::This => base_date.clone(), |
| 521 | RelativeReference::Next => res!(base_date.add_days(7)), |
| 522 | RelativeReference::Last => res!(base_date.add_days(-7)), |
| 523 | _ => base_date.clone(), |
| 524 | }; |
| 525 | |
| 526 | match boundary { |
| 527 | BoundaryType::Beginning => { |
| 528 | // Beginning of week (Sunday) |
| 529 | let days_to_sunday = if current_day_num == 0 { 0 } else { -current_day_num }; |
| 530 | target_date.add_days(days_to_sunday) |
| 531 | }, |
| 532 | BoundaryType::End => { |
| 533 | // End of week (Saturday) |
| 534 | let days_to_saturday = 6 - current_day_num; |
| 535 | target_date.add_days(days_to_saturday) |
| 536 | }, |
| 537 | BoundaryType::Middle => { |
| 538 | // Middle of week (Wednesday) |
| 539 | let days_to_wednesday = 3 - current_day_num; |
| 540 | target_date.add_days(days_to_wednesday) |
| 541 | }, |
| 542 | } |
| 543 | } |
| 544 | |
| 545 | fn calculate_month_boundary(&self, expr: &RelativeExpression, base_date: &CalendarDate, boundary: &BoundaryType, zone: CalClockZone) -> Outcome<CalendarDate> { |
| 546 | let (target_year, target_month) = match expr.reference { |
| 547 | RelativeReference::This => (base_date.year(), base_date.month()), |
| 548 | RelativeReference::Next => { |
| 549 | if base_date.month() == 12 { |
| 550 | (base_date.year() + 1, 1) |
| 551 | } else { |
| 552 | (base_date.year(), base_date.month() + 1) |
| 553 | } |
| 554 | }, |
| 555 | RelativeReference::Last => { |
| 556 | if base_date.month() == 1 { |
| 557 | (base_date.year() - 1, 12) |
| 558 | } else { |
| 559 | (base_date.year(), base_date.month() - 1) |
| 560 | } |
| 561 | }, |
| 562 | _ => (base_date.year(), base_date.month()), |
| 563 | }; |
| 564 | |
| 565 | let target_month_enum = res!(MonthOfYear::from_number(target_month)); |
| 566 | |
| 567 | match boundary { |
| 568 | BoundaryType::Beginning => { |
| 569 | CalendarDate::from_ymd(target_year, target_month_enum, 1, zone) |
| 570 | }, |
| 571 | BoundaryType::End => { |
| 572 | let days_in_month = target_month_enum.days_in_month(target_year); |
| 573 | CalendarDate::from_ymd(target_year, target_month_enum, days_in_month, zone) |
| 574 | }, |
| 575 | BoundaryType::Middle => { |
| 576 | let days_in_month = target_month_enum.days_in_month(target_year); |
| 577 | let middle_day = (days_in_month + 1) / 2; |
| 578 | CalendarDate::from_ymd(target_year, target_month_enum, middle_day, zone) |
| 579 | }, |
| 580 | } |
| 581 | } |
| 582 | |
| 583 | fn calculate_quarter_boundary(&self, expr: &RelativeExpression, base_date: &CalendarDate, boundary: &BoundaryType, zone: CalClockZone) -> Outcome<CalendarDate> { |
| 584 | let current_quarter = ((base_date.month() - 1) / 3) + 1; |
| 585 | |
| 586 | let target_quarter = match expr.reference { |
| 587 | RelativeReference::This => current_quarter, |
| 588 | RelativeReference::Next => if current_quarter == 4 { 1 } else { current_quarter + 1 }, |
| 589 | RelativeReference::Last => if current_quarter == 1 { 4 } else { current_quarter - 1 }, |
| 590 | _ => current_quarter, |
| 591 | }; |
| 592 | |
| 593 | let (target_year, first_month) = if expr.reference == RelativeReference::Next && current_quarter == 4 { |
| 594 | (base_date.year() + 1, 1) |
| 595 | } else if expr.reference == RelativeReference::Last && current_quarter == 1 { |
| 596 | (base_date.year() - 1, 10) |
| 597 | } else { |
| 598 | (base_date.year(), (target_quarter - 1) * 3 + 1) |
| 599 | }; |
| 600 | |
| 601 | match boundary { |
| 602 | BoundaryType::Beginning => { |
| 603 | let month_enum = res!(MonthOfYear::from_number(first_month)); |
| 604 | CalendarDate::from_ymd(target_year, month_enum, 1, zone) |
| 605 | }, |
| 606 | BoundaryType::End => { |
| 607 | let last_month = first_month + 2; |
| 608 | let month_enum = res!(MonthOfYear::from_number(last_month)); |
| 609 | let days_in_month = month_enum.days_in_month(target_year); |
| 610 | CalendarDate::from_ymd(target_year, month_enum, days_in_month, zone) |
| 611 | }, |
| 612 | BoundaryType::Middle => { |
| 613 | // Middle of quarter (middle of second month) |
| 614 | let middle_month = first_month + 1; |
| 615 | let month_enum = res!(MonthOfYear::from_number(middle_month)); |
| 616 | let days_in_month = month_enum.days_in_month(target_year); |
| 617 | let middle_day = (days_in_month + 1) / 2; |
| 618 | CalendarDate::from_ymd(target_year, month_enum, middle_day, zone) |
| 619 | }, |
| 620 | } |
| 621 | } |
| 622 | |
| 623 | fn calculate_year_boundary(&self, expr: &RelativeExpression, base_date: &CalendarDate, boundary: &BoundaryType, zone: CalClockZone) -> Outcome<CalendarDate> { |
| 624 | let target_year = match expr.reference { |
| 625 | RelativeReference::This => base_date.year(), |
| 626 | RelativeReference::Next => base_date.year() + 1, |
| 627 | RelativeReference::Last => base_date.year() - 1, |
| 628 | _ => base_date.year(), |
| 629 | }; |
| 630 | |
| 631 | match boundary { |
| 632 | BoundaryType::Beginning => { |
| 633 | CalendarDate::from_ymd(target_year, MonthOfYear::January, 1, zone) |
| 634 | }, |
| 635 | BoundaryType::End => { |
| 636 | CalendarDate::from_ymd(target_year, MonthOfYear::December, 31, zone) |
| 637 | }, |
| 638 | BoundaryType::Middle => { |
| 639 | // Middle of year (July 1st or 2nd depending on leap year) |
| 640 | let middle_day = if target_year % 4 == 0 && (target_year % 100 != 0 || target_year % 400 == 0) { 2 } else { 1 }; |
| 641 | CalendarDate::from_ymd(target_year, MonthOfYear::July, middle_day, zone) |
| 642 | }, |
| 643 | } |
| 644 | } |
| 645 | |
| 646 | /// # Examples |
| 647 | /// |
| 648 | /// ```ignore |
| 649 | /// let parser = RelativeDateParser::new(); |
| 650 | /// let today = CalendarDate::today(CalClockZone::utc()).unwrap(); |
| 651 | /// |
| 652 | /// let next_tuesday = parser.parse_and_calculate("next Tuesday", &today, CalClockZone::utc()).unwrap(); |
| 653 | /// let in_two_weeks = parser.parse_and_calculate("in 2 weeks", &today, CalClockZone::utc()).unwrap(); |
| 654 | /// let end_of_month = parser.parse_and_calculate("end of this month", &today, CalClockZone::utc()).unwrap(); |
| 655 | /// ``` |
| 656 | pub fn parse_and_calculate(&self, input: &str, base_date: &CalendarDate, zone: CalClockZone) -> Outcome<CalendarDate> { |
| 657 | let expression = res!(self.parse(input)); |
| 658 | self.calculate_date(&expression, base_date, zone) |
| 659 | } |
| 660 | } |
| 661 | |
| 662 | #[cfg(test)] |
| 663 | mod tests { |
| 664 | use super::*; |
| 665 | use crate::time::CalClockZone; |
| 666 | |
| 667 | fn get_test_base_date() -> CalendarDate { |
| 668 | // Wednesday, June 12, 2024 |
| 669 | CalendarDate::from_ymd(2024, MonthOfYear::June, 12, CalClockZone::utc()).unwrap() |
| 670 | } |
| 671 | |
| 672 | #[test] |
| 673 | fn test_parse_simple_relative_dates() { |
| 674 | let parser = RelativeDateParser::new(); |
| 675 | |
| 676 | // Test "next Tuesday" |
| 677 | let expr = parser.parse("next Tuesday").unwrap(); |
| 678 | assert_eq!(expr.reference, RelativeReference::Next); |
| 679 | assert_eq!(expr.unit, RelativeUnit::DayOfWeek(DayOfWeek::Tuesday)); |
| 680 | assert_eq!(expr.direction, Direction::Forward); |
| 681 | |
| 682 | // Test "last Friday" |
| 683 | let expr = parser.parse("last Friday").unwrap(); |
| 684 | assert_eq!(expr.reference, RelativeReference::Last); |
| 685 | assert_eq!(expr.unit, RelativeUnit::DayOfWeek(DayOfWeek::Friday)); |
| 686 | assert_eq!(expr.direction, Direction::Backward); |
| 687 | |
| 688 | // Test "this Monday" |
| 689 | let expr = parser.parse("this Monday").unwrap(); |
| 690 | assert_eq!(expr.reference, RelativeReference::This); |
| 691 | assert_eq!(expr.unit, RelativeUnit::DayOfWeek(DayOfWeek::Monday)); |
| 692 | } |
| 693 | |
| 694 | #[test] |
| 695 | fn test_parse_quantified_relative_dates() { |
| 696 | let parser = RelativeDateParser::new(); |
| 697 | |
| 698 | // Test "in 2 weeks" |
| 699 | let expr = parser.parse("in 2 weeks").unwrap(); |
| 700 | assert_eq!(expr.quantity, Some(2)); |
| 701 | assert_eq!(expr.unit, RelativeUnit::Week); |
| 702 | assert_eq!(expr.direction, Direction::Forward); |
| 703 | |
| 704 | // Test "3 days ago" |
| 705 | let expr = parser.parse("3 days ago").unwrap(); |
| 706 | assert_eq!(expr.quantity, Some(3)); |
| 707 | assert_eq!(expr.unit, RelativeUnit::Day); |
| 708 | assert_eq!(expr.direction, Direction::Backward); |
| 709 | |
| 710 | // Test "2 months from now" |
| 711 | let expr = parser.parse("2 months from now").unwrap(); |
| 712 | assert_eq!(expr.quantity, Some(2)); |
| 713 | assert_eq!(expr.unit, RelativeUnit::Month); |
| 714 | assert_eq!(expr.direction, Direction::Forward); |
| 715 | } |
| 716 | |
| 717 | #[test] |
| 718 | fn test_parse_period_boundaries() { |
| 719 | let parser = RelativeDateParser::new(); |
| 720 | |
| 721 | // Test "end of this month" |
| 722 | let expr = parser.parse("end of this month").unwrap(); |
| 723 | assert_eq!(expr.reference, RelativeReference::This); |
| 724 | assert!(matches!(expr.unit, RelativeUnit::PeriodBoundary(PeriodType::Month, BoundaryType::End))); |
| 725 | |
| 726 | // Test "beginning of next year" |
| 727 | let expr = parser.parse("beginning of next year").unwrap(); |
| 728 | assert_eq!(expr.reference, RelativeReference::Next); |
| 729 | assert!(matches!(expr.unit, RelativeUnit::PeriodBoundary(PeriodType::Year, BoundaryType::Beginning))); |
| 730 | } |
| 731 | |
| 732 | #[test] |
| 733 | fn test_calculate_day_of_week() { |
| 734 | let parser = RelativeDateParser::new(); |
| 735 | let base_date = get_test_base_date(); // Wednesday, June 12, 2024 |
| 736 | let zone = CalClockZone::utc(); |
| 737 | |
| 738 | // Next Tuesday (June 18, 2024) |
| 739 | let next_tuesday = parser.parse_and_calculate("next Tuesday", &base_date, zone.clone()).unwrap(); |
| 740 | assert_eq!(next_tuesday.day(), 18); |
| 741 | assert_eq!(next_tuesday.day_of_week(), DayOfWeek::Tuesday); |
| 742 | |
| 743 | // Last Friday (June 7, 2024) |
| 744 | let last_friday = parser.parse_and_calculate("last Friday", &base_date, zone.clone()).unwrap(); |
| 745 | assert_eq!(last_friday.day(), 7); |
| 746 | assert_eq!(last_friday.day_of_week(), DayOfWeek::Friday); |
| 747 | |
| 748 | // This Monday (June 10, 2024) |
| 749 | let this_monday = parser.parse_and_calculate("this Monday", &base_date, zone.clone()).unwrap(); |
| 750 | assert_eq!(this_monday.day(), 10); |
| 751 | assert_eq!(this_monday.day_of_week(), DayOfWeek::Monday); |
| 752 | } |
| 753 | |
| 754 | #[test] |
| 755 | fn test_calculate_quantified_dates() { |
| 756 | let parser = RelativeDateParser::new(); |
| 757 | let base_date = get_test_base_date(); // Wednesday, June 12, 2024 |
| 758 | let zone = CalClockZone::utc(); |
| 759 | |
| 760 | // In 2 weeks (June 26, 2024) |
| 761 | let in_two_weeks = parser.parse_and_calculate("in 2 weeks", &base_date, zone.clone()).unwrap(); |
| 762 | assert_eq!(in_two_weeks.day(), 26); |
| 763 | assert_eq!(in_two_weeks.month(), 6); |
| 764 | |
| 765 | // 3 days ago (June 9, 2024) |
| 766 | let three_days_ago = parser.parse_and_calculate("3 days ago", &base_date, zone.clone()).unwrap(); |
| 767 | assert_eq!(three_days_ago.day(), 9); |
| 768 | assert_eq!(three_days_ago.month(), 6); |
| 769 | |
| 770 | // 2 months from now (August 12, 2024) |
| 771 | let two_months_later = parser.parse_and_calculate("2 months from now", &base_date, zone.clone()).unwrap(); |
| 772 | assert_eq!(two_months_later.day(), 12); |
| 773 | assert_eq!(two_months_later.month(), 8); |
| 774 | } |
| 775 | |
| 776 | #[test] |
| 777 | fn test_calculate_period_boundaries() { |
| 778 | let parser = RelativeDateParser::new(); |
| 779 | let base_date = get_test_base_date(); // Wednesday, June 12, 2024 |
| 780 | let zone = CalClockZone::utc(); |
| 781 | |
| 782 | // End of this month (June 30, 2024) |
| 783 | let end_of_month = parser.parse_and_calculate("end of this month", &base_date, zone.clone()).unwrap(); |
| 784 | assert_eq!(end_of_month.day(), 30); |
| 785 | assert_eq!(end_of_month.month(), 6); |
| 786 | |
| 787 | // Beginning of next month (July 1, 2024) |
| 788 | let beginning_next_month = parser.parse_and_calculate("beginning of next month", &base_date, zone.clone()).unwrap(); |
| 789 | assert_eq!(beginning_next_month.day(), 1); |
| 790 | assert_eq!(beginning_next_month.month(), 7); |
| 791 | |
| 792 | // End of this year (December 31, 2024) |
| 793 | let end_of_year = parser.parse_and_calculate("end of this year", &base_date, zone.clone()).unwrap(); |
| 794 | assert_eq!(end_of_year.day(), 31); |
| 795 | assert_eq!(end_of_year.month(), 12); |
| 796 | assert_eq!(end_of_year.year(), 2024); |
| 797 | } |
| 798 | |
| 799 | #[test] |
| 800 | fn test_complex_expressions() { |
| 801 | let parser = RelativeDateParser::new(); |
| 802 | let base_date = get_test_base_date(); // Wednesday, June 12, 2024 |
| 803 | let zone = CalClockZone::utc(); |
| 804 | |
| 805 | // The Tuesday after next |
| 806 | let expr = parser.parse("after next Tuesday").unwrap(); |
| 807 | assert_eq!(expr.reference, RelativeReference::AfterNext); |
| 808 | assert_eq!(expr.unit, RelativeUnit::DayOfWeek(DayOfWeek::Tuesday)); |
| 809 | |
| 810 | let tuesday_after_next = parser.calculate_date(&expr, &base_date, zone.clone()).unwrap(); |
| 811 | assert_eq!(tuesday_after_next.day(), 25); // June 25, 2024 |
| 812 | assert_eq!(tuesday_after_next.day_of_week(), DayOfWeek::Tuesday); |
| 813 | } |
| 814 | |
| 815 | #[test] |
| 816 | fn test_edge_cases() { |
| 817 | let parser = RelativeDateParser::new(); |
| 818 | let zone = CalClockZone::utc(); |
| 819 | |
| 820 | // Test from different days of week |
| 821 | let sunday = CalendarDate::from_ymd(2024, MonthOfYear::June, 16, zone.clone()).unwrap(); // Sunday |
| 822 | |
| 823 | // Next Monday from Sunday should be tomorrow |
| 824 | let next_monday = parser.parse_and_calculate("next Monday", &sunday, zone.clone()).unwrap(); |
| 825 | assert_eq!(next_monday.day(), 17); |
| 826 | assert_eq!(next_monday.day_of_week(), DayOfWeek::Monday); |
| 827 | |
| 828 | // This Monday from Sunday should be tomorrow (since "this week" includes future days) |
| 829 | let this_monday = parser.parse_and_calculate("this Monday", &sunday, zone.clone()).unwrap(); |
| 830 | assert_eq!(this_monday.day(), 17); |
| 831 | assert_eq!(this_monday.day_of_week(), DayOfWeek::Monday); |
| 832 | } |
| 833 | } |