1use 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
17pub type TimeFormatAbi = c_int;
19pub type TimeScaleAbi = c_int;
21
22#[repr(C)]
24#[derive(Clone, Copy, Debug, Default)]
25pub struct asdf_time_location_t {
26 pub longitude: f64,
28 pub latitude: f64,
30 pub height: f64,
32}
33
34#[repr(C)]
40#[derive(Clone, Copy)]
41pub struct asdf_time_info_t {
42 pub ts: libc::timespec,
44 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 unsafe { core::mem::zeroed() }
63 }
64}
65
66#[repr(C)]
68#[derive(Debug)]
69pub struct asdf_time_t {
70 pub value: *mut c_char,
72 pub info: asdf_time_info_t,
74 pub format: TimeFormatAbi,
76 pub scale: TimeScaleAbi,
78 pub location: asdf_time_location_t,
80}
81
82impl asdf_time_t {
83 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
95fn 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 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 info.tm.tm_isdst = 0;
112 info
113}
114
115fn 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
145const 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#[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#[unsafe(no_mangle)]
203pub extern "C" fn asdf_time_format_string(format: TimeFormatAbi) -> *const c_char {
204 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 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
235pub const TIME_TAG: &str = "tag:stsci.edu:asdf/time/time-1.4.0";
237
238pub(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 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 (*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 format.name()?;
282
283 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 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 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
340fn needs_reformat(format: TimeFormat) -> bool {
346 format == TimeFormat::PlotDate
347}
348
349fn 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 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 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 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 #[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 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 #[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 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 #[test]
536 fn a_numeric_other_format_is_rewritten_as_a_date_time() {
537 let mut doc = Document::new_asdf();
538 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 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 assert_eq!(name(14), None);
592 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 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 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}