Skip to main content

asdf/
time_ffi.rs

1//! `asdf/core/time.h`.
2//!
3//! `asdf_time_t` is a public struct that embeds `struct timespec` and
4//! `struct tm` — platform types, which is why they come from `libc` rather
5//! than being hand-rolled. Their layouts differ between targets, so the
6//! layout gate checks this one on every platform in the matrix.
7
8use alloc::ffi::CString;
9use core::ffi::{CStr, c_char, c_int};
10
11use asdf_core::core::time::{Civil, Time, TimeFormat, TimeScale, infer_format};
12use asdf_core::yaml::{Document, NodeId};
13
14use crate::panic::guard;
15use crate::types::AsdfValueErr;
16
17/// `asdf_time_format_t` as it crosses the boundary.
18pub type TimeFormatAbi = c_int;
19/// `asdf_time_scale_t` as it crosses the boundary.
20pub type TimeScaleAbi = c_int;
21
22/// Mirror of `asdf_time_location_t`.
23#[repr(C)]
24#[derive(Clone, Copy, Debug, Default)]
25pub struct asdf_time_location_t {
26    /// Degrees east.
27    pub longitude: f64,
28    /// Degrees north.
29    pub latitude: f64,
30    /// Metres above the reference ellipsoid.
31    pub height: f64,
32}
33
34/// Mirror of `asdf_time_info_t`.
35///
36/// A derived, best-effort calendar reading. For anything off the UTC scale it
37/// ignores leap seconds, because this library carries no leap-second table —
38/// the same caveat libasdf documents.
39#[repr(C)]
40#[derive(Clone, Copy)]
41pub struct asdf_time_info_t {
42    /// Seconds and nanoseconds from the Unix epoch.
43    pub ts: libc::timespec,
44    /// The broken-down calendar fields.
45    pub tm: libc::tm,
46}
47
48impl core::fmt::Debug for asdf_time_info_t {
49    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
50        f.debug_struct("asdf_time_info_t")
51            .field("tv_sec", &self.ts.tv_sec)
52            .field("tv_nsec", &self.ts.tv_nsec)
53            .field("tm_year", &self.tm.tm_year)
54            .finish()
55    }
56}
57
58impl Default for asdf_time_info_t {
59    fn default() -> Self {
60        // SAFETY: both are plain C structs of integers and pointers, for
61        // which an all-zero value is valid and is what C's `= {0}` gives.
62        unsafe { core::mem::zeroed() }
63    }
64}
65
66/// Mirror of `asdf_time_t`.
67#[repr(C)]
68#[derive(Debug)]
69pub struct asdf_time_t {
70    /// The value exactly as it appears in the file. Owned.
71    pub value: *mut c_char,
72    /// The derived calendar reading.
73    pub info: asdf_time_info_t,
74    /// The effective format, with `format` and `base_format` collapsed.
75    pub format: TimeFormatAbi,
76    /// The time scale.
77    pub scale: TimeScaleAbi,
78    /// The observer's location.
79    pub location: asdf_time_location_t,
80}
81
82impl asdf_time_t {
83    /// A zeroed instance.
84    pub(crate) fn zeroed() -> Self {
85        Self {
86            value: core::ptr::null_mut(),
87            info: asdf_time_info_t::default(),
88            format: TimeFormat::Iso as c_int,
89            scale: TimeScale::Utc as c_int,
90            location: asdf_time_location_t::default(),
91        }
92    }
93}
94
95/// Fill a `struct tm` and `struct timespec` from a calendar breakdown.
96fn fill_info(civil: &Civil) -> asdf_time_info_t {
97    let mut info = asdf_time_info_t::default();
98    info.ts.tv_sec = civil.unix_seconds as libc::time_t;
99    info.ts.tv_nsec = libc::c_long::from(0) + i64::from(civil.nanosecond) as libc::c_long;
100
101    // `struct tm` counts years from 1900 and months from zero.
102    info.tm.tm_year = civil.year - 1900;
103    info.tm.tm_mon = civil.month as c_int - 1;
104    info.tm.tm_mday = civil.day as c_int;
105    info.tm.tm_hour = civil.hour as c_int;
106    info.tm.tm_min = civil.minute as c_int;
107    info.tm.tm_sec = civil.second as c_int;
108    info.tm.tm_yday = civil.yday as c_int - 1;
109    info.tm.tm_wday = civil.wday as c_int;
110    // These times carry their scale separately, so there is no DST notion.
111    info.tm.tm_isdst = 0;
112    info
113}
114
115/// Build the engine's view from a C struct.
116fn to_engine(time: &asdf_time_t) -> Option<Time> {
117    if time.value.is_null() {
118        return None;
119    }
120    let value = unsafe { CStr::from_ptr(time.value) }.to_string_lossy().into_owned();
121    Some(Time {
122        value,
123        format: format_from_abi(time.format),
124        scale: TimeScale::from_i32(time.scale),
125        location: asdf_core::core::time::Location {
126            longitude: time.location.longitude,
127            latitude: time.location.latitude,
128            height: time.location.height,
129        },
130        civil: None,
131    })
132}
133
134fn format_from_abi(value: TimeFormatAbi) -> TimeFormat {
135    TimeFormat::from_name(
136        FORMAT_LOOKUP
137            .get(usize::try_from(value).unwrap_or(usize::MAX))
138            .copied()
139            .flatten()
140            .unwrap_or("iso"),
141    )
142    .unwrap_or(TimeFormat::Iso)
143}
144
145/// Format names by discriminant, for converting an ABI value back.
146const FORMAT_LOOKUP: [Option<&str>; 23] = [
147    Some("iso"),
148    Some("yday"),
149    Some("byear"),
150    Some("jyear"),
151    Some("decimalyear"),
152    Some("jd"),
153    Some("mjd"),
154    Some("gps"),
155    Some("unix"),
156    Some("utime"),
157    Some("tai_seconds"),
158    Some("cxcsec"),
159    Some("galexsec"),
160    Some("unix_tai"),
161    None,
162    Some("byear_str"),
163    Some("datetime"),
164    Some("fits"),
165    Some("isot"),
166    Some("jyear_str"),
167    Some("plot_date"),
168    Some("ymdhms"),
169    Some("datetime64"),
170];
171
172/// Compute a time's calendar reading from its value, format and scale.
173///
174/// Returns 0 on success and non-zero when the value cannot be interpreted.
175///
176/// # Safety
177/// `time` must be null or a valid `asdf_time_t` with a `value`.
178#[unsafe(no_mangle)]
179pub unsafe extern "C" fn asdf_time_parse(time: *mut asdf_time_t) -> c_int {
180    guard("asdf_time_parse", -1, || {
181        if time.is_null() {
182            return -1;
183        }
184        let handle = unsafe { &mut *time };
185        let Some(mut engine) = to_engine(handle) else {
186            return -1;
187        };
188        match engine.compute_civil() {
189            Ok(civil) => {
190                handle.info = fill_info(&civil);
191                0
192            }
193            Err(_) => -1,
194        }
195    })
196}
197
198/// The name of a time format, or null for the reserved slot.
199///
200/// # Safety
201/// The returned pointer refers to a `'static` string.
202#[unsafe(no_mangle)]
203pub extern "C" fn asdf_time_format_string(format: TimeFormatAbi) -> *const c_char {
204    // Static names, so no allocation and no lifetime question.
205    let name: Option<&'static CStr> = match format {
206        0 => Some(c"iso"),
207        1 => Some(c"yday"),
208        2 => Some(c"byear"),
209        3 => Some(c"jyear"),
210        4 => Some(c"decimalyear"),
211        5 => Some(c"jd"),
212        6 => Some(c"mjd"),
213        7 => Some(c"gps"),
214        8 => Some(c"unix"),
215        9 => Some(c"utime"),
216        10 => Some(c"tai_seconds"),
217        11 => Some(c"cxcsec"),
218        12 => Some(c"galexsec"),
219        13 => Some(c"unix_tai"),
220        // The reserved slot has no name, matching upstream's NULL entry.
221        14 => None,
222        15 => Some(c"byear_str"),
223        16 => Some(c"datetime"),
224        17 => Some(c"fits"),
225        18 => Some(c"isot"),
226        19 => Some(c"jyear_str"),
227        20 => Some(c"plot_date"),
228        21 => Some(c"ymdhms"),
229        22 => Some(c"datetime64"),
230        _ => None,
231    };
232    name.map_or(core::ptr::null(), CStr::as_ptr)
233}
234
235/// The tag for `core/time`.
236pub const TIME_TAG: &str = "tag:stsci.edu:asdf/time/time-1.4.0";
237
238/// Read a `time/time` value from the tree.
239pub(crate) fn time_deserialize(
240    doc: &Document,
241    node: NodeId,
242    _file: *mut crate::file_ffi::AsdfFile,
243    out: *mut asdf_time_t,
244) -> AsdfValueErr {
245    // The reading is the engine's; this only lays the result out for C.
246    let Ok(time) = Time::parse(doc, node) else {
247        return AsdfValueErr::ParseFailure;
248    };
249    let Ok(owned) = CString::new(time.value.clone()) else {
250        return AsdfValueErr::ParseFailure;
251    };
252
253    unsafe {
254        (*out).value = owned.into_raw();
255        (*out).format = time.format as c_int;
256        (*out).scale = time.scale as c_int;
257        (*out).location = asdf_time_location_t {
258            longitude: time.location.longitude,
259            latitude: time.location.latitude,
260            height: time.location.height,
261        };
262        // A value that will not parse is not an error: the value, format and
263        // scale are authoritative and round-trip regardless, and the
264        // breakdown is only a convenience.
265        (*out).info = match time.civil {
266            Some(civil) => fill_info(&civil),
267            None => asdf_time_info_t::default(),
268        };
269    }
270    AsdfValueErr::Ok
271}
272
273pub(crate) fn time_serialize(doc: &mut Document, obj: &asdf_time_t) -> Option<NodeId> {
274    if obj.value.is_null() {
275        return None;
276    }
277    let value = unsafe { CStr::from_ptr(obj.value) }.to_string_lossy().into_owned();
278    let format = format_from_abi(obj.format);
279    let scale = TimeScale::from_i32(obj.scale);
280    // A reserved slot has no name and cannot be written.
281    format.name()?;
282
283    // A `J`- or `B`-prefixed string stored under `jyear`/`byear` is really
284    // the `*_str` "other" form -- astropy accepts only the prefixed strings
285    // under those -- so relabel it and let the "other" handling below place
286    // it in `base_format`.
287    let effective = match (format, value.chars().next()) {
288        (TimeFormat::Jyear, Some('J' | 'j')) => TimeFormat::JyearStr,
289        (TimeFormat::Byear, Some('B' | 'b')) => TimeFormat::ByearStr,
290        _ => format,
291    };
292
293    // A numeric "other" format has no string wire form of its own, so its
294    // value is rewritten as an ISO date-time from the parsed instant. A value
295    // that is already a date-time string -- a `plot_date` just read back from
296    // this very form -- is written verbatim.
297    let value = if needs_reformat(effective) && infer_format(&value).is_none() {
298        let mut parsed = Time::new(value.clone(), format, scale);
299        let civil = parsed.compute_civil().ok()?;
300        format_isot(&civil)
301    } else {
302        value
303    };
304
305    let mut pairs = Vec::new();
306    let mut put = |doc: &mut Document, key: &str, text: String| {
307        let k = doc.add_scalar(key);
308        let v = doc.add_scalar_styled(text, asdf_core::yaml::ScalarStyle::SingleQuoted);
309        pairs.push((k, v));
310    };
311    put(doc, "value", value);
312
313    // The schema permits only standard formats in `format`; an "other"
314    // effective format goes in `base_format` with `format` left out, and its
315    // standard wire form is re-inferred from the value on read.
316    let name = effective.name()?.to_string();
317    put(doc, if effective.is_other() { "base_format" } else { "format" }, name);
318
319    if scale != TimeScale::Utc {
320        put(doc, "scale", scale.name().to_string());
321    }
322    if obj.location.longitude != 0.0 || obj.location.latitude != 0.0 || obj.location.height != 0.0 {
323        let mut location = Vec::new();
324        for (key, number) in [
325            ("longitude", obj.location.longitude),
326            ("latitude", obj.location.latitude),
327            ("height", obj.location.height),
328        ] {
329            let k = doc.add_scalar(key);
330            let v = doc.add_scalar(asdf_core::core::elements::format_float(number));
331            location.push((k, v));
332        }
333        let node = doc.add_mapping(location);
334        let k = doc.add_scalar("location");
335        pairs.push((k, node));
336    }
337    Some(doc.add_mapping(pairs))
338}
339
340/// Whether a format's value must be rewritten as a date-time string.
341///
342/// Only `plot_date` has a numeric scalar form of its own; `ymdhms` and
343/// `datetime64` are always stored as ISO strings, and a bare-integer
344/// `datetime64` is unit-ambiguous, so neither is reformatted.
345fn needs_reformat(format: TimeFormat) -> bool {
346    format == TimeFormat::PlotDate
347}
348
349/// Render an instant as an `isot` string, trailing zeros trimmed.
350fn format_isot(civil: &Civil) -> String {
351    let mut out = format!(
352        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}",
353        civil.year, civil.month, civil.day, civil.hour, civil.minute, civil.second
354    );
355    if civil.nanosecond > 0 {
356        let fraction = format!("{:09}", civil.nanosecond);
357        out.push('.');
358        out.push_str(fraction.trim_end_matches('0'));
359    }
360    out
361}
362
363pub(crate) unsafe fn time_deinit(obj: *mut asdf_time_t) {
364    let time = unsafe { &mut *obj };
365    if !time.value.is_null() {
366        drop(unsafe { CString::from_raw(time.value) });
367    }
368    *time = asdf_time_t::zeroed();
369}
370
371pub(crate) unsafe fn time_copy(src: &asdf_time_t, dst: *mut asdf_time_t) -> bool {
372    let out = unsafe { &mut *dst };
373    out.value = if src.value.is_null() {
374        core::ptr::null_mut()
375    } else {
376        let text = unsafe { CStr::from_ptr(src.value) };
377        match CString::new(text.to_bytes()) {
378            Ok(copy) => copy.into_raw(),
379            Err(_) => return false,
380        }
381    };
382    out.info = src.info;
383    out.format = src.format;
384    out.scale = src.scale;
385    out.location = src.location;
386    true
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use asdf_core::yaml::parse_document;
393
394    fn read(yaml: &str) -> asdf_time_t {
395        let doc = parse_document(yaml).unwrap();
396        let root = doc.root().unwrap();
397        let node = doc.mapping_get(root, "t").unwrap();
398        let mut time = asdf_time_t::zeroed();
399        assert_eq!(
400            time_deserialize(&doc, node, core::ptr::null_mut(), &mut time),
401            AsdfValueErr::Ok
402        );
403        time
404    }
405
406    fn value_of(time: &asdf_time_t) -> String {
407        unsafe { CStr::from_ptr(time.value) }.to_string_lossy().into_owned()
408    }
409
410    #[test]
411    fn reads_the_bare_string_shorthand() {
412        let time = read("t: '2026-09-04T12:00:00'\n");
413        assert_eq!(value_of(&time), "2026-09-04T12:00:00");
414        assert_eq!(time.format, TimeFormat::Iso as c_int);
415        assert_eq!(time.scale, TimeScale::Utc as c_int);
416        // The calendar reading is filled in.
417        assert_eq!(time.info.tm.tm_year, 2026 - 1900);
418        assert_eq!(time.info.tm.tm_mon, 8, "September is month 8 in struct tm");
419        assert_eq!(time.info.tm.tm_mday, 4);
420        assert_eq!(time.info.tm.tm_hour, 12);
421    }
422
423    #[test]
424    fn reads_the_mapping_form() {
425        let time = read("t:\n  value: 1000000.0\n  format: unix\n  scale: tai\n");
426        assert_eq!(value_of(&time), "1000000.0");
427        assert_eq!(time.format, TimeFormat::Unix as c_int);
428        assert_eq!(time.scale, TimeScale::Tai as c_int);
429        assert_eq!(time.info.ts.tv_sec, 1_000_000);
430    }
431
432    #[test]
433    fn base_format_overrides_format() {
434        // The schema's split: `format` carries a standard stand-in and
435        // `base_format` the real one, which wins.
436        let time = read("t:\n  value: '2026-09-04T12:00:00'\n  format: iso\n  base_format: isot\n");
437        assert_eq!(time.format, TimeFormat::Isot as c_int, "base_format must win over format");
438    }
439
440    #[test]
441    fn reads_a_location() {
442        let time = read(
443            "t:\n  value: '2026-01-01'\n  format: iso\n  \
444             location:\n    longitude: -155.47\n    latitude: 19.82\n    height: 4205.0\n",
445        );
446        assert!((time.location.longitude - (-155.47)).abs() < 1e-9);
447        assert!((time.location.latitude - 19.82).abs() < 1e-9);
448        assert!((time.location.height - 4205.0).abs() < 1e-9);
449    }
450
451    #[test]
452    fn an_unparseable_value_still_round_trips() {
453        // The value, format and scale are authoritative; a breakdown that
454        // cannot be computed leaves the info zeroed rather than failing.
455        let time = read("t:\n  value: 'not a date'\n  format: iso\n");
456        assert_eq!(value_of(&time), "not a date");
457        assert_eq!(time.info.ts.tv_sec, 0);
458    }
459
460    /// Upstream always writes the mapping form, even for a plain UTC ISO
461    /// time. Writing the bare-string shorthand instead would be terser but
462    /// would not be what a libasdf-written file looks like.
463    #[test]
464    fn serializes_a_plain_utc_iso_time_as_a_mapping() {
465        let mut doc = Document::new_asdf();
466        let value = CString::new("2026-09-04T12:00:00").unwrap();
467        let time = asdf_time_t { value: value.as_ptr().cast_mut(), ..asdf_time_t::zeroed() };
468        let node = time_serialize(&mut doc, &time).unwrap();
469
470        assert!(doc.node(node).is_mapping());
471        let written = doc.mapping_get(node, "value").unwrap();
472        assert_eq!(doc.node(written).as_str(), Some("2026-09-04T12:00:00"));
473        let format = doc.mapping_get(node, "format").unwrap();
474        assert_eq!(doc.node(format).as_str(), Some("iso"));
475        assert!(doc.mapping_get(node, "scale").is_none(), "UTC is the default");
476        assert!(doc.mapping_get(node, "location").is_none(), "no location was set");
477    }
478
479    #[test]
480    fn serializes_other_formats_into_base_format() {
481        let mut doc = Document::new_asdf();
482        let value = CString::new("2026-09-04T12:00:00").unwrap();
483        let time = asdf_time_t {
484            value: value.as_ptr().cast_mut(),
485            format: TimeFormat::Isot as c_int,
486            ..asdf_time_t::zeroed()
487        };
488        let node = time_serialize(&mut doc, &time).unwrap();
489
490        // An "other" format is written in `base_format` *instead of*
491        // `format`, not alongside it: the schema's `format` enum does not
492        // contain it, and a reader re-infers the wire form from the value.
493        assert!(doc.node(node).is_mapping());
494        assert!(doc.mapping_get(node, "format").is_none(), "`format` is omitted entirely");
495        let base = doc.mapping_get(node, "base_format").unwrap();
496        assert_eq!(doc.node(base).as_str(), Some("isot"), "the real format goes here");
497    }
498
499    /// A `J`- or `B`-prefixed string under `jyear`/`byear` is really the
500    /// `*_str` form, and is relabelled so it round-trips as one.
501    #[test]
502    fn prefixed_epoch_year_strings_become_their_str_forms() {
503        for (text, format, expected) in [
504            ("J2000.0", TimeFormat::Jyear, "jyear_str"),
505            ("B1950.0", TimeFormat::Byear, "byear_str"),
506        ] {
507            let mut doc = Document::new_asdf();
508            let value = CString::new(text).unwrap();
509            let time = asdf_time_t {
510                value: value.as_ptr().cast_mut(),
511                format: format as c_int,
512                ..asdf_time_t::zeroed()
513            };
514            let node = time_serialize(&mut doc, &time).unwrap();
515            assert!(doc.mapping_get(node, "format").is_none(), "{text}");
516            let base = doc.mapping_get(node, "base_format").unwrap();
517            assert_eq!(doc.node(base).as_str(), Some(expected), "{text}");
518        }
519
520        // A numeric jyear keeps the standard format.
521        let mut doc = Document::new_asdf();
522        let value = CString::new("2000.0").unwrap();
523        let time = asdf_time_t {
524            value: value.as_ptr().cast_mut(),
525            format: TimeFormat::Jyear as c_int,
526            ..asdf_time_t::zeroed()
527        };
528        let node = time_serialize(&mut doc, &time).unwrap();
529        let format = doc.mapping_get(node, "format").unwrap();
530        assert_eq!(doc.node(format).as_str(), Some("jyear"));
531    }
532
533    /// `plot_date` has a numeric value with no string wire form, so it is
534    /// rewritten as an ISO date-time from the instant it names.
535    #[test]
536    fn a_numeric_other_format_is_rewritten_as_a_date_time() {
537        let mut doc = Document::new_asdf();
538        // Matplotlib day 1 is 0001-01-01 in its own reckoning.
539        let value = CString::new("739903.5").unwrap();
540        let time = asdf_time_t {
541            value: value.as_ptr().cast_mut(),
542            format: TimeFormat::PlotDate as c_int,
543            ..asdf_time_t::zeroed()
544        };
545        let node = time_serialize(&mut doc, &time).unwrap();
546
547        let written = doc.mapping_get(node, "value").unwrap();
548        let text = doc.node(written).as_str().unwrap();
549        assert!(text.contains('T'), "a numeric plot_date becomes a date-time: {text}");
550        let base = doc.mapping_get(node, "base_format").unwrap();
551        assert_eq!(doc.node(base).as_str(), Some("plot_date"));
552
553        // A value that is already a date-time string is written verbatim.
554        let mut doc = Document::new_asdf();
555        let value = CString::new("2025-10-14T13:26:41").unwrap();
556        let time = asdf_time_t {
557            value: value.as_ptr().cast_mut(),
558            format: TimeFormat::PlotDate as c_int,
559            ..asdf_time_t::zeroed()
560        };
561        let node = time_serialize(&mut doc, &time).unwrap();
562        let written = doc.mapping_get(node, "value").unwrap();
563        assert_eq!(doc.node(written).as_str(), Some("2025-10-14T13:26:41"));
564    }
565
566    #[test]
567    fn a_non_utc_scale_is_written_out() {
568        let mut doc = Document::new_asdf();
569        let value = CString::new("1000.0").unwrap();
570        let time = asdf_time_t {
571            value: value.as_ptr().cast_mut(),
572            format: TimeFormat::Unix as c_int,
573            scale: TimeScale::Tai as c_int,
574            ..asdf_time_t::zeroed()
575        };
576        let node = time_serialize(&mut doc, &time).unwrap();
577        let scale = doc.mapping_get(node, "scale").unwrap();
578        assert_eq!(doc.node(scale).as_str(), Some("tai"));
579    }
580
581    #[test]
582    fn format_names_match_upstream() {
583        let name = |format: c_int| {
584            let ptr = asdf_time_format_string(format);
585            unsafe { crate::ffi::c_str(ptr) }.map(|s| s.to_string_lossy().into_owned())
586        };
587        assert_eq!(name(0).as_deref(), Some("iso"));
588        assert_eq!(name(10).as_deref(), Some("tai_seconds"));
589        assert_eq!(name(22).as_deref(), Some("datetime64"));
590        // The reserved slot has no name, as upstream.
591        assert_eq!(name(14), None);
592        // Out of range is null rather than a crash.
593        assert_eq!(name(999), None);
594        assert_eq!(name(-1), None);
595    }
596
597    #[test]
598    fn parse_recomputes_the_breakdown() {
599        let value = CString::new("2000-01-01T00:00:00").unwrap();
600        let mut time = asdf_time_t { value: value.as_ptr().cast_mut(), ..asdf_time_t::zeroed() };
601        assert_eq!(unsafe { asdf_time_parse(&mut time) }, 0);
602        assert_eq!(time.info.tm.tm_year, 100);
603        assert_eq!(time.info.tm.tm_mon, 0);
604        assert_eq!(time.info.tm.tm_mday, 1);
605
606        // A value that will not parse reports failure.
607        let bad = CString::new("nonsense").unwrap();
608        let mut time = asdf_time_t { value: bad.as_ptr().cast_mut(), ..asdf_time_t::zeroed() };
609        assert_eq!(unsafe { asdf_time_parse(&mut time) }, -1);
610
611        assert_eq!(unsafe { asdf_time_parse(core::ptr::null_mut()) }, -1);
612    }
613
614    #[test]
615    fn struct_tm_conventions_are_honoured() {
616        // tm_year counts from 1900, tm_mon from zero, tm_yday from zero.
617        let time = read("t: '1970-01-01T00:00:00'\n");
618        assert_eq!(time.info.tm.tm_year, 70);
619        assert_eq!(time.info.tm.tm_mon, 0);
620        assert_eq!(time.info.tm.tm_mday, 1);
621        assert_eq!(time.info.tm.tm_yday, 0);
622        assert_eq!(time.info.tm.tm_wday, 4, "1970-01-01 was a Thursday");
623        assert_eq!(time.info.ts.tv_sec, 0);
624    }
625}