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.
69///
70/// # Examples
71///
72/// ```
73/// let dt = fits_header::parse_datetime("2026-07-11T22:15:03.5").unwrap();
74/// assert_eq!(fits_header::format_datetime(&dt), "2026-07-11T22:15:03.5");
75/// ```
76pub fn format_datetime(dt: &PrimitiveDateTime) -> String {
77    let (d, t) = (dt.date(), dt.time());
78    let base = format!(
79        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}",
80        d.year(),
81        d.month() as u8,
82        d.day(),
83        t.hour(),
84        t.minute(),
85        t.second()
86    );
87    let nanos = t.nanosecond();
88    if nanos == 0 {
89        base
90    } else {
91        let frac = format!("{nanos:09}");
92        format!("{base}.{}", frac.trim_end_matches('0'))
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn date_only_is_midnight() {
102        let dt = parse_datetime("2026-07-11").unwrap();
103        assert_eq!(dt.time(), Time::MIDNIGHT);
104        assert_eq!(format_datetime(&dt), "2026-07-11T00:00:00");
105    }
106
107    #[test]
108    fn seconds_and_fraction_are_optional() {
109        assert_eq!(
110            format_datetime(&parse_datetime("2026-07-11T22:15").unwrap()),
111            "2026-07-11T22:15:00"
112        );
113        let dt = parse_datetime("2026-07-11T22:15:03.25").unwrap();
114        assert_eq!(dt.time().nanosecond(), 250_000_000);
115        assert_eq!(format_datetime(&dt), "2026-07-11T22:15:03.25");
116    }
117
118    #[test]
119    fn fraction_beyond_nanoseconds_is_truncated() {
120        let dt = parse_datetime("2026-07-11T00:00:00.1234567891234").unwrap();
121        assert_eq!(dt.time().nanosecond(), 123_456_789);
122    }
123
124    #[test]
125    fn quoted_input_is_tolerated() {
126        assert!(parse_datetime("'2026-07-11T01:02:03'").is_some());
127        assert!(parse_datetime("  2026-07-11  ").is_some());
128    }
129
130    #[test]
131    fn invalid_forms_are_none() {
132        for bad in [
133            "2026-13-01",          // month
134            "2026-02-30",          // day
135            "2026-07-11T25:00:00", // hour
136            "2026-07-11-05",       // extra date part
137            "2026-07-11T1:2:3:4",  // extra time part
138            "2026",                // no month/day
139            "not a date",
140        ] {
141            assert!(parse_datetime(bad).is_none(), "{bad:?} should not parse");
142        }
143    }
144
145    #[test]
146    fn format_trims_trailing_fraction_zeros() {
147        let dt = parse_datetime("2026-07-11T00:00:00.100").unwrap();
148        assert_eq!(format_datetime(&dt), "2026-07-11T00:00:00.1");
149        let dt = parse_datetime("2026-07-11T00:00:00.000").unwrap();
150        assert_eq!(format_datetime(&dt), "2026-07-11T00:00:00");
151    }
152}