Skip to main content

fits_header/
dates.rs

1//! FITS date/time interpretation.
2
3use time::{Date, Month, PrimitiveDateTime, Time};
4
5/// Parse a FITS civil date/time (`YYYY-MM-DD[Thh:mm:ss[.fff]]`), timezone-naive.
6///
7/// # Examples
8///
9/// ```
10/// let dt = fits_header::parse_datetime("2026-07-11T22:15:03").unwrap();
11/// assert_eq!(fits_header::format_datetime(&dt), "2026-07-11T22:15:03");
12/// assert!(fits_header::parse_datetime("2026-07-11").is_some()); // date-only → midnight
13/// ```
14pub fn parse_datetime(s: &str) -> Option<PrimitiveDateTime> {
15    let t = s.trim().trim_matches('\'').trim();
16    let (date_part, time_part) = match t.split_once('T') {
17        Some((d, tm)) => (d, Some(tm)),
18        None => (t, None),
19    };
20
21    let mut d = date_part.split('-');
22    let year: i32 = d.next()?.parse().ok()?;
23    let month: u8 = d.next()?.parse().ok()?;
24    let day: u8 = d.next()?.parse().ok()?;
25    if d.next().is_some() {
26        return None;
27    }
28    let date = Date::from_calendar_date(year, Month::try_from(month).ok()?, day).ok()?;
29
30    let time = match time_part {
31        None => Time::MIDNIGHT,
32        Some(tp) => {
33            let mut parts = tp.split(':');
34            let hour: u8 = parts.next()?.parse().ok()?;
35            let minute: u8 = parts.next()?.parse().ok()?;
36            let (sec, nanos) = match parts.next() {
37                None => (0u8, 0u32),
38                Some(sec_field) => {
39                    let (whole, frac) = match sec_field.split_once('.') {
40                        Some((w, f)) => (w, Some(f)),
41                        None => (sec_field, None),
42                    };
43                    let sec: u8 = whole.parse().ok()?;
44                    let nanos = match frac {
45                        None => 0,
46                        Some(f) => {
47                            let mut digits: String = f.chars().take(9).collect();
48                            while digits.len() < 9 {
49                                digits.push('0');
50                            }
51                            digits.parse::<u32>().ok()?
52                        }
53                    };
54                    (sec, nanos)
55                }
56            };
57            if parts.next().is_some() {
58                return None;
59            }
60            Time::from_hms_nano(hour, minute, sec, nanos).ok()?
61        }
62    };
63
64    Some(PrimitiveDateTime::new(date, time))
65}
66
67/// Format a date/time back to the FITS civil form (`YYYY-MM-DDThh:mm:ss[.fff]`), dropping a zero
68/// sub-second part.
69pub fn format_datetime(dt: &PrimitiveDateTime) -> String {
70    let (d, t) = (dt.date(), dt.time());
71    let base = format!(
72        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}",
73        d.year(),
74        d.month() as u8,
75        d.day(),
76        t.hour(),
77        t.minute(),
78        t.second()
79    );
80    let nanos = t.nanosecond();
81    if nanos == 0 {
82        base
83    } else {
84        let frac = format!("{nanos:09}");
85        format!("{base}.{}", frac.trim_end_matches('0'))
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    #[test]
94    fn date_only_is_midnight() {
95        let dt = parse_datetime("2026-07-11").unwrap();
96        assert_eq!(dt.time(), Time::MIDNIGHT);
97        assert_eq!(format_datetime(&dt), "2026-07-11T00:00:00");
98    }
99
100    #[test]
101    fn seconds_and_fraction_are_optional() {
102        assert_eq!(
103            format_datetime(&parse_datetime("2026-07-11T22:15").unwrap()),
104            "2026-07-11T22:15:00"
105        );
106        let dt = parse_datetime("2026-07-11T22:15:03.25").unwrap();
107        assert_eq!(dt.time().nanosecond(), 250_000_000);
108        assert_eq!(format_datetime(&dt), "2026-07-11T22:15:03.25");
109    }
110
111    #[test]
112    fn fraction_beyond_nanoseconds_is_truncated() {
113        let dt = parse_datetime("2026-07-11T00:00:00.1234567891234").unwrap();
114        assert_eq!(dt.time().nanosecond(), 123_456_789);
115    }
116
117    #[test]
118    fn quoted_input_is_tolerated() {
119        assert!(parse_datetime("'2026-07-11T01:02:03'").is_some());
120        assert!(parse_datetime("  2026-07-11  ").is_some());
121    }
122
123    #[test]
124    fn invalid_forms_are_none() {
125        for bad in [
126            "2026-13-01",          // month
127            "2026-02-30",          // day
128            "2026-07-11T25:00:00", // hour
129            "2026-07-11-05",       // extra date part
130            "2026-07-11T1:2:3:4",  // extra time part
131            "2026",                // no month/day
132            "not a date",
133        ] {
134            assert!(parse_datetime(bad).is_none(), "{bad:?} should not parse");
135        }
136    }
137
138    #[test]
139    fn format_trims_trailing_fraction_zeros() {
140        let dt = parse_datetime("2026-07-11T00:00:00.100").unwrap();
141        assert_eq!(format_datetime(&dt), "2026-07-11T00:00:00.1");
142        let dt = parse_datetime("2026-07-11T00:00:00.000").unwrap();
143        assert_eq!(format_datetime(&dt), "2026-07-11T00:00:00");
144    }
145}