1use std::cmp::Ordering;
44use std::fmt;
45use std::str::FromStr;
46
47use jiff::civil;
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
71pub struct UtcOffset {
72 pub minutes: i16,
74}
75
76impl UtcOffset {
77 pub const UTC: Self = Self { minutes: 0 };
79
80 pub fn from_hhmm(sign: i8, hours: u8, minutes: u8) -> Option<Self> {
86 if !matches!(sign, 1 | -1) || hours > 23 || minutes > 59 {
87 return None;
88 }
89 let total = (hours as i16 * 60 + minutes as i16) * sign as i16;
90 Some(Self { minutes: total })
91 }
92
93 pub fn to_seconds(self) -> i32 {
95 self.minutes as i32 * 60
96 }
97}
98
99impl fmt::Display for UtcOffset {
100 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101 if self.minutes == 0 {
102 write!(f, "Z")
103 } else {
104 let sign = if self.minutes >= 0 { '+' } else { '-' };
105 let abs = self.minutes.unsigned_abs();
106 write!(f, "{}{:02}:{:02}", sign, abs / 60, abs % 60)
107 }
108 }
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Hash)]
152pub enum IsmDate {
153 Year(i32),
156
157 YearMonth(i32, u8),
160
161 Date(i32, u8, u8),
168
169 DateHourMin {
177 year: i32,
178 month: u8,
179 day: u8,
180 hour: u8,
181 minute: u8,
182 offset: Option<UtcOffset>,
184 },
185
186 DateTime {
189 year: i32,
190 month: u8,
191 day: u8,
192 hour: u8,
193 minute: u8,
194 second: u8,
195 nanosecond: u32,
197 offset: Option<UtcOffset>,
199 },
200}
201
202impl IsmDate {
203 #[inline]
209 pub fn year(&self) -> i32 {
210 match self {
211 IsmDate::Year(y) => *y,
212 IsmDate::YearMonth(y, _) => *y,
213 IsmDate::Date(y, _, _) => *y,
214 IsmDate::DateHourMin { year, .. } => *year,
215 IsmDate::DateTime { year, .. } => *year,
216 }
217 }
218
219 #[inline]
221 pub fn month(&self) -> Option<u8> {
222 match self {
223 IsmDate::Year(_) => None,
224 IsmDate::YearMonth(_, m) => Some(*m),
225 IsmDate::Date(_, m, _) => Some(*m),
226 IsmDate::DateHourMin { month, .. } => Some(*month),
227 IsmDate::DateTime { month, .. } => Some(*month),
228 }
229 }
230
231 #[inline]
233 pub fn day(&self) -> Option<u8> {
234 match self {
235 IsmDate::Year(_) | IsmDate::YearMonth(_, _) => None,
236 IsmDate::Date(_, _, d) => Some(*d),
237 IsmDate::DateHourMin { day, .. } => Some(*day),
238 IsmDate::DateTime { day, .. } => Some(*day),
239 }
240 }
241
242 pub fn contains(&self, point: &IsmDate) -> bool {
272 if self.year() != point.year() {
274 return false;
275 }
276 match self {
277 IsmDate::Year(_) => {
278 true
280 }
281 IsmDate::YearMonth(_, sm) => {
282 match point {
285 IsmDate::Year(_) => false,
286 _ => point.month() == Some(*sm),
287 }
288 }
289 IsmDate::Date(_, sm, sd) => {
290 match point {
293 IsmDate::Year(_) | IsmDate::YearMonth(_, _) => false,
294 _ => point.month() == Some(*sm) && point.day() == Some(*sd),
295 }
296 }
297 IsmDate::DateHourMin {
298 month: sm,
299 day: sd,
300 hour: sh,
301 minute: smin,
302 offset: soff,
303 ..
304 } => {
305 match point {
307 IsmDate::DateHourMin {
308 month,
309 day,
310 hour,
311 minute,
312 offset,
313 ..
314 } => month == sm && day == sd && hour == sh && minute == smin && offset == soff,
315 IsmDate::DateTime {
316 month,
317 day,
318 hour,
319 minute,
320 offset,
321 ..
322 } => month == sm && day == sd && hour == sh && minute == smin && offset == soff,
323 _ => false,
324 }
325 }
326 IsmDate::DateTime {
327 month: sm,
328 day: sd,
329 hour: sh,
330 minute: smin,
331 second: ss,
332 nanosecond: sns,
333 offset: soff,
334 ..
335 } => {
336 if let IsmDate::DateTime {
337 month,
338 day,
339 hour,
340 minute,
341 second,
342 nanosecond,
343 offset,
344 ..
345 } = point
346 {
347 month == sm
348 && day == sd
349 && hour == sh
350 && minute == smin
351 && second == ss
352 && nanosecond == sns
353 && offset == soff
354 } else {
355 false
356 }
357 }
358 }
359 }
360
361 pub fn end_cmp(&self, other: &IsmDate) -> Ordering {
386 let a = self.end_components();
387 let b = other.end_components();
388 a.cmp(&b)
389 }
390
391 pub fn to_maxdate_str(&self) -> Box<str> {
403 let (y, m, d, _, _, _, _, _) = self.end_components();
404 format!("{:04}{:02}{:02}", y, m, d).into_boxed_str()
405 }
406
407 fn end_components(&self) -> (i32, u8, u8, u8, u8, u8, u32, i16) {
422 match self {
423 IsmDate::Year(y) => (*y, 12, 31, 23, 59, 59, 999_999_999, 0),
424 IsmDate::YearMonth(y, m) => {
425 let d = days_in_month(*y, *m);
426 (*y, *m, d, 23, 59, 59, 999_999_999, 0)
427 }
428 IsmDate::Date(y, m, d) => (*y, *m, *d, 23, 59, 59, 999_999_999, 0),
429 IsmDate::DateHourMin {
430 year,
431 month,
432 day,
433 hour,
434 minute,
435 offset,
436 } => {
437 let utc_tb = offset.map_or(0_i16, |o| -o.minutes);
439 (*year, *month, *day, *hour, *minute, 59, 999_999_999, utc_tb)
440 }
441 IsmDate::DateTime {
442 year,
443 month,
444 day,
445 hour,
446 minute,
447 second,
448 nanosecond,
449 offset,
450 } => {
451 let utc_tb = offset.map_or(0_i16, |o| -o.minutes);
452 (
453 *year,
454 *month,
455 *day,
456 *hour,
457 *minute,
458 *second,
459 *nanosecond,
460 utc_tb,
461 )
462 }
463 }
464 }
465}
466
467impl fmt::Display for IsmDate {
472 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
473 match self {
474 IsmDate::Year(y) => write!(f, "{:04}", y),
475 IsmDate::YearMonth(y, m) => write!(f, "{:04}-{:02}", y, m),
476 IsmDate::Date(y, m, d) => write!(f, "{:04}-{:02}-{:02}", y, m, d),
477 IsmDate::DateHourMin {
478 year,
479 month,
480 day,
481 hour,
482 minute,
483 offset,
484 } => {
485 write!(
486 f,
487 "{:04}-{:02}-{:02}T{:02}:{:02}",
488 year, month, day, hour, minute
489 )?;
490 if let Some(o) = offset {
491 write!(f, "{o}")?;
492 }
493 Ok(())
494 }
495 IsmDate::DateTime {
496 year,
497 month,
498 day,
499 hour,
500 minute,
501 second,
502 nanosecond,
503 offset,
504 } => {
505 write!(
506 f,
507 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}",
508 year, month, day, hour, minute, second
509 )?;
510 if *nanosecond > 0 {
511 let s = format!("{:09}", nanosecond);
513 let trimmed = s.trim_end_matches('0');
514 write!(f, ".{trimmed}")?;
515 }
516 if let Some(o) = offset {
517 write!(f, "{o}")?;
518 }
519 Ok(())
520 }
521 }
522 }
523}
524
525#[derive(Debug, Clone, PartialEq, Eq)]
531pub struct ParseIsmDateError {
532 msg: &'static str,
533}
534
535impl fmt::Display for ParseIsmDateError {
536 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
537 write!(f, "invalid ISM date: {}", self.msg)
538 }
539}
540
541impl std::error::Error for ParseIsmDateError {}
542
543impl ParseIsmDateError {
544 const fn new(msg: &'static str) -> Self {
545 Self { msg }
546 }
547}
548
549impl FromStr for IsmDate {
550 type Err = ParseIsmDateError;
551
552 fn from_str(s: &str) -> Result<Self, Self::Err> {
553 parse_ism_date(s)
554 }
555}
556
557impl FromStr for UtcOffset {
558 type Err = ParseIsmDateError;
559
560 fn from_str(s: &str) -> Result<Self, Self::Err> {
569 match s {
570 "Z" => Ok(UtcOffset::UTC),
571 _ if (s.starts_with('+') || s.starts_with('-')) && s.len() == 6 => {
572 let b = s.as_bytes();
573 if b[3] != b':' {
575 return Err(ParseIsmDateError::new(
576 "UTC offset missing ':' separator (expected ±HH:MM)",
577 ));
578 }
579 let sign: i8 = if s.starts_with('+') { 1 } else { -1 };
580 let oh =
581 parse_2digits(&b[1..3]).ok_or(ParseIsmDateError::new("invalid offset hour"))?;
582 let om = parse_2digits(&b[4..6])
583 .ok_or(ParseIsmDateError::new("invalid offset minute"))?;
584 UtcOffset::from_hhmm(sign, oh, om)
585 .ok_or(ParseIsmDateError::new("UTC offset out of range"))
586 }
587 _ => Err(ParseIsmDateError::new(
588 "unrecognized UTC offset (expected Z or ±HH:MM)",
589 )),
590 }
591 }
592}
593
594fn parse_ism_date(s: &str) -> Result<IsmDate, ParseIsmDateError> {
601 let bytes = s.as_bytes();
602 match bytes.len() {
603 4 if all_ascii_digits(bytes) => {
605 let y = parse_4digit_year(bytes)?;
606 Ok(IsmDate::Year(y))
607 }
608 8 if all_ascii_digits(bytes) => {
610 let y = parse_4digit_year(&bytes[0..4])?;
611 let m = parse_2digits(&bytes[4..6])
612 .ok_or(ParseIsmDateError::new("invalid month digits"))?;
613 let d =
614 parse_2digits(&bytes[6..8]).ok_or(ParseIsmDateError::new("invalid day digits"))?;
615 validate_date(y, m, d)?;
616 Ok(IsmDate::Date(y, m, d))
617 }
618 7 if bytes[4] == b'-' => {
620 let y = parse_4digit_year(&bytes[0..4])?;
621 let m = parse_2digits(&bytes[5..7])
622 .ok_or(ParseIsmDateError::new("invalid month digits"))?;
623 validate_year_month(y, m)?;
624 Ok(IsmDate::YearMonth(y, m))
625 }
626 10 if bytes[4] == b'-' && bytes[7] == b'-' => {
628 let y = parse_4digit_year(&bytes[0..4])?;
629 let m = parse_2digits(&bytes[5..7])
630 .ok_or(ParseIsmDateError::new("invalid month digits"))?;
631 let d =
632 parse_2digits(&bytes[8..10]).ok_or(ParseIsmDateError::new("invalid day digits"))?;
633 validate_date(y, m, d)?;
634 Ok(IsmDate::Date(y, m, d))
635 }
636 _ if bytes.len() >= 16
638 && bytes[4] == b'-'
639 && bytes[7] == b'-'
640 && bytes[10] == b'T'
641 && bytes[13] == b':' =>
642 {
643 parse_datetime_or_hourmind(s)
644 }
645 _ => Err(ParseIsmDateError::new("unrecognized date format")),
646 }
647}
648
649fn parse_datetime_or_hourmind(s: &str) -> Result<IsmDate, ParseIsmDateError> {
652 if !s.is_ascii() {
655 return Err(ParseIsmDateError::new(
656 "date string contains non-ASCII characters",
657 ));
658 }
659 let bytes = s.as_bytes();
660
661 let y = parse_4digit_year(&bytes[0..4])?;
662 let m = parse_2digits(&bytes[5..7]).ok_or(ParseIsmDateError::new("invalid month digits"))?;
663 let d = parse_2digits(&bytes[8..10]).ok_or(ParseIsmDateError::new("invalid day digits"))?;
664 validate_date(y, m, d)?;
665
666 let h = parse_2digits(&bytes[11..13]).ok_or(ParseIsmDateError::new("invalid hour digits"))?;
667 let min =
668 parse_2digits(&bytes[14..16]).ok_or(ParseIsmDateError::new("invalid minute digits"))?;
669 if h > 23 {
670 return Err(ParseIsmDateError::new("hour out of range"));
671 }
672 if min > 59 {
673 return Err(ParseIsmDateError::new("minute out of range"));
674 }
675
676 let rest = &s[16..];
677
678 if rest.is_empty() || rest.starts_with('Z') || rest.starts_with('+') || rest.starts_with('-') {
681 let offset = parse_offset(rest)?;
682 return Ok(IsmDate::DateHourMin {
683 year: y,
684 month: m,
685 day: d,
686 hour: h,
687 minute: min,
688 offset,
689 });
690 }
691
692 if !rest.starts_with(':') || rest.len() < 3 {
694 return Err(ParseIsmDateError::new("expected ':SS' in dateTime"));
695 }
696 let sec_bytes = &rest.as_bytes()[1..3];
697 let sec = parse_2digits(sec_bytes).ok_or(ParseIsmDateError::new("invalid second digits"))?;
698 if sec > 59 {
699 return Err(ParseIsmDateError::new("second out of range"));
700 }
701
702 let after_sec = &rest[3..];
703
704 let (nanosecond, after_frac) = if let Some(frac_str) = after_sec.strip_prefix('.') {
706 let digit_end = frac_str.bytes().take_while(|b| b.is_ascii_digit()).count();
708 if digit_end == 0 {
709 return Err(ParseIsmDateError::new("empty fractional seconds"));
710 }
711 let frac_digits = &frac_str[..digit_end];
712 let ns = parse_frac_as_nanoseconds(frac_digits)?;
714 (ns, &frac_str[digit_end..])
715 } else {
716 (0u32, after_sec)
717 };
718
719 let offset = parse_offset(after_frac)?;
720
721 Ok(IsmDate::DateTime {
722 year: y,
723 month: m,
724 day: d,
725 hour: h,
726 minute: min,
727 second: sec,
728 nanosecond,
729 offset,
730 })
731}
732
733fn parse_offset(s: &str) -> Result<Option<UtcOffset>, ParseIsmDateError> {
736 match s {
737 "" => Ok(None),
738 "Z" => Ok(Some(UtcOffset::UTC)),
739 _ if (s.starts_with('+') || s.starts_with('-')) && s.len() == 6 => {
740 let b = s.as_bytes();
741 if b[3] != b':' {
745 return Err(ParseIsmDateError::new(
746 "UTC offset missing ':' separator (expected ±HH:MM)",
747 ));
748 }
749 let sign: i8 = if s.starts_with('+') { 1 } else { -1 };
750 let oh =
751 parse_2digits(&b[1..3]).ok_or(ParseIsmDateError::new("invalid offset hour"))?;
752 let om =
753 parse_2digits(&b[4..6]).ok_or(ParseIsmDateError::new("invalid offset minute"))?;
754 UtcOffset::from_hhmm(sign, oh, om)
755 .ok_or(ParseIsmDateError::new("UTC offset out of range"))
756 .map(Some)
757 }
758 _ => Err(ParseIsmDateError::new("unrecognized timezone suffix")),
759 }
760}
761
762fn parse_frac_as_nanoseconds(frac: &str) -> Result<u32, ParseIsmDateError> {
764 if frac.len() > 9 {
765 return Err(ParseIsmDateError::new(
766 "fractional seconds: more than 9 digits",
767 ));
768 }
769 let mut padded = [b'0'; 9];
771 padded[..frac.len()].copy_from_slice(frac.as_bytes());
772 let ns: u32 = std::str::from_utf8(&padded)
773 .ok()
774 .and_then(|s| s.parse().ok())
775 .ok_or(ParseIsmDateError::new("fractional seconds not numeric"))?;
776 Ok(ns)
777}
778
779fn validate_date(year: i32, month: u8, day: u8) -> Result<(), ParseIsmDateError> {
785 let y = i16::try_from(year).map_err(|_| ParseIsmDateError::new("year out of i16 range"))?;
786 civil::Date::new(y, month as i8, day as i8)
787 .map_err(|_| ParseIsmDateError::new("invalid calendar date"))?;
788 Ok(())
789}
790
791fn validate_year_month(year: i32, month: u8) -> Result<(), ParseIsmDateError> {
793 if !(1..=12).contains(&month) {
794 return Err(ParseIsmDateError::new("month out of range 1–12"));
795 }
796 let _y = i16::try_from(year).map_err(|_| ParseIsmDateError::new("year out of i16 range"))?;
797 Ok(())
798}
799
800fn days_in_month(year: i32, month: u8) -> u8 {
802 let y = i16::try_from(year).unwrap_or(2000); civil::Date::new(y, month as i8, 1)
804 .map(|d| d.days_in_month() as u8)
805 .unwrap_or(30) }
807
808fn parse_4digit_year(bytes: &[u8]) -> Result<i32, ParseIsmDateError> {
814 if bytes.len() != 4 || !all_ascii_digits(bytes) {
815 return Err(ParseIsmDateError::new("year must be exactly 4 digits"));
816 }
817 Ok(parse_digits_as_i32(bytes))
818}
819
820#[inline]
823fn parse_2digits(bytes: &[u8]) -> Option<u8> {
824 if bytes.len() == 2 && bytes[0].is_ascii_digit() && bytes[1].is_ascii_digit() {
825 Some((bytes[0] - b'0') * 10 + (bytes[1] - b'0'))
826 } else {
827 None
828 }
829}
830
831#[inline]
834fn parse_digits_as_i32(bytes: &[u8]) -> i32 {
835 bytes
836 .iter()
837 .fold(0i32, |acc, b| acc * 10 + (*b - b'0') as i32)
838}
839
840#[inline]
842fn all_ascii_digits(bytes: &[u8]) -> bool {
843 bytes.iter().all(|b| b.is_ascii_digit())
844}
845
846#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
856pub enum ApproxQualifier {
857 FirstQtr,
859 SecondQtr,
861 ThirdQtr,
863 FourthQtr,
865 Circa,
867 Early,
869 Mid,
871 Late,
873}
874
875impl fmt::Display for ApproxQualifier {
876 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
877 let s = match self {
878 ApproxQualifier::FirstQtr => "1st qtr",
879 ApproxQualifier::SecondQtr => "2nd qtr",
880 ApproxQualifier::ThirdQtr => "3rd qtr",
881 ApproxQualifier::FourthQtr => "4th qtr",
882 ApproxQualifier::Circa => "circa",
883 ApproxQualifier::Early => "early",
884 ApproxQualifier::Mid => "mid",
885 ApproxQualifier::Late => "late",
886 };
887 f.write_str(s)
888 }
889}
890
891#[derive(Debug, Clone, PartialEq, Eq)]
893pub struct ParseApproxQualifierError;
894
895impl fmt::Display for ParseApproxQualifierError {
896 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
897 write!(f, "invalid approx qualifier")
898 }
899}
900
901impl std::error::Error for ParseApproxQualifierError {}
902
903impl FromStr for ApproxQualifier {
904 type Err = ParseApproxQualifierError;
905
906 fn from_str(s: &str) -> Result<Self, Self::Err> {
907 match s {
908 "1st qtr" => Ok(ApproxQualifier::FirstQtr),
909 "2nd qtr" => Ok(ApproxQualifier::SecondQtr),
910 "3rd qtr" => Ok(ApproxQualifier::ThirdQtr),
911 "4th qtr" => Ok(ApproxQualifier::FourthQtr),
912 "circa" => Ok(ApproxQualifier::Circa),
913 "early" => Ok(ApproxQualifier::Early),
914 "mid" => Ok(ApproxQualifier::Mid),
915 "late" => Ok(ApproxQualifier::Late),
916 _ => Err(ParseApproxQualifierError),
917 }
918 }
919}
920
921#[derive(Debug, Clone, PartialEq, Eq, Hash)]
934pub struct ApproxIsmDate {
935 pub date: IsmDate,
936 pub qualifier: Option<ApproxQualifier>,
937}
938
939impl fmt::Display for ApproxIsmDate {
940 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
941 if let Some(q) = self.qualifier {
942 write!(f, "{} {}", q, self.date)
943 } else {
944 write!(f, "{}", self.date)
945 }
946 }
947}
948
949#[cfg(test)]
954mod tests {
955 use super::*;
956 use std::str::FromStr;
957
958 fn round_trip(s: &str) -> bool {
963 IsmDate::from_str(s)
964 .map(|d| d.to_string() == s)
965 .unwrap_or(false)
966 }
967
968 #[test]
969 fn round_trip_year() {
970 assert!(round_trip("2003"));
971 assert!(round_trip("1900"));
972 assert!(round_trip("9999"));
973 }
974
975 #[test]
976 fn round_trip_year_month() {
977 assert!(round_trip("2003-04"));
978 assert!(round_trip("2003-12"));
979 assert!(round_trip("2003-01"));
980 }
981
982 #[test]
983 fn round_trip_date() {
984 assert!(round_trip("2003-04-15"));
985 assert!(round_trip("2000-02-29")); }
987
988 #[test]
989 fn round_trip_date_hour_min_utc() {
990 assert!(round_trip("2003-04-15T14:30Z"));
991 }
992
993 #[test]
994 fn round_trip_date_hour_min_offset() {
995 assert!(round_trip("2003-04-15T14:30-05:00"));
996 assert!(round_trip("2003-04-15T14:30+05:30"));
997 }
998
999 #[test]
1000 fn round_trip_date_hour_min_floating() {
1001 assert!(round_trip("2003-04-15T14:30"));
1002 }
1003
1004 #[test]
1005 fn round_trip_datetime_utc() {
1006 assert!(round_trip("2003-04-15T14:30:00Z"));
1007 }
1008
1009 #[test]
1010 fn round_trip_datetime_with_millis() {
1011 assert!(round_trip("2003-04-15T14:30:00.123Z"));
1012 }
1013
1014 #[test]
1015 fn round_trip_datetime_with_micros() {
1016 assert!(round_trip("2003-04-15T14:30:00.123456Z"));
1017 }
1018
1019 #[test]
1020 fn round_trip_datetime_floating() {
1021 assert!(round_trip("2003-04-15T14:30:00"));
1022 }
1023
1024 #[test]
1029 fn capco_yyyymmdd_parses_to_date() {
1030 let d = IsmDate::from_str("20030415").unwrap();
1031 assert_eq!(d, IsmDate::Date(2003, 4, 15));
1032 }
1033
1034 #[test]
1035 fn capco_year_only_parses_to_year() {
1036 let d = IsmDate::from_str("2035").unwrap();
1037 assert_eq!(d, IsmDate::Year(2035));
1038 }
1039
1040 #[test]
1041 fn capco_display_uses_iso_form() {
1042 let d = IsmDate::from_str("20030415").unwrap();
1043 assert_eq!(d.to_string(), "2003-04-15");
1044 }
1045
1046 #[test]
1051 fn rejects_invalid_month() {
1052 assert!(IsmDate::from_str("2003-13").is_err());
1053 assert!(IsmDate::from_str("2003-00").is_err());
1054 }
1055
1056 #[test]
1057 fn rejects_invalid_day() {
1058 assert!(IsmDate::from_str("2003-02-29").is_err()); assert!(IsmDate::from_str("2003-04-31").is_err()); }
1061
1062 #[test]
1063 fn accepts_leap_day_in_leap_year() {
1064 assert!(IsmDate::from_str("2000-02-29").is_ok()); assert!(IsmDate::from_str("2004-02-29").is_ok()); }
1067
1068 #[test]
1073 fn year_contains_same_year() {
1074 let y = IsmDate::Year(2003);
1075 assert!(y.contains(&IsmDate::Year(2003)));
1076 }
1077
1078 #[test]
1079 fn year_contains_year_month() {
1080 let y = IsmDate::Year(2003);
1081 assert!(y.contains(&IsmDate::YearMonth(2003, 4)));
1082 assert!(!y.contains(&IsmDate::YearMonth(2004, 1)));
1083 }
1084
1085 #[test]
1086 fn year_contains_date() {
1087 let y = IsmDate::Year(2003);
1088 assert!(y.contains(&IsmDate::Date(2003, 12, 31)));
1089 assert!(!y.contains(&IsmDate::Date(2004, 1, 1)));
1090 }
1091
1092 #[test]
1093 fn year_month_does_not_contain_year() {
1094 let ym = IsmDate::YearMonth(2003, 4);
1095 assert!(!ym.contains(&IsmDate::Year(2003)));
1096 }
1097
1098 #[test]
1099 fn year_month_contains_same_month_date() {
1100 let ym = IsmDate::YearMonth(2003, 4);
1101 assert!(ym.contains(&IsmDate::Date(2003, 4, 1)));
1102 assert!(ym.contains(&IsmDate::Date(2003, 4, 30)));
1103 assert!(!ym.contains(&IsmDate::Date(2003, 5, 1)));
1104 }
1105
1106 #[test]
1107 fn date_does_not_contain_coarser() {
1108 let d = IsmDate::Date(2003, 4, 15);
1109 assert!(!d.contains(&IsmDate::Year(2003)));
1110 assert!(!d.contains(&IsmDate::YearMonth(2003, 4)));
1111 }
1112
1113 #[test]
1114 fn date_contains_self() {
1115 let d = IsmDate::Date(2003, 4, 15);
1116 assert!(d.contains(&IsmDate::Date(2003, 4, 15)));
1117 }
1118
1119 #[test]
1120 fn date_contains_hour_min_on_same_day() {
1121 let d = IsmDate::Date(2003, 4, 15);
1122 assert!(d.contains(&IsmDate::DateHourMin {
1123 year: 2003,
1124 month: 4,
1125 day: 15,
1126 hour: 14,
1127 minute: 30,
1128 offset: None,
1129 }));
1130 }
1131
1132 #[test]
1133 fn date_does_not_contain_hour_min_different_day() {
1134 let d = IsmDate::Date(2003, 4, 15);
1135 assert!(!d.contains(&IsmDate::DateHourMin {
1136 year: 2003,
1137 month: 4,
1138 day: 16,
1139 hour: 0,
1140 minute: 0,
1141 offset: None,
1142 }));
1143 }
1144
1145 #[test]
1150 fn year_end_cmp_is_greater_than_mid_year_date() {
1151 let year = IsmDate::Year(2003);
1152 let mid = IsmDate::Date(2003, 6, 15);
1153 assert_eq!(year.end_cmp(&mid), Ordering::Greater);
1155 }
1156
1157 #[test]
1158 fn year_month_end_cmp_greater_than_early_date_in_month() {
1159 let ym = IsmDate::YearMonth(2003, 4); let d = IsmDate::Date(2003, 4, 1); assert_eq!(ym.end_cmp(&d), Ordering::Greater);
1162 }
1163
1164 #[test]
1165 fn date_end_cmp_greater_than_date_hour_min_same_day() {
1166 let day = IsmDate::Date(2003, 4, 15);
1170 let t = IsmDate::DateHourMin {
1171 year: 2003,
1172 month: 4,
1173 day: 15,
1174 hour: 22,
1175 minute: 30,
1176 offset: None,
1177 };
1178 assert_eq!(day.end_cmp(&t), Ordering::Greater);
1179 }
1180
1181 #[test]
1182 fn date_hour_min_end_cmp_later_time_is_greater() {
1183 let earlier = IsmDate::DateHourMin {
1184 year: 2003,
1185 month: 4,
1186 day: 15,
1187 hour: 10,
1188 minute: 0,
1189 offset: None,
1190 };
1191 let later = IsmDate::DateHourMin {
1192 year: 2003,
1193 month: 4,
1194 day: 15,
1195 hour: 14,
1196 minute: 30,
1197 offset: None,
1198 };
1199 assert_eq!(later.end_cmp(&earlier), Ordering::Greater);
1200 assert_eq!(earlier.end_cmp(&later), Ordering::Less);
1201 }
1202
1203 #[test]
1204 fn date_hour_min_end_cmp_equal_times_is_equal() {
1205 let a = IsmDate::DateHourMin {
1206 year: 2003,
1207 month: 4,
1208 day: 15,
1209 hour: 14,
1210 minute: 30,
1211 offset: None,
1212 };
1213 let b = a.clone();
1214 assert_eq!(a.end_cmp(&b), Ordering::Equal);
1215 }
1216
1217 #[test]
1218 fn date_hour_min_end_cmp_same_civil_negative_offset_is_greater() {
1219 let utc = IsmDate::DateHourMin {
1221 year: 2003,
1222 month: 4,
1223 day: 15,
1224 hour: 10,
1225 minute: 30,
1226 offset: Some(UtcOffset::UTC),
1227 };
1228 let eastern = IsmDate::DateHourMin {
1229 year: 2003,
1230 month: 4,
1231 day: 15,
1232 hour: 10,
1233 minute: 30,
1234 offset: Some(UtcOffset::from_hhmm(-1, 5, 0).unwrap()), };
1236 assert_eq!(eastern.end_cmp(&utc), Ordering::Greater);
1238 assert_eq!(utc.end_cmp(&eastern), Ordering::Less);
1239 }
1240
1241 #[test]
1242 fn date_hour_min_end_cmp_same_civil_positive_offset_is_less() {
1243 let utc = IsmDate::DateHourMin {
1245 year: 2003,
1246 month: 4,
1247 day: 15,
1248 hour: 10,
1249 minute: 30,
1250 offset: Some(UtcOffset::UTC),
1251 };
1252 let india = IsmDate::DateHourMin {
1253 year: 2003,
1254 month: 4,
1255 day: 15,
1256 hour: 10,
1257 minute: 30,
1258 offset: Some(UtcOffset::from_hhmm(1, 5, 30).unwrap()), };
1260 assert_eq!(india.end_cmp(&utc), Ordering::Less);
1262 assert_eq!(utc.end_cmp(&india), Ordering::Greater);
1263 }
1264
1265 #[test]
1266 fn to_maxdate_str_year() {
1267 assert_eq!(&*IsmDate::Year(2003).to_maxdate_str(), "20031231");
1268 }
1269
1270 #[test]
1271 fn to_maxdate_str_year_month_april() {
1272 assert_eq!(&*IsmDate::YearMonth(2003, 4).to_maxdate_str(), "20030430");
1273 }
1274
1275 #[test]
1276 fn to_maxdate_str_year_month_february_non_leap() {
1277 assert_eq!(&*IsmDate::YearMonth(2003, 2).to_maxdate_str(), "20030228");
1278 }
1279
1280 #[test]
1281 fn to_maxdate_str_year_month_february_leap() {
1282 assert_eq!(&*IsmDate::YearMonth(2000, 2).to_maxdate_str(), "20000229");
1283 }
1284
1285 #[test]
1286 fn to_maxdate_str_date() {
1287 assert_eq!(&*IsmDate::Date(2003, 4, 15).to_maxdate_str(), "20030415");
1288 }
1289
1290 #[test]
1295 fn approx_qualifier_round_trip() {
1296 for q in [
1297 ApproxQualifier::FirstQtr,
1298 ApproxQualifier::SecondQtr,
1299 ApproxQualifier::ThirdQtr,
1300 ApproxQualifier::FourthQtr,
1301 ApproxQualifier::Circa,
1302 ApproxQualifier::Early,
1303 ApproxQualifier::Mid,
1304 ApproxQualifier::Late,
1305 ] {
1306 let s = q.to_string();
1307 assert_eq!(ApproxQualifier::from_str(&s).unwrap(), q);
1308 }
1309 }
1310
1311 #[test]
1316 fn utc_offset_display_utc() {
1317 assert_eq!(UtcOffset::UTC.to_string(), "Z");
1318 }
1319
1320 #[test]
1321 fn utc_offset_display_positive() {
1322 let o = UtcOffset::from_hhmm(1, 5, 30).unwrap();
1323 assert_eq!(o.to_string(), "+05:30");
1324 }
1325
1326 #[test]
1327 fn utc_offset_display_negative() {
1328 let o = UtcOffset::from_hhmm(-1, 5, 0).unwrap();
1329 assert_eq!(o.to_string(), "-05:00");
1330 }
1331
1332 #[test]
1333 fn utc_offset_rejects_invalid() {
1334 assert!(UtcOffset::from_hhmm(1, 24, 0).is_none()); assert!(UtcOffset::from_hhmm(1, 0, 60).is_none()); }
1337
1338 #[test]
1339 fn utc_offset_from_str_z_is_utc() {
1340 assert_eq!("Z".parse::<UtcOffset>().unwrap(), UtcOffset::UTC);
1341 }
1342
1343 #[test]
1344 fn utc_offset_from_str_positive() {
1345 let o = "+05:30".parse::<UtcOffset>().unwrap();
1346 assert_eq!(o, UtcOffset::from_hhmm(1, 5, 30).unwrap());
1347 }
1348
1349 #[test]
1350 fn utc_offset_from_str_negative() {
1351 let o = "-05:00".parse::<UtcOffset>().unwrap();
1352 assert_eq!(o, UtcOffset::from_hhmm(-1, 5, 0).unwrap());
1353 }
1354
1355 #[test]
1356 fn utc_offset_from_str_round_trip() {
1357 for s in ["Z", "+05:30", "-05:00", "+23:59", "-23:59"] {
1359 let parsed: UtcOffset = s.parse().unwrap();
1360 assert_eq!(parsed.to_string(), s, "round-trip failed for {s:?}");
1361 }
1362 let zero: UtcOffset = "+00:00".parse().unwrap();
1364 assert_eq!(zero, UtcOffset::UTC);
1365 assert_eq!(zero.to_string(), "Z");
1366 }
1367
1368 #[test]
1369 fn utc_offset_from_str_rejects_invalid() {
1370 for bad in [
1371 "EST", "UTC", "utc", "+0530", "+05-30", "05:30", "", "+24:00",
1372 ] {
1373 assert!(bad.parse::<UtcOffset>().is_err(), "should reject {bad:?}");
1374 }
1375 }
1376
1377 #[test]
1378 fn parse_offset_rejects_wrong_separator() {
1379 let err = IsmDate::from_str("2003-04-15T10:30+05-30");
1381 assert!(
1382 err.is_err(),
1383 "offset with wrong separator should be Err, got {err:?}"
1384 );
1385 let err2 = IsmDate::from_str("2003-04-15T10:30+0530");
1387 assert!(
1388 err2.is_err(),
1389 "offset without separator should be Err, got {err2:?}"
1390 );
1391 }
1392
1393 #[test]
1394 fn parse_datetime_rejects_non_ascii() {
1395 let result = IsmDate::from_str("2003-04-15T10:30\u{00E9}");
1397 assert!(result.is_err(), "non-ASCII should be Err, got {result:?}");
1398 }
1399
1400 #[test]
1405 fn utc_offset_from_hhmm_invalid_sign_zero() {
1406 assert!(
1407 UtcOffset::from_hhmm(0, 5, 0).is_none(),
1408 "sign=0 must be rejected"
1409 );
1410 }
1411
1412 #[test]
1413 fn utc_offset_from_hhmm_invalid_sign_two() {
1414 assert!(
1415 UtcOffset::from_hhmm(2, 5, 0).is_none(),
1416 "sign=2 must be rejected"
1417 );
1418 }
1419
1420 #[test]
1421 fn utc_offset_from_hhmm_invalid_sign_minus_two() {
1422 assert!(
1423 UtcOffset::from_hhmm(-2, 5, 0).is_none(),
1424 "sign=-2 must be rejected"
1425 );
1426 }
1427
1428 #[test]
1429 fn utc_offset_from_hhmm_max_valid_boundary() {
1430 let pos = UtcOffset::from_hhmm(1, 23, 59).unwrap();
1432 assert_eq!(pos.minutes, 23 * 60 + 59);
1433 let neg = UtcOffset::from_hhmm(-1, 23, 59).unwrap();
1434 assert_eq!(neg.minutes, -(23 * 60 + 59));
1435 }
1436
1437 #[test]
1438 fn utc_offset_from_hhmm_rejects_hours_24() {
1439 assert!(
1440 UtcOffset::from_hhmm(1, 24, 0).is_none(),
1441 "hours=24 must be rejected"
1442 );
1443 }
1444
1445 #[test]
1446 fn utc_offset_from_hhmm_rejects_minutes_60() {
1447 assert!(
1448 UtcOffset::from_hhmm(1, 0, 60).is_none(),
1449 "minutes=60 must be rejected"
1450 );
1451 }
1452
1453 #[test]
1454 fn utc_offset_to_seconds_utc_is_zero() {
1455 assert_eq!(UtcOffset::UTC.to_seconds(), 0);
1456 }
1457
1458 #[test]
1459 fn utc_offset_to_seconds_positive() {
1460 let o = UtcOffset::from_hhmm(1, 5, 30).unwrap();
1462 assert_eq!(o.to_seconds(), 5 * 3600 + 30 * 60);
1463 }
1464
1465 #[test]
1466 fn utc_offset_to_seconds_negative() {
1467 let o = UtcOffset::from_hhmm(-1, 5, 0).unwrap();
1469 assert_eq!(o.to_seconds(), -5 * 3600);
1470 }
1471
1472 #[test]
1473 fn utc_offset_from_hhmm_zero_positive_sign() {
1474 let pos = UtcOffset::from_hhmm(1, 0, 0).unwrap();
1476 let neg = UtcOffset::from_hhmm(-1, 0, 0).unwrap();
1477 assert_eq!(pos, UtcOffset::UTC);
1478 assert_eq!(neg, UtcOffset::UTC);
1479 }
1480
1481 #[test]
1486 fn year_accessor_all_variants() {
1487 assert_eq!(IsmDate::Year(2003).year(), 2003);
1488 assert_eq!(IsmDate::YearMonth(2003, 4).year(), 2003);
1489 assert_eq!(IsmDate::Date(2003, 4, 15).year(), 2003);
1490 assert_eq!(
1491 IsmDate::DateHourMin {
1492 year: 2003,
1493 month: 4,
1494 day: 15,
1495 hour: 10,
1496 minute: 30,
1497 offset: None,
1498 }
1499 .year(),
1500 2003
1501 );
1502 assert_eq!(
1503 IsmDate::DateTime {
1504 year: 2003,
1505 month: 4,
1506 day: 15,
1507 hour: 10,
1508 minute: 30,
1509 second: 0,
1510 nanosecond: 0,
1511 offset: None,
1512 }
1513 .year(),
1514 2003
1515 );
1516 }
1517
1518 #[test]
1519 fn month_accessor_all_variants() {
1520 assert_eq!(IsmDate::Year(2003).month(), None);
1521 assert_eq!(IsmDate::YearMonth(2003, 4).month(), Some(4));
1522 assert_eq!(IsmDate::Date(2003, 4, 15).month(), Some(4));
1523 assert_eq!(
1524 IsmDate::DateHourMin {
1525 year: 2003,
1526 month: 4,
1527 day: 15,
1528 hour: 10,
1529 minute: 30,
1530 offset: None,
1531 }
1532 .month(),
1533 Some(4)
1534 );
1535 assert_eq!(
1536 IsmDate::DateTime {
1537 year: 2003,
1538 month: 4,
1539 day: 15,
1540 hour: 10,
1541 minute: 30,
1542 second: 0,
1543 nanosecond: 0,
1544 offset: None,
1545 }
1546 .month(),
1547 Some(4)
1548 );
1549 }
1550
1551 #[test]
1552 fn day_accessor_all_variants() {
1553 assert_eq!(IsmDate::Year(2003).day(), None);
1554 assert_eq!(IsmDate::YearMonth(2003, 4).day(), None);
1555 assert_eq!(IsmDate::Date(2003, 4, 15).day(), Some(15));
1556 assert_eq!(
1557 IsmDate::DateHourMin {
1558 year: 2003,
1559 month: 4,
1560 day: 15,
1561 hour: 10,
1562 minute: 30,
1563 offset: None,
1564 }
1565 .day(),
1566 Some(15)
1567 );
1568 assert_eq!(
1569 IsmDate::DateTime {
1570 year: 2003,
1571 month: 4,
1572 day: 15,
1573 hour: 10,
1574 minute: 30,
1575 second: 0,
1576 nanosecond: 0,
1577 offset: None,
1578 }
1579 .day(),
1580 Some(15)
1581 );
1582 }
1583
1584 #[test]
1589 fn date_hour_min_contains_itself() {
1590 let t = IsmDate::DateHourMin {
1591 year: 2003,
1592 month: 4,
1593 day: 15,
1594 hour: 14,
1595 minute: 30,
1596 offset: Some(UtcOffset::UTC),
1597 };
1598 assert!(t.contains(&t.clone()));
1599 }
1600
1601 #[test]
1602 fn date_hour_min_does_not_contain_coarser() {
1603 let t = IsmDate::DateHourMin {
1604 year: 2003,
1605 month: 4,
1606 day: 15,
1607 hour: 14,
1608 minute: 30,
1609 offset: None,
1610 };
1611 assert!(!t.contains(&IsmDate::Year(2003)));
1612 assert!(!t.contains(&IsmDate::YearMonth(2003, 4)));
1613 assert!(!t.contains(&IsmDate::Date(2003, 4, 15)));
1614 }
1615
1616 #[test]
1617 fn date_hour_min_contains_datetime_same_minute() {
1618 let dhm = IsmDate::DateHourMin {
1619 year: 2003,
1620 month: 4,
1621 day: 15,
1622 hour: 14,
1623 minute: 30,
1624 offset: None,
1625 };
1626 let dt = IsmDate::DateTime {
1628 year: 2003,
1629 month: 4,
1630 day: 15,
1631 hour: 14,
1632 minute: 30,
1633 second: 45,
1634 nanosecond: 0,
1635 offset: None,
1636 };
1637 assert!(dhm.contains(&dt));
1638 }
1639
1640 #[test]
1641 fn date_hour_min_does_not_contain_datetime_different_minute() {
1642 let dhm = IsmDate::DateHourMin {
1643 year: 2003,
1644 month: 4,
1645 day: 15,
1646 hour: 14,
1647 minute: 30,
1648 offset: None,
1649 };
1650 let dt = IsmDate::DateTime {
1651 year: 2003,
1652 month: 4,
1653 day: 15,
1654 hour: 14,
1655 minute: 31,
1656 second: 0,
1657 nanosecond: 0,
1658 offset: None,
1659 };
1660 assert!(!dhm.contains(&dt));
1661 }
1662
1663 #[test]
1664 fn date_hour_min_does_not_contain_datetime_different_offset() {
1665 let dhm = IsmDate::DateHourMin {
1667 year: 2003,
1668 month: 4,
1669 day: 15,
1670 hour: 14,
1671 minute: 30,
1672 offset: Some(UtcOffset::UTC),
1673 };
1674 let dt = IsmDate::DateTime {
1675 year: 2003,
1676 month: 4,
1677 day: 15,
1678 hour: 14,
1679 minute: 30,
1680 second: 0,
1681 nanosecond: 0,
1682 offset: Some(UtcOffset::from_hhmm(-1, 5, 0).unwrap()),
1683 };
1684 assert!(!dhm.contains(&dt));
1685 }
1686
1687 #[test]
1688 fn datetime_contains_itself() {
1689 let dt = IsmDate::DateTime {
1690 year: 2003,
1691 month: 4,
1692 day: 15,
1693 hour: 14,
1694 minute: 30,
1695 second: 45,
1696 nanosecond: 123_456_789,
1697 offset: Some(UtcOffset::UTC),
1698 };
1699 assert!(dt.contains(&dt.clone()));
1700 }
1701
1702 #[test]
1703 fn datetime_does_not_contain_coarser() {
1704 let dt = IsmDate::DateTime {
1705 year: 2003,
1706 month: 4,
1707 day: 15,
1708 hour: 14,
1709 minute: 30,
1710 second: 45,
1711 nanosecond: 0,
1712 offset: None,
1713 };
1714 assert!(!dt.contains(&IsmDate::Year(2003)));
1715 assert!(!dt.contains(&IsmDate::YearMonth(2003, 4)));
1716 assert!(!dt.contains(&IsmDate::Date(2003, 4, 15)));
1717 }
1718
1719 #[test]
1720 fn datetime_does_not_contain_datehourmin() {
1721 let dt = IsmDate::DateTime {
1722 year: 2003,
1723 month: 4,
1724 day: 15,
1725 hour: 14,
1726 minute: 30,
1727 second: 45,
1728 nanosecond: 0,
1729 offset: None,
1730 };
1731 let dhm = IsmDate::DateHourMin {
1732 year: 2003,
1733 month: 4,
1734 day: 15,
1735 hour: 14,
1736 minute: 30,
1737 offset: None,
1738 };
1739 assert!(!dt.contains(&dhm));
1740 }
1741
1742 #[test]
1743 fn year_contains_datehourmin_same_year() {
1744 let y = IsmDate::Year(2003);
1745 let t = IsmDate::DateHourMin {
1746 year: 2003,
1747 month: 6,
1748 day: 15,
1749 hour: 10,
1750 minute: 0,
1751 offset: None,
1752 };
1753 assert!(y.contains(&t));
1754 }
1755
1756 #[test]
1757 fn year_contains_datetime_same_year() {
1758 let y = IsmDate::Year(2003);
1759 let dt = IsmDate::DateTime {
1760 year: 2003,
1761 month: 12,
1762 day: 31,
1763 hour: 23,
1764 minute: 59,
1765 second: 59,
1766 nanosecond: 0,
1767 offset: None,
1768 };
1769 assert!(y.contains(&dt));
1770 }
1771
1772 #[test]
1773 fn year_does_not_contain_datehourmin_different_year() {
1774 let y = IsmDate::Year(2003);
1775 let t = IsmDate::DateHourMin {
1776 year: 2004,
1777 month: 1,
1778 day: 1,
1779 hour: 0,
1780 minute: 0,
1781 offset: None,
1782 };
1783 assert!(!y.contains(&t));
1784 }
1785
1786 #[test]
1787 fn year_month_contains_datehourmin_same_month() {
1788 let ym = IsmDate::YearMonth(2003, 4);
1789 let t = IsmDate::DateHourMin {
1790 year: 2003,
1791 month: 4,
1792 day: 15,
1793 hour: 10,
1794 minute: 0,
1795 offset: None,
1796 };
1797 assert!(ym.contains(&t));
1798 }
1799
1800 #[test]
1801 fn year_month_does_not_contain_datehourmin_different_month() {
1802 let ym = IsmDate::YearMonth(2003, 4);
1803 let t = IsmDate::DateHourMin {
1804 year: 2003,
1805 month: 5,
1806 day: 1,
1807 hour: 0,
1808 minute: 0,
1809 offset: None,
1810 };
1811 assert!(!ym.contains(&t));
1812 }
1813
1814 #[test]
1815 fn date_contains_datetime_same_day() {
1816 let d = IsmDate::Date(2003, 4, 15);
1817 let dt = IsmDate::DateTime {
1818 year: 2003,
1819 month: 4,
1820 day: 15,
1821 hour: 23,
1822 minute: 59,
1823 second: 59,
1824 nanosecond: 999_999_999,
1825 offset: None,
1826 };
1827 assert!(d.contains(&dt));
1828 }
1829
1830 #[test]
1831 fn date_does_not_contain_datetime_different_day() {
1832 let d = IsmDate::Date(2003, 4, 15);
1833 let dt = IsmDate::DateTime {
1834 year: 2003,
1835 month: 4,
1836 day: 16,
1837 hour: 0,
1838 minute: 0,
1839 second: 0,
1840 nanosecond: 0,
1841 offset: None,
1842 };
1843 assert!(!d.contains(&dt));
1844 }
1845
1846 #[test]
1851 fn year_end_cmp_same_year_is_equal() {
1852 assert_eq!(
1853 IsmDate::Year(2003).end_cmp(&IsmDate::Year(2003)),
1854 Ordering::Equal
1855 );
1856 }
1857
1858 #[test]
1859 fn year_end_cmp_different_years() {
1860 assert_eq!(
1861 IsmDate::Year(2004).end_cmp(&IsmDate::Year(2003)),
1862 Ordering::Greater
1863 );
1864 assert_eq!(
1865 IsmDate::Year(2003).end_cmp(&IsmDate::Year(2004)),
1866 Ordering::Less
1867 );
1868 }
1869
1870 #[test]
1871 fn year_month_end_cmp_same_month_is_equal() {
1872 assert_eq!(
1873 IsmDate::YearMonth(2003, 4).end_cmp(&IsmDate::YearMonth(2003, 4)),
1874 Ordering::Equal
1875 );
1876 }
1877
1878 #[test]
1879 fn year_month_end_cmp_different_months_same_year() {
1880 assert_eq!(
1882 IsmDate::YearMonth(2003, 5).end_cmp(&IsmDate::YearMonth(2003, 4)),
1883 Ordering::Greater
1884 );
1885 assert_eq!(
1886 IsmDate::YearMonth(2003, 4).end_cmp(&IsmDate::YearMonth(2003, 5)),
1887 Ordering::Less
1888 );
1889 }
1890
1891 #[test]
1892 fn date_end_cmp_same_date_is_equal() {
1893 assert_eq!(
1894 IsmDate::Date(2003, 4, 15).end_cmp(&IsmDate::Date(2003, 4, 15)),
1895 Ordering::Equal
1896 );
1897 }
1898
1899 #[test]
1900 fn date_end_cmp_later_date_is_greater() {
1901 assert_eq!(
1902 IsmDate::Date(2003, 4, 16).end_cmp(&IsmDate::Date(2003, 4, 15)),
1903 Ordering::Greater
1904 );
1905 }
1906
1907 #[test]
1908 fn datetime_end_cmp_same_instant_is_equal() {
1909 let dt = IsmDate::DateTime {
1910 year: 2003,
1911 month: 4,
1912 day: 15,
1913 hour: 10,
1914 minute: 30,
1915 second: 45,
1916 nanosecond: 0,
1917 offset: None,
1918 };
1919 assert_eq!(dt.end_cmp(&dt.clone()), Ordering::Equal);
1920 }
1921
1922 #[test]
1923 fn datetime_end_cmp_later_second_is_greater() {
1924 let earlier = IsmDate::DateTime {
1925 year: 2003,
1926 month: 4,
1927 day: 15,
1928 hour: 10,
1929 minute: 30,
1930 second: 44,
1931 nanosecond: 0,
1932 offset: None,
1933 };
1934 let later = IsmDate::DateTime {
1935 year: 2003,
1936 month: 4,
1937 day: 15,
1938 hour: 10,
1939 minute: 30,
1940 second: 45,
1941 nanosecond: 0,
1942 offset: None,
1943 };
1944 assert_eq!(later.end_cmp(&earlier), Ordering::Greater);
1945 assert_eq!(earlier.end_cmp(&later), Ordering::Less);
1946 }
1947
1948 #[test]
1949 fn datetime_end_cmp_nanosecond_tiebreak() {
1950 let a = IsmDate::DateTime {
1951 year: 2003,
1952 month: 4,
1953 day: 15,
1954 hour: 10,
1955 minute: 30,
1956 second: 45,
1957 nanosecond: 0,
1958 offset: None,
1959 };
1960 let b = IsmDate::DateTime {
1961 year: 2003,
1962 month: 4,
1963 day: 15,
1964 hour: 10,
1965 minute: 30,
1966 second: 45,
1967 nanosecond: 1,
1968 offset: None,
1969 };
1970 assert_eq!(b.end_cmp(&a), Ordering::Greater);
1971 }
1972
1973 #[test]
1974 fn date_hour_min_floating_is_treated_as_offset_zero() {
1975 let floating = IsmDate::DateHourMin {
1978 year: 2003,
1979 month: 4,
1980 day: 15,
1981 hour: 10,
1982 minute: 30,
1983 offset: None,
1984 };
1985 let utc = IsmDate::DateHourMin {
1986 year: 2003,
1987 month: 4,
1988 day: 15,
1989 hour: 10,
1990 minute: 30,
1991 offset: Some(UtcOffset::UTC),
1992 };
1993 assert_eq!(floating.end_cmp(&utc), Ordering::Equal);
1996 }
1997
1998 #[test]
1999 fn year_end_cmp_vs_year_month_same_year() {
2000 assert_eq!(
2002 IsmDate::Year(2003).end_cmp(&IsmDate::YearMonth(2003, 6)),
2003 Ordering::Greater
2004 );
2005 assert_eq!(
2006 IsmDate::YearMonth(2003, 12).end_cmp(&IsmDate::Year(2003)),
2007 Ordering::Equal );
2009 }
2010
2011 #[test]
2016 fn to_maxdate_str_date_hour_min() {
2017 let t = IsmDate::DateHourMin {
2019 year: 2003,
2020 month: 4,
2021 day: 15,
2022 hour: 14,
2023 minute: 30,
2024 offset: None,
2025 };
2026 assert_eq!(&*t.to_maxdate_str(), "20030415");
2027 }
2028
2029 #[test]
2030 fn to_maxdate_str_datetime() {
2031 let dt = IsmDate::DateTime {
2032 year: 2003,
2033 month: 4,
2034 day: 15,
2035 hour: 14,
2036 minute: 30,
2037 second: 45,
2038 nanosecond: 0,
2039 offset: Some(UtcOffset::UTC),
2040 };
2041 assert_eq!(&*dt.to_maxdate_str(), "20030415");
2042 }
2043
2044 #[test]
2045 fn to_maxdate_str_all_months_days_in_month() {
2046 let expected = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
2048 for (i, &days) in expected.iter().enumerate() {
2049 let month = (i + 1) as u8;
2050 let ym = IsmDate::YearMonth(2003, month);
2051 let s = ym.to_maxdate_str();
2052 let day_part: u8 = s[6..].parse().unwrap();
2053 assert_eq!(
2054 day_part, days,
2055 "2003-{month:02} should end on day {days}, got {day_part}"
2056 );
2057 }
2058 }
2059
2060 #[test]
2061 fn to_maxdate_str_february_leap_year() {
2062 assert_eq!(&*IsmDate::YearMonth(2000, 2).to_maxdate_str(), "20000229");
2064 assert_eq!(&*IsmDate::YearMonth(1900, 2).to_maxdate_str(), "19000228");
2066 }
2067
2068 #[test]
2073 fn approx_ism_date_display_without_qualifier() {
2074 let a = ApproxIsmDate {
2075 date: IsmDate::Year(2003),
2076 qualifier: None,
2077 };
2078 assert_eq!(a.to_string(), "2003");
2079 }
2080
2081 #[test]
2082 fn approx_ism_date_display_with_qualifier() {
2083 let a = ApproxIsmDate {
2084 date: IsmDate::Year(1995),
2085 qualifier: Some(ApproxQualifier::Circa),
2086 };
2087 assert_eq!(a.to_string(), "circa 1995");
2088 }
2089
2090 #[test]
2091 fn approx_ism_date_display_all_qualifiers() {
2092 let pairs = [
2093 (ApproxQualifier::FirstQtr, "1st qtr 2003"),
2094 (ApproxQualifier::SecondQtr, "2nd qtr 2003"),
2095 (ApproxQualifier::ThirdQtr, "3rd qtr 2003"),
2096 (ApproxQualifier::FourthQtr, "4th qtr 2003"),
2097 (ApproxQualifier::Circa, "circa 2003"),
2098 (ApproxQualifier::Early, "early 2003"),
2099 (ApproxQualifier::Mid, "mid 2003"),
2100 (ApproxQualifier::Late, "late 2003"),
2101 ];
2102 for (qualifier, expected) in pairs {
2103 let a = ApproxIsmDate {
2104 date: IsmDate::Year(2003),
2105 qualifier: Some(qualifier),
2106 };
2107 assert_eq!(a.to_string(), expected, "qualifier={qualifier:?}");
2108 }
2109 }
2110
2111 #[test]
2116 fn parse_ism_date_error_display() {
2117 let err = IsmDate::from_str("not-a-date").unwrap_err();
2118 let s = err.to_string();
2119 assert!(
2120 s.contains("invalid ISM date"),
2121 "error display should mention 'invalid ISM date', got: {s:?}"
2122 );
2123 }
2124
2125 #[test]
2126 fn parse_approx_qualifier_error_display() {
2127 let err = ApproxQualifier::from_str("bogus").unwrap_err();
2128 let s = err.to_string();
2129 assert!(
2130 s.contains("invalid approx qualifier"),
2131 "error display should mention 'invalid approx qualifier', got: {s:?}"
2132 );
2133 }
2134
2135 #[test]
2140 fn rejects_short_strings() {
2141 for s in ["", "2", "20", "200", "20030"] {
2142 assert!(
2143 IsmDate::from_str(s).is_err(),
2144 "should reject short string {s:?}"
2145 );
2146 }
2147 }
2148
2149 #[test]
2150 fn rejects_nine_char_string() {
2151 assert!(IsmDate::from_str("200304150").is_err());
2153 }
2154
2155 #[test]
2156 fn rejects_day_zero_in_date() {
2157 assert!(IsmDate::from_str("2003-04-00").is_err());
2158 }
2159
2160 #[test]
2161 fn rejects_day_32_in_date() {
2162 assert!(IsmDate::from_str("2003-01-32").is_err());
2163 }
2164
2165 #[test]
2166 fn rejects_yyyymmdd_month_13() {
2167 assert!(IsmDate::from_str("20031301").is_err());
2168 }
2169
2170 #[test]
2171 fn rejects_yyyymmdd_day_00() {
2172 assert!(IsmDate::from_str("20030400").is_err());
2173 }
2174
2175 #[test]
2176 fn rejects_datehourmin_hour_out_of_range() {
2177 assert!(IsmDate::from_str("2003-04-15T24:00").is_err());
2178 assert!(IsmDate::from_str("2003-04-15T25:00Z").is_err());
2179 }
2180
2181 #[test]
2182 fn rejects_datehourmin_minute_out_of_range() {
2183 assert!(IsmDate::from_str("2003-04-15T10:60").is_err());
2184 assert!(IsmDate::from_str("2003-04-15T10:99Z").is_err());
2185 }
2186
2187 #[test]
2188 fn rejects_datetime_second_out_of_range() {
2189 assert!(IsmDate::from_str("2003-04-15T10:30:60Z").is_err());
2190 assert!(IsmDate::from_str("2003-04-15T10:30:99").is_err());
2191 }
2192
2193 #[test]
2194 fn rejects_fractional_seconds_empty() {
2195 assert!(IsmDate::from_str("2003-04-15T10:30:00.Z").is_err());
2197 assert!(IsmDate::from_str("2003-04-15T10:30:00.").is_err());
2198 }
2199
2200 #[test]
2201 fn rejects_fractional_seconds_too_many_digits() {
2202 assert!(IsmDate::from_str("2003-04-15T10:30:00.1234567890Z").is_err());
2204 }
2205
2206 #[test]
2207 fn accepts_fractional_seconds_9_digits() {
2208 assert!(IsmDate::from_str("2003-04-15T10:30:00.123456789Z").is_ok());
2210 }
2211
2212 #[test]
2213 fn rejects_bad_offset_in_datetime() {
2214 assert!(IsmDate::from_str("2003-04-15T10:30:00+99:99").is_err());
2215 assert!(IsmDate::from_str("2003-04-15T10:30:00+24:00").is_err());
2216 }
2217
2218 #[test]
2219 fn rejects_bad_offset_in_datehourmin() {
2220 assert!(IsmDate::from_str("2003-04-15T10:30+99:99").is_err());
2221 assert!(IsmDate::from_str("2003-04-15T10:30+24:00").is_err());
2222 }
2223
2224 #[test]
2225 fn rejects_unknown_suffix_after_datehourmin() {
2226 assert!(IsmDate::from_str("2003-04-15T10:30:garbage").is_err());
2228 }
2229
2230 #[test]
2231 fn rejects_year_with_non_digit_separator() {
2232 assert!(IsmDate::from_str("2003X04").is_err());
2234 }
2235
2236 #[test]
2237 fn rejects_date_with_wrong_separator() {
2238 assert!(IsmDate::from_str("2003/04/15").is_err());
2239 }
2240
2241 #[test]
2242 fn round_trip_datetime_with_nanos() {
2243 assert!(round_trip("2003-04-15T14:30:00.123456789Z"));
2245 }
2246
2247 #[test]
2248 fn round_trip_datetime_with_negative_offset() {
2249 assert!(round_trip("2003-04-15T14:30:00-05:00"));
2250 }
2251
2252 #[test]
2253 fn round_trip_date_hour_min_negative_offset() {
2254 assert!(round_trip("2003-04-15T14:30-07:00"));
2255 }
2256
2257 #[test]
2258 fn round_trip_year_month_january() {
2259 assert!(round_trip("2003-01"));
2260 }
2261
2262 #[test]
2263 fn round_trip_year_month_december() {
2264 assert!(round_trip("2003-12"));
2265 }
2266
2267 #[test]
2268 fn capco_yyyymmdd_rejects_invalid_calendar_date() {
2269 assert!(IsmDate::from_str("20031301").is_err());
2271 assert!(IsmDate::from_str("20030400").is_err());
2273 assert!(IsmDate::from_str("20030229").is_err());
2275 }
2276
2277 #[test]
2278 fn utc_offset_from_str_all_canonical_forms() {
2279 let o: UtcOffset = "+12:00".parse().unwrap();
2281 assert_eq!(o.minutes, 720);
2282 assert_eq!(o.to_string(), "+12:00");
2283
2284 let o: UtcOffset = "-12:00".parse().unwrap();
2286 assert_eq!(o.minutes, -720);
2287 assert_eq!(o.to_string(), "-12:00");
2288 }
2289}