1use jiff::civil::{Date, DateTime, Time};
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum PatternToken {
30 Segment(SegmentKind),
32 Literal(String),
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
38pub enum SegmentKind {
39 Year,
41 Month,
43 MonthShort,
45 Day,
47 DayShort,
49 Hour24,
51 Hour24Short,
53 Hour12,
55 Hour12Short,
57 Minute,
59 MinuteShort,
61 Second,
63 SecondShort,
65 Period,
67}
68
69impl SegmentKind {
70 pub fn max_digits(self) -> usize {
72 match self {
73 Self::Year => 4,
74 Self::Month | Self::Day | Self::Hour24 | Self::Hour12 | Self::Minute | Self::Second => {
75 2
76 }
77 Self::MonthShort
78 | Self::DayShort
79 | Self::Hour24Short
80 | Self::Hour12Short
81 | Self::MinuteShort
82 | Self::SecondShort => 2,
83 Self::Period => 0,
84 }
85 }
86
87 pub fn value_range(self) -> Option<(i32, i32)> {
90 match self {
91 Self::Year => Some((-9999, 9999)),
92 Self::Month | Self::MonthShort => Some((1, 12)),
93 Self::Day | Self::DayShort => Some((1, 31)),
94 Self::Hour24 | Self::Hour24Short => Some((0, 23)),
95 Self::Hour12 | Self::Hour12Short => Some((1, 12)),
96 Self::Minute | Self::MinuteShort | Self::Second | Self::SecondShort => Some((0, 59)),
97 Self::Period => None,
98 }
99 }
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct ParsedPattern {
106 pub tokens: Vec<PatternToken>,
107}
108
109impl ParsedPattern {
110 pub fn parse(pattern: &str) -> Result<Self, PatternError> {
112 let mut tokens = Vec::new();
113 let mut literal = String::new();
114 let mut chars = pattern.chars().peekable();
115
116 let flush_literal = |lit: &mut String, tokens: &mut Vec<PatternToken>| {
117 if !lit.is_empty() {
118 tokens.push(PatternToken::Literal(std::mem::take(lit)));
119 }
120 };
121
122 while let Some(c) = chars.next() {
123 if c != '%' {
124 literal.push(c);
125 continue;
126 }
127 let no_pad = matches!(chars.peek(), Some('-'));
129 if no_pad {
130 chars.next();
131 }
132 let Some(d) = chars.next() else {
133 return Err(PatternError::TrailingPercent);
134 };
135 let segment = match (no_pad, d) {
136 (false, 'Y') => SegmentKind::Year,
137 (true, 'Y') => SegmentKind::Year,
138 (false, 'm') => SegmentKind::Month,
139 (true, 'm') => SegmentKind::MonthShort,
140 (false, 'd') => SegmentKind::Day,
141 (true, 'd') => SegmentKind::DayShort,
142 (false, 'H') => SegmentKind::Hour24,
143 (true, 'H') => SegmentKind::Hour24Short,
144 (false, 'I') => SegmentKind::Hour12,
145 (true, 'I') => SegmentKind::Hour12Short,
146 (false, 'M') => SegmentKind::Minute,
147 (true, 'M') => SegmentKind::MinuteShort,
148 (false, 'S') => SegmentKind::Second,
149 (true, 'S') => SegmentKind::SecondShort,
150 (false, 'p') => SegmentKind::Period,
151 (false, '%') => {
152 literal.push('%');
153 continue;
154 }
155 (no_pad, ch) => {
156 return Err(PatternError::UnsupportedDirective {
157 directive: ch,
158 with_dash_modifier: no_pad,
159 });
160 }
161 };
162 flush_literal(&mut literal, &mut tokens);
163 tokens.push(PatternToken::Segment(segment));
164 }
165 flush_literal(&mut literal, &mut tokens);
166 Ok(Self { tokens })
167 }
168
169 pub fn segments(&self) -> impl Iterator<Item = SegmentKind> + '_ {
171 self.tokens.iter().filter_map(|t| match t {
172 PatternToken::Segment(k) => Some(*k),
173 _ => None,
174 })
175 }
176}
177
178#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
180pub enum PatternError {
181 #[error("trailing `%` at end of pattern")]
183 TrailingPercent,
184 #[error(
186 "{}",
187 if *with_dash_modifier {
188 format!("unsupported directive `%-{directive}` in pattern")
189 } else {
190 format!("unsupported directive `%{directive}` in pattern")
191 }
192 )]
193 UnsupportedDirective {
194 directive: char,
195 with_dash_modifier: bool,
196 },
197}
198
199pub fn segment_value_for_date(d: Date, kind: SegmentKind) -> Option<i32> {
202 Some(match kind {
203 SegmentKind::Year => d.year() as i32,
204 SegmentKind::Month | SegmentKind::MonthShort => d.month() as i32,
205 SegmentKind::Day | SegmentKind::DayShort => d.day() as i32,
206 _ => return None,
207 })
208}
209
210pub fn segment_value_for_time(t: Time, kind: SegmentKind) -> Option<i32> {
212 Some(match kind {
213 SegmentKind::Hour24 | SegmentKind::Hour24Short => t.hour() as i32,
214 SegmentKind::Hour12 | SegmentKind::Hour12Short => {
215 let h = t.hour() as i32;
216 let h12 = h % 12;
217 if h12 == 0 { 12 } else { h12 }
218 }
219 SegmentKind::Minute | SegmentKind::MinuteShort => t.minute() as i32,
220 SegmentKind::Second | SegmentKind::SecondShort => t.second() as i32,
221 SegmentKind::Period => {
222 if t.hour() < 12 {
223 0
224 } else {
225 1
226 }
227 }
228 _ => return None,
229 })
230}
231
232pub fn render_segment(kind: SegmentKind, value: i32) -> String {
234 match kind {
235 SegmentKind::Year => {
236 if value < 0 {
237 format!("-{:04}", value.unsigned_abs())
238 } else {
239 format!("{:04}", value)
240 }
241 }
242 SegmentKind::Month
243 | SegmentKind::Day
244 | SegmentKind::Hour24
245 | SegmentKind::Hour12
246 | SegmentKind::Minute
247 | SegmentKind::Second => format!("{:02}", value),
248 SegmentKind::MonthShort
249 | SegmentKind::DayShort
250 | SegmentKind::Hour24Short
251 | SegmentKind::Hour12Short
252 | SegmentKind::MinuteShort
253 | SegmentKind::SecondShort => format!("{}", value),
254 SegmentKind::Period => {
255 if value == 0 {
256 "AM".to_string()
257 } else {
258 "PM".to_string()
259 }
260 }
261 }
262}
263
264pub fn format_value(pattern: &ParsedPattern, date: Option<Date>, time: Option<Time>) -> String {
266 let mut out = String::new();
267 for token in &pattern.tokens {
268 match token {
269 PatternToken::Literal(s) => out.push_str(s),
270 PatternToken::Segment(kind) => {
271 let value = match (date, time) {
272 (Some(d), _) if segment_value_for_date(d, *kind).is_some() => {
273 segment_value_for_date(d, *kind).expect("guarded by is_some() above")
274 }
275 (_, Some(t)) => segment_value_for_time(t, *kind).unwrap_or(0),
276 (Some(d), None) => segment_value_for_date(d, *kind).unwrap_or(0),
277 (None, None) => 0,
278 };
279 out.push_str(&render_segment(*kind, value));
280 }
281 }
282 }
283 out
284}
285
286#[derive(Debug, Clone, Copy, PartialEq, Eq)]
288pub enum ParseTarget {
289 DateOnly,
290 TimeOnly,
291 DateTime,
292}
293
294#[derive(Debug, Clone, Copy, PartialEq, Eq)]
296pub enum ParsedValue {
297 Date(Date),
298 Time(Time),
299 DateTime(DateTime),
300}
301
302pub fn parse_value(
329 pattern: &ParsedPattern,
330 input: &str,
331 target: ParseTarget,
332) -> Option<ParsedValue> {
333 let mut cursor = input;
334 let mut year: Option<i16> = None;
335 let mut month: Option<i8> = None;
336 let mut day: Option<i8> = None;
337 let mut hour24: Option<i8> = None;
338 let mut hour12: Option<i8> = None;
339 let mut minute: Option<i8> = None;
340 let mut second: Option<i8> = None;
341 let mut period: Option<i8> = None; for token in &pattern.tokens {
344 if cursor.is_empty() {
351 break;
352 }
353 match token {
354 PatternToken::Literal(lit) => {
355 if let Some(trimmed) = cursor.strip_prefix(lit.as_str()) {
361 cursor = trimmed;
362 } else if lit.starts_with(cursor) {
363 cursor = "";
367 } else {
368 return None;
371 }
372 }
373 PatternToken::Segment(kind) => {
374 if matches!(kind, SegmentKind::Period) {
375 let first_char = cursor.chars().next()?;
379 let upper = first_char.to_ascii_uppercase();
380 match upper {
381 'A' => {
382 period = Some(0);
383 cursor = consume_period_letters(cursor);
384 }
385 'P' => {
386 period = Some(1);
387 cursor = consume_period_letters(cursor);
388 }
389 _ => return None,
390 }
391 continue;
392 }
393 let max = kind.max_digits();
394 let Some((digits, rest)) = take_digits(cursor, max) else {
395 break;
399 };
400 cursor = rest;
401 let v: i32 = digits.parse().ok()?;
402 let (lo, hi) = kind.value_range().unwrap_or((i32::MIN, i32::MAX));
403 if v < lo || v > hi {
404 return None;
405 }
406 match kind {
407 SegmentKind::Year => year = Some(v as i16),
408 SegmentKind::Month | SegmentKind::MonthShort => month = Some(v as i8),
409 SegmentKind::Day | SegmentKind::DayShort => day = Some(v as i8),
410 SegmentKind::Hour24 | SegmentKind::Hour24Short => hour24 = Some(v as i8),
411 SegmentKind::Hour12 | SegmentKind::Hour12Short => hour12 = Some(v as i8),
412 SegmentKind::Minute | SegmentKind::MinuteShort => minute = Some(v as i8),
413 SegmentKind::Second | SegmentKind::SecondShort => second = Some(v as i8),
414 SegmentKind::Period => unreachable!(),
415 }
416 }
417 }
418 }
419 if !cursor.trim().is_empty() {
422 return None;
423 }
424
425 let hour = match (hour24, hour12, period) {
427 (Some(h), _, _) => h,
428 (None, Some(h12), Some(p)) => {
429 let base = h12 % 12;
430 base + if p == 1 { 12 } else { 0 }
431 }
432 (None, Some(h12), None) => h12 % 12, (None, None, _) => 0,
434 };
435
436 match target {
437 ParseTarget::DateOnly => {
438 let y = year?;
441 let m = month.unwrap_or(1);
442 let d = day.unwrap_or(1);
443 Date::new(y, m, d).ok().map(ParsedValue::Date)
444 }
445 ParseTarget::TimeOnly => {
446 if hour24.is_none() && hour12.is_none() {
448 return None;
449 }
450 Time::new(hour, minute.unwrap_or(0), second.unwrap_or(0), 0)
451 .ok()
452 .map(ParsedValue::Time)
453 }
454 ParseTarget::DateTime => {
455 let y = year?;
456 let m = month.unwrap_or(1);
457 let d = day.unwrap_or(1);
458 let date = Date::new(y, m, d).ok()?;
459 let time = Time::new(hour, minute.unwrap_or(0), second.unwrap_or(0), 0).ok()?;
460 Some(ParsedValue::DateTime(DateTime::from_parts(date, time)))
461 }
462 }
463}
464
465pub fn mask_for_pattern(pattern: &ParsedPattern) -> String {
480 let mut out = String::new();
481 for token in &pattern.tokens {
482 match token {
483 PatternToken::Literal(s) => {
484 for c in s.chars() {
485 if matches!(
489 c,
490 '9' | '0'
491 | 'A'
492 | 'a'
493 | 'N'
494 | 'n'
495 | 'X'
496 | 'x'
497 | 'H'
498 | 'h'
499 | '>'
500 | '<'
501 | '!'
502 | '\\'
503 ) {
504 out.push('\\');
505 }
506 out.push(c);
507 }
508 }
509 PatternToken::Segment(kind) => {
510 let digits = match kind {
511 SegmentKind::Year => 4,
512 SegmentKind::Month
513 | SegmentKind::MonthShort
514 | SegmentKind::Day
515 | SegmentKind::DayShort
516 | SegmentKind::Hour24
517 | SegmentKind::Hour24Short
518 | SegmentKind::Hour12
519 | SegmentKind::Hour12Short
520 | SegmentKind::Minute
521 | SegmentKind::MinuteShort
522 | SegmentKind::Second
523 | SegmentKind::SecondShort => 2,
524 SegmentKind::Period => {
525 out.push_str(">AA");
526 continue;
527 }
528 };
529 for _ in 0..digits {
530 out.push('9');
531 }
532 }
533 }
534 }
535 out
536}
537
538pub fn segments_layout(pattern: &ParsedPattern) -> Vec<(usize, usize, SegmentKind)> {
545 let mut out = Vec::new();
546 let mut pos = 0usize;
547 for token in &pattern.tokens {
548 match token {
549 PatternToken::Literal(s) => pos += s.chars().count(),
550 PatternToken::Segment(kind) => {
551 let width = match kind {
552 SegmentKind::Year => 4,
553 SegmentKind::Period => 2,
556 _ => 2,
557 };
558 out.push((pos, pos + width, *kind));
559 pos += width;
560 }
561 }
562 }
563 out
564}
565
566pub fn segment_at_position(
573 pattern: &ParsedPattern,
574 caret_pos: usize,
575) -> Option<(usize, usize, SegmentKind)> {
576 let layout = segments_layout(pattern);
577 for &(start, end, kind) in &layout {
579 if caret_pos >= start && caret_pos < end {
580 return Some((start, end, kind));
581 }
582 }
583 layout
586 .iter()
587 .rev()
588 .find(|(_, end, _)| *end <= caret_pos)
589 .copied()
590 .or_else(|| layout.first().copied())
591}
592
593pub fn step_date_field(date: Date, kind: SegmentKind, delta: i32) -> Date {
601 match kind {
602 SegmentKind::Year => {
603 let new_year = (date.year() as i32)
606 .saturating_add(delta)
607 .clamp(-9999, 9999) as i16;
608 let last_day = Date::new(new_year, date.month(), 1)
609 .map(|d| d.last_of_month().day())
610 .unwrap_or(date.day());
611 let day = date.day().min(last_day);
612 Date::new(new_year, date.month(), day).unwrap_or(date)
613 }
614 SegmentKind::Month | SegmentKind::MonthShort => {
615 let new_month = ((date.month() as i32 - 1) + delta.rem_euclid(12)).rem_euclid(12) + 1;
618 let new_month = new_month as i8;
619 let last_day = Date::new(date.year(), new_month, 1)
620 .map(|d| d.last_of_month().day())
621 .unwrap_or(date.day());
622 let day = date.day().min(last_day);
623 Date::new(date.year(), new_month, day).unwrap_or(date)
624 }
625 SegmentKind::Day | SegmentKind::DayShort => {
626 let last_day = Date::new(date.year(), date.month(), 1)
627 .map(|d| d.last_of_month().day())
628 .unwrap_or(28) as i32;
629 let new_day =
631 ((date.day() as i32 - 1) + delta.rem_euclid(last_day)).rem_euclid(last_day) + 1;
632 Date::new(date.year(), date.month(), new_day as i8).unwrap_or(date)
633 }
634 _ => date,
635 }
636}
637
638pub fn step_time_field(time: Time, kind: SegmentKind, delta: i32) -> Time {
644 match kind {
645 SegmentKind::Hour24
646 | SegmentKind::Hour24Short
647 | SegmentKind::Hour12
648 | SegmentKind::Hour12Short => {
649 let h = (time.hour() as i32 + delta.rem_euclid(24)).rem_euclid(24) as i8;
652 Time::new(h, time.minute(), time.second(), 0).unwrap_or(time)
653 }
654 SegmentKind::Minute | SegmentKind::MinuteShort => {
655 let m = (time.minute() as i32 + delta.rem_euclid(60)).rem_euclid(60) as i8;
656 Time::new(time.hour(), m, time.second(), 0).unwrap_or(time)
657 }
658 SegmentKind::Second | SegmentKind::SecondShort => {
659 let s = (time.second() as i32 + delta.rem_euclid(60)).rem_euclid(60) as i8;
660 Time::new(time.hour(), time.minute(), s, 0).unwrap_or(time)
661 }
662 SegmentKind::Period => {
663 if delta == 0 {
664 return time;
665 }
666 let h = (time.hour() as i32 + 12).rem_euclid(24) as i8;
667 Time::new(h, time.minute(), time.second(), 0).unwrap_or(time)
668 }
669 _ => time,
670 }
671}
672
673fn consume_period_letters(cursor: &str) -> &str {
677 let mut end = 0;
678 for (i, ch) in cursor.char_indices() {
679 if ch.is_ascii_alphabetic() && end < 2 {
680 end = i + ch.len_utf8();
681 } else {
682 break;
683 }
684 }
685 &cursor[end..]
686}
687
688fn take_digits(s: &str, max: usize) -> Option<(&str, &str)> {
691 let mut end = 0;
692 for (i, ch) in s.char_indices() {
693 if ch.is_ascii_digit() && end < max {
694 end = i + ch.len_utf8();
695 } else {
696 break;
697 }
698 }
699 if end == 0 {
700 None
701 } else {
702 Some((&s[..end], &s[end..]))
703 }
704}
705
706#[cfg(test)]
707mod tests {
708 use super::*;
709
710 #[test]
711 fn parses_iso_pattern() {
712 let pat = ParsedPattern::parse("%Y-%m-%d").unwrap();
713 assert_eq!(
714 pat.tokens,
715 vec![
716 PatternToken::Segment(SegmentKind::Year),
717 PatternToken::Literal("-".to_string()),
718 PatternToken::Segment(SegmentKind::Month),
719 PatternToken::Literal("-".to_string()),
720 PatternToken::Segment(SegmentKind::Day),
721 ]
722 );
723 }
724
725 #[test]
726 fn parses_us_pattern() {
727 let pat = ParsedPattern::parse("%m/%d/%Y").unwrap();
728 let segs: Vec<_> = pat.segments().collect();
729 assert_eq!(
730 segs,
731 vec![SegmentKind::Month, SegmentKind::Day, SegmentKind::Year]
732 );
733 }
734
735 #[test]
736 fn parses_dotted_european_pattern() {
737 let pat = ParsedPattern::parse("%d.%m.%Y").unwrap();
738 assert_eq!(pat.segments().count(), 3);
739 }
740
741 #[test]
742 fn rejects_unsupported_directive() {
743 assert!(matches!(
744 ParsedPattern::parse("%Y-%B-%d"),
745 Err(PatternError::UnsupportedDirective { directive: 'B', .. })
746 ));
747 }
748
749 #[test]
750 fn rejects_trailing_percent() {
751 assert_eq!(
752 ParsedPattern::parse("%Y-%"),
753 Err(PatternError::TrailingPercent)
754 );
755 }
756
757 #[test]
758 fn round_trip_iso() {
759 let pat = ParsedPattern::parse("%Y-%m-%d").unwrap();
760 let date = Date::constant(2026, 5, 2);
761 let s = format_value(&pat, Some(date), None);
762 assert_eq!(s, "2026-05-02");
763 match parse_value(&pat, &s, ParseTarget::DateOnly) {
764 Some(ParsedValue::Date(d)) => assert_eq!(d, date),
765 other => panic!("expected Date, got {other:?}"),
766 }
767 }
768
769 #[test]
770 fn round_trip_us() {
771 let pat = ParsedPattern::parse("%m/%d/%Y").unwrap();
772 let date = Date::constant(2026, 5, 2);
773 let s = format_value(&pat, Some(date), None);
774 assert_eq!(s, "05/02/2026");
775 match parse_value(&pat, &s, ParseTarget::DateOnly) {
776 Some(ParsedValue::Date(d)) => assert_eq!(d, date),
777 other => panic!("expected Date, got {other:?}"),
778 }
779 }
780
781 #[test]
782 fn parse_rejects_out_of_range_month() {
783 let pat = ParsedPattern::parse("%Y-%m-%d").unwrap();
784 assert!(parse_value(&pat, "2026-13-02", ParseTarget::DateOnly).is_none());
785 }
786
787 #[test]
788 fn parse_rejects_invalid_separator() {
789 let pat = ParsedPattern::parse("%Y-%m-%d").unwrap();
790 assert!(parse_value(&pat, "2026/05/02", ParseTarget::DateOnly).is_none());
791 }
792
793 #[test]
794 fn time_round_trip_24h() {
795 let pat = ParsedPattern::parse("%H:%M:%S").unwrap();
796 let time = Time::new(14, 35, 7, 0).unwrap();
797 let s = format_value(&pat, None, Some(time));
798 assert_eq!(s, "14:35:07");
799 match parse_value(&pat, &s, ParseTarget::TimeOnly) {
800 Some(ParsedValue::Time(t)) => assert_eq!(t, time),
801 other => panic!("expected Time, got {other:?}"),
802 }
803 }
804
805 #[test]
806 fn time_round_trip_12h_with_period() {
807 let pat = ParsedPattern::parse("%I:%M %p").unwrap();
808 let time = Time::new(14, 35, 0, 0).unwrap();
809 let s = format_value(&pat, None, Some(time));
810 assert_eq!(s, "02:35 PM");
811 match parse_value(&pat, &s, ParseTarget::TimeOnly) {
812 Some(ParsedValue::Time(t)) => assert_eq!(t.hour(), 14),
813 other => panic!("expected Time, got {other:?}"),
814 }
815 }
816
817 #[test]
818 fn lenient_year_only_defaults_month_and_day() {
819 let pat = ParsedPattern::parse("%Y-%m-%d").unwrap();
820 match parse_value(&pat, "2026", ParseTarget::DateOnly) {
821 Some(ParsedValue::Date(d)) => assert_eq!(d, Date::constant(2026, 1, 1)),
822 other => panic!("expected Date(2026,1,1), got {other:?}"),
823 }
824 }
825
826 #[test]
827 fn lenient_year_month_only_defaults_day() {
828 let pat = ParsedPattern::parse("%Y-%m-%d").unwrap();
829 match parse_value(&pat, "2026-5", ParseTarget::DateOnly) {
830 Some(ParsedValue::Date(d)) => assert_eq!(d, Date::constant(2026, 5, 1)),
831 other => panic!("expected Date(2026,5,1), got {other:?}"),
832 }
833 }
834
835 #[test]
836 fn lenient_trailing_separator_accepted() {
837 let pat = ParsedPattern::parse("%Y-%m-%d").unwrap();
838 match parse_value(&pat, "2026-", ParseTarget::DateOnly) {
839 Some(ParsedValue::Date(d)) => assert_eq!(d, Date::constant(2026, 1, 1)),
840 other => panic!("expected Date(2026,1,1), got {other:?}"),
841 }
842 match parse_value(&pat, "2026-5-", ParseTarget::DateOnly) {
843 Some(ParsedValue::Date(d)) => assert_eq!(d, Date::constant(2026, 5, 1)),
844 other => panic!("expected Date(2026,5,1), got {other:?}"),
845 }
846 }
847
848 #[test]
849 fn lenient_two_digit_month_one_digit_day() {
850 let pat = ParsedPattern::parse("%Y-%m-%d").unwrap();
854 match parse_value(&pat, "2026-5-2", ParseTarget::DateOnly) {
855 Some(ParsedValue::Date(d)) => assert_eq!(d, Date::constant(2026, 5, 2)),
856 other => panic!("expected Date(2026,5,2), got {other:?}"),
857 }
858 }
859
860 #[test]
861 fn lenient_empty_input_rejects() {
862 let pat = ParsedPattern::parse("%Y-%m-%d").unwrap();
863 assert!(parse_value(&pat, "", ParseTarget::DateOnly).is_none());
864 assert!(parse_value(&pat, " ", ParseTarget::DateOnly).is_none());
865 }
866
867 #[test]
868 fn lenient_us_pattern_partial_input() {
869 let pat = ParsedPattern::parse("%m/%d/%Y").unwrap();
872 match parse_value(&pat, "5", ParseTarget::DateOnly) {
873 None => {}
876 other => panic!("expected None (no year), got {other:?}"),
877 }
878 match parse_value(&pat, "5/2/2026", ParseTarget::DateOnly) {
879 Some(ParsedValue::Date(d)) => assert_eq!(d, Date::constant(2026, 5, 2)),
880 other => panic!("expected Date, got {other:?}"),
881 }
882 }
883
884 #[test]
885 fn lenient_time_partial() {
886 let pat = ParsedPattern::parse("%H:%M:%S").unwrap();
887 match parse_value(&pat, "14", ParseTarget::TimeOnly) {
889 Some(ParsedValue::Time(t)) => assert_eq!(t, Time::new(14, 0, 0, 0).unwrap()),
890 other => panic!("expected Time(14:00:00), got {other:?}"),
891 }
892 match parse_value(&pat, "14:35", ParseTarget::TimeOnly) {
894 Some(ParsedValue::Time(t)) => assert_eq!(t, Time::new(14, 35, 0, 0).unwrap()),
895 other => panic!("expected Time(14:35:00), got {other:?}"),
896 }
897 }
898
899 #[test]
900 fn mask_for_iso_date() {
901 let pat = ParsedPattern::parse("%Y-%m-%d").unwrap();
902 assert_eq!(mask_for_pattern(&pat), "9999-99-99");
903 }
904
905 #[test]
906 fn mask_for_us_date() {
907 let pat = ParsedPattern::parse("%m/%d/%Y").unwrap();
908 assert_eq!(mask_for_pattern(&pat), "99/99/9999");
909 }
910
911 #[test]
912 fn mask_for_european_date() {
913 let pat = ParsedPattern::parse("%d.%m.%Y").unwrap();
914 assert_eq!(mask_for_pattern(&pat), "99.99.9999");
915 }
916
917 #[test]
918 fn mask_for_24h_time() {
919 let pat = ParsedPattern::parse("%H:%M").unwrap();
920 assert_eq!(mask_for_pattern(&pat), "99:99");
921 let pat = ParsedPattern::parse("%H:%M:%S").unwrap();
922 assert_eq!(mask_for_pattern(&pat), "99:99:99");
923 }
924
925 #[test]
926 fn mask_for_12h_time() {
927 let pat = ParsedPattern::parse("%I:%M %p").unwrap();
928 assert_eq!(mask_for_pattern(&pat), "99:99 >AA");
929 }
930
931 #[test]
932 fn segment_kind_max_digits() {
933 assert_eq!(SegmentKind::Year.max_digits(), 4);
934 assert_eq!(SegmentKind::Month.max_digits(), 2);
935 assert_eq!(SegmentKind::Day.max_digits(), 2);
936 assert_eq!(SegmentKind::Period.max_digits(), 0);
937 }
938
939 #[test]
942 fn segments_layout_iso_pattern() {
943 let pat = ParsedPattern::parse("%Y-%m-%d").unwrap();
944 assert_eq!(
945 segments_layout(&pat),
946 vec![
947 (0, 4, SegmentKind::Year),
948 (5, 7, SegmentKind::Month),
949 (8, 10, SegmentKind::Day),
950 ]
951 );
952 }
953
954 #[test]
955 fn segments_layout_with_period() {
956 let pat = ParsedPattern::parse("%I:%M %p").unwrap();
957 assert_eq!(
958 segments_layout(&pat),
959 vec![
960 (0, 2, SegmentKind::Hour12),
961 (3, 5, SegmentKind::Minute),
962 (6, 8, SegmentKind::Period),
963 ]
964 );
965 }
966
967 #[test]
968 fn segment_at_position_inside_segments() {
969 let pat = ParsedPattern::parse("%Y-%m-%d").unwrap();
970 assert_eq!(
972 segment_at_position(&pat, 0).map(|s| s.2),
973 Some(SegmentKind::Year)
974 );
975 assert_eq!(
976 segment_at_position(&pat, 3).map(|s| s.2),
977 Some(SegmentKind::Year)
978 );
979 assert_eq!(
981 segment_at_position(&pat, 5).map(|s| s.2),
982 Some(SegmentKind::Month)
983 );
984 assert_eq!(
985 segment_at_position(&pat, 6).map(|s| s.2),
986 Some(SegmentKind::Month)
987 );
988 assert_eq!(
990 segment_at_position(&pat, 9).map(|s| s.2),
991 Some(SegmentKind::Day)
992 );
993 }
994
995 #[test]
996 fn segment_at_position_on_separator_snaps_left() {
997 let pat = ParsedPattern::parse("%Y-%m-%d").unwrap();
998 assert_eq!(
1001 segment_at_position(&pat, 4).map(|s| s.2),
1002 Some(SegmentKind::Year)
1003 );
1004 assert_eq!(
1006 segment_at_position(&pat, 7).map(|s| s.2),
1007 Some(SegmentKind::Month)
1008 );
1009 assert_eq!(
1011 segment_at_position(&pat, 10).map(|s| s.2),
1012 Some(SegmentKind::Day)
1013 );
1014 }
1015
1016 #[test]
1019 fn step_year_basic_increment() {
1020 let d = Date::new(2026, 5, 15).unwrap();
1021 assert_eq!(
1022 step_date_field(d, SegmentKind::Year, 1),
1023 Date::new(2027, 5, 15).unwrap()
1024 );
1025 assert_eq!(
1026 step_date_field(d, SegmentKind::Year, -10),
1027 Date::new(2016, 5, 15).unwrap()
1028 );
1029 }
1030
1031 #[test]
1032 fn step_year_clamps_feb_29() {
1033 let leap = Date::new(2024, 2, 29).unwrap();
1034 assert_eq!(
1036 step_date_field(leap, SegmentKind::Year, 1),
1037 Date::new(2025, 2, 28).unwrap()
1038 );
1039 }
1040
1041 #[test]
1042 fn step_fields_dont_overflow_on_extreme_delta() {
1043 let d = Date::new(2026, 5, 15).unwrap();
1046 let _ = step_date_field(d, SegmentKind::Year, i32::MAX);
1048 let _ = step_date_field(d, SegmentKind::Year, i32::MIN);
1049 assert_eq!(
1051 step_date_field(d, SegmentKind::Month, i32::MAX),
1052 step_date_field(d, SegmentKind::Month, i32::MAX.rem_euclid(12)),
1053 );
1054 let _ = step_date_field(d, SegmentKind::Day, i32::MIN);
1055 let t = Time::new(10, 30, 0, 0).unwrap();
1056 let _ = step_time_field(t, SegmentKind::Hour24, i32::MAX);
1057 let _ = step_time_field(t, SegmentKind::Minute, i32::MIN);
1058 let _ = step_time_field(t, SegmentKind::Second, i32::MAX);
1059 }
1060
1061 #[test]
1062 fn step_month_wraps_within_year() {
1063 let d = Date::new(2026, 12, 5).unwrap();
1065 assert_eq!(
1066 step_date_field(d, SegmentKind::Month, 1),
1067 Date::new(2026, 1, 5).unwrap()
1068 );
1069 let d = Date::new(2026, 1, 5).unwrap();
1071 assert_eq!(
1072 step_date_field(d, SegmentKind::Month, -1),
1073 Date::new(2026, 12, 5).unwrap()
1074 );
1075 }
1076
1077 #[test]
1078 fn step_month_clamps_day() {
1079 let d = Date::new(2026, 3, 31).unwrap();
1081 assert_eq!(
1082 step_date_field(d, SegmentKind::Month, -1),
1083 Date::new(2026, 2, 28).unwrap()
1084 );
1085 }
1086
1087 #[test]
1088 fn step_day_wraps_within_month() {
1089 let d = Date::new(2026, 3, 31).unwrap();
1091 assert_eq!(
1092 step_date_field(d, SegmentKind::Day, 1),
1093 Date::new(2026, 3, 1).unwrap()
1094 );
1095 let d = Date::new(2026, 3, 1).unwrap();
1097 assert_eq!(
1098 step_date_field(d, SegmentKind::Day, -1),
1099 Date::new(2026, 3, 31).unwrap()
1100 );
1101 }
1102
1103 #[test]
1106 fn step_hour_wraps_24h() {
1107 let t = Time::new(23, 30, 0, 0).unwrap();
1108 assert_eq!(
1109 step_time_field(t, SegmentKind::Hour24, 1),
1110 Time::new(0, 30, 0, 0).unwrap()
1111 );
1112 let t = Time::new(0, 30, 0, 0).unwrap();
1113 assert_eq!(
1114 step_time_field(t, SegmentKind::Hour24, -1),
1115 Time::new(23, 30, 0, 0).unwrap()
1116 );
1117 }
1118
1119 #[test]
1120 fn step_minute_wraps_60() {
1121 let t = Time::new(10, 59, 0, 0).unwrap();
1122 assert_eq!(
1123 step_time_field(t, SegmentKind::Minute, 1),
1124 Time::new(10, 0, 0, 0).unwrap()
1125 );
1126 }
1127
1128 #[test]
1129 fn step_period_toggles_am_pm() {
1130 let am = Time::new(9, 0, 0, 0).unwrap();
1131 assert_eq!(
1132 step_time_field(am, SegmentKind::Period, 1),
1133 Time::new(21, 0, 0, 0).unwrap()
1134 );
1135 let pm = Time::new(15, 30, 0, 0).unwrap();
1136 assert_eq!(
1137 step_time_field(pm, SegmentKind::Period, -1),
1138 Time::new(3, 30, 0, 0).unwrap()
1139 );
1140 assert_eq!(step_time_field(am, SegmentKind::Period, 0), am);
1142 }
1143}