Skip to main content

fits_header/
dates.rs

1//! FITS date/time interpretation, and (behind the `coords` feature) MJD conversion.
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/// Epoch for the Modified Julian Date: 1858-11-17T00:00:00.
90#[cfg(feature = "coords")]
91fn mjd_epoch() -> PrimitiveDateTime {
92    PrimitiveDateTime::new(
93        Date::from_calendar_date(1858, Month::November, 17).expect("valid MJD epoch"),
94        Time::MIDNIGHT,
95    )
96}
97
98/// Convert a Modified Julian Date to a calendar date/time.
99#[cfg(feature = "coords")]
100pub fn mjd_to_datetime(mjd: f64) -> PrimitiveDateTime {
101    mjd_epoch() + time::Duration::seconds_f64(mjd * 86_400.0)
102}
103
104/// Convert a calendar date/time to a Modified Julian Date.
105#[cfg(feature = "coords")]
106pub fn datetime_to_mjd(dt: &PrimitiveDateTime) -> f64 {
107    (*dt - mjd_epoch()).as_seconds_f64() / 86_400.0
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn date_only_is_midnight() {
116        let dt = parse_datetime("2026-07-11").unwrap();
117        assert_eq!(dt.time(), Time::MIDNIGHT);
118        assert_eq!(format_datetime(&dt), "2026-07-11T00:00:00");
119    }
120
121    #[test]
122    fn seconds_and_fraction_are_optional() {
123        assert_eq!(
124            format_datetime(&parse_datetime("2026-07-11T22:15").unwrap()),
125            "2026-07-11T22:15:00"
126        );
127        let dt = parse_datetime("2026-07-11T22:15:03.25").unwrap();
128        assert_eq!(dt.time().nanosecond(), 250_000_000);
129        assert_eq!(format_datetime(&dt), "2026-07-11T22:15:03.25");
130    }
131
132    #[test]
133    fn fraction_beyond_nanoseconds_is_truncated() {
134        let dt = parse_datetime("2026-07-11T00:00:00.1234567891234").unwrap();
135        assert_eq!(dt.time().nanosecond(), 123_456_789);
136    }
137
138    #[test]
139    fn quoted_input_is_tolerated() {
140        assert!(parse_datetime("'2026-07-11T01:02:03'").is_some());
141        assert!(parse_datetime("  2026-07-11  ").is_some());
142    }
143
144    #[test]
145    fn invalid_forms_are_none() {
146        for bad in [
147            "2026-13-01",          // month
148            "2026-02-30",          // day
149            "2026-07-11T25:00:00", // hour
150            "2026-07-11-05",       // extra date part
151            "2026-07-11T1:2:3:4",  // extra time part
152            "2026",                // no month/day
153            "not a date",
154        ] {
155            assert!(parse_datetime(bad).is_none(), "{bad:?} should not parse");
156        }
157    }
158
159    #[test]
160    fn format_trims_trailing_fraction_zeros() {
161        let dt = parse_datetime("2026-07-11T00:00:00.100").unwrap();
162        assert_eq!(format_datetime(&dt), "2026-07-11T00:00:00.1");
163        let dt = parse_datetime("2026-07-11T00:00:00.000").unwrap();
164        assert_eq!(format_datetime(&dt), "2026-07-11T00:00:00");
165    }
166
167    #[cfg(feature = "coords")]
168    #[test]
169    fn mjd_epoch_is_zero() {
170        assert_eq!(datetime_to_mjd(&mjd_epoch()), 0.0);
171        assert_eq!(mjd_to_datetime(0.0), mjd_epoch());
172    }
173}