1use chrono::DateTime;
10use log::debug;
11use num_traits::FromPrimitive;
12use std::os::raw::{c_char, c_int, c_void};
13use std::panic::{AssertUnwindSafe, catch_unwind};
14
15use crate::{
16 common::ptr_to_string,
17 err::ReadStatError,
18 formats,
19 rs_data::{ColumnBuilder, ReadStatData, StreamingData},
20 rs_metadata::{ReadStatCompress, ReadStatEndian, ReadStatMetadata, ReadStatVarMetadata},
21 rs_var::{ReadStatVarFormatClass, ReadStatVarType, ReadStatVarTypeClass},
22};
23
24#[allow(dead_code, non_camel_case_types)]
30#[derive(Debug)]
31#[repr(C)]
32enum ReadStatHandler {
33 READSTAT_HANDLER_OK,
34 READSTAT_HANDLER_ABORT,
35 READSTAT_HANDLER_SKIP_VARIABLE,
36}
37
38pub(crate) fn catch_callback<T>(failure: T, f: impl FnOnce() -> T) -> T {
42 catch_unwind(AssertUnwindSafe(f)).unwrap_or(failure)
43}
44
45fn row_count_from_c(row_count: c_int) -> Option<i32> {
46 (row_count != -1).then_some(row_count)
47}
48
49#[allow(
61 clippy::cast_possible_truncation,
62 clippy::cast_sign_loss,
63 clippy::cast_possible_wrap
64)]
65pub(crate) extern "C" fn handle_metadata(
66 metadata: *mut readstat_sys::readstat_metadata_t,
67 ctx: *mut c_void,
68) -> c_int {
69 catch_callback(ReadStatHandler::READSTAT_HANDLER_ABORT as c_int, || {
70 handle_metadata_inner(metadata, ctx)
71 })
72}
73
74fn handle_metadata_inner(
75 metadata: *mut readstat_sys::readstat_metadata_t,
76 ctx: *mut c_void,
77) -> c_int {
78 let m = unsafe { &mut *ctx.cast::<ReadStatMetadata>() };
80
81 let rc: c_int = unsafe { readstat_sys::readstat_get_row_count(metadata) };
83 let vc: c_int = unsafe { readstat_sys::readstat_get_var_count(metadata) };
84 let table_name = unsafe { ptr_to_string(readstat_sys::readstat_get_table_name(metadata)) };
85 let file_label = unsafe { ptr_to_string(readstat_sys::readstat_get_file_label(metadata)) };
86 let file_encoding =
87 unsafe { ptr_to_string(readstat_sys::readstat_get_file_encoding(metadata)) };
88 let version: c_int = unsafe { readstat_sys::readstat_get_file_format_version(metadata) };
89 let is_64bit = unsafe { readstat_sys::readstat_get_file_format_is_64bit(metadata) };
90 let ct = DateTime::from_timestamp(
91 unsafe { readstat_sys::readstat_get_creation_time(metadata) },
92 0,
93 )
94 .unwrap_or_default()
95 .format("%Y-%m-%d %H:%M:%S")
96 .to_string();
97 let mt = DateTime::from_timestamp(
98 unsafe { readstat_sys::readstat_get_modified_time(metadata) },
99 0,
100 )
101 .unwrap_or_default()
102 .format("%Y-%m-%d %H:%M:%S")
103 .to_string();
104
105 #[allow(clippy::useless_conversion)]
106 let compression =
107 FromPrimitive::from_i32(unsafe { readstat_sys::readstat_get_compression(metadata) } as i32)
108 .unwrap_or(ReadStatCompress::None);
109
110 #[allow(clippy::useless_conversion)]
111 let endianness =
112 FromPrimitive::from_i32(unsafe { readstat_sys::readstat_get_endianness(metadata) } as i32)
113 .unwrap_or(ReadStatEndian::None);
114
115 debug!("row_count is {rc}");
116 debug!("var_count is {vc}");
117 debug!("table_name is {table_name}");
118 debug!("file_label is {file_label}");
119 debug!("file_encoding is {file_encoding}");
120 debug!("version is {version}");
121 debug!("is_64bit is {is_64bit}");
122 debug!("creation_time is {ct}");
123 debug!("modified_time is {mt}");
124 debug!("compression is {compression:#?}");
125 debug!("endianness is {endianness:#?}");
126
127 m.row_count = row_count_from_c(rc);
129 m.var_count = vc;
130 m.table_name = table_name;
131 m.file_label = file_label;
132 m.file_encoding = file_encoding;
133 m.version = version;
134 m.is_64bit = is_64bit != 0;
135 m.creation_time = ct;
136 m.modified_time = mt;
137 m.compression = compression;
138 m.endianness = endianness;
139
140 debug!("metadata struct is {m:#?}");
141
142 ReadStatHandler::READSTAT_HANDLER_OK as c_int
143}
144
145#[allow(
157 clippy::cast_possible_truncation,
158 clippy::cast_sign_loss,
159 clippy::cast_possible_wrap
160)]
161pub(crate) extern "C" fn handle_variable(
162 index: c_int,
163 variable: *mut readstat_sys::readstat_variable_t,
164 #[allow(unused_variables)] val_labels: *const c_char,
165 ctx: *mut c_void,
166) -> c_int {
167 catch_callback(ReadStatHandler::READSTAT_HANDLER_ABORT as c_int, || {
168 handle_variable_inner(index, variable, val_labels, ctx)
169 })
170}
171
172fn handle_variable_inner(
173 index: c_int,
174 variable: *mut readstat_sys::readstat_variable_t,
175 #[allow(unused_variables)] val_labels: *const c_char,
176 ctx: *mut c_void,
177) -> c_int {
178 let m = unsafe { &mut *ctx.cast::<ReadStatMetadata>() };
180
181 #[allow(clippy::useless_conversion)]
183 let var_type =
184 FromPrimitive::from_i32(
185 unsafe { readstat_sys::readstat_variable_get_type(variable) } as i32,
186 )
187 .unwrap_or(ReadStatVarType::Unknown);
188
189 #[allow(clippy::useless_conversion)]
190 let var_type_class =
191 FromPrimitive::from_i32(
192 unsafe { readstat_sys::readstat_variable_get_type_class(variable) } as i32,
193 )
194 .unwrap_or(ReadStatVarTypeClass::Numeric);
195
196 let var_name = unsafe { ptr_to_string(readstat_sys::readstat_variable_get_name(variable)) };
197 let var_label = unsafe { ptr_to_string(readstat_sys::readstat_variable_get_label(variable)) };
198 let var_format = unsafe { ptr_to_string(readstat_sys::readstat_variable_get_format(variable)) };
199 let var_format_class = formats::match_var_format(&var_format);
200 let storage_width =
201 unsafe { readstat_sys::readstat_variable_get_storage_width(variable) } as usize;
202 let display_width =
203 unsafe { readstat_sys::readstat_variable_get_display_width(variable) } as i32;
204
205 debug!("var_type is {var_type:#?}");
206 debug!("var_type_class is {var_type_class:#?}");
207 debug!("var_name is {var_name}");
208 debug!("var_label is {var_label}");
209 debug!("var_format is {var_format}");
210 debug!("var_format_class is {var_format_class:#?}");
211 debug!("storage_width is {storage_width}");
212 debug!("display_width is {display_width}");
213
214 m.vars.insert(
216 index,
217 ReadStatVarMetadata::new(
218 var_name,
219 var_type,
220 var_type_class,
221 var_label,
222 var_format,
223 var_format_class,
224 storage_width,
225 display_width,
226 ),
227 );
228
229 ReadStatHandler::READSTAT_HANDLER_OK as c_int
230}
231
232pub(crate) const DAY_SHIFT: i32 = 3653;
234pub(crate) const SEC_SHIFT: i64 = 315_619_200;
236
237pub(crate) const ROUND_SCALE: f64 = 1e14;
239
240#[inline]
250pub(crate) fn round_decimal_f64(v: f64) -> f64 {
251 if !v.is_finite() {
252 return v;
253 }
254 let int_part = v.trunc();
255 let frac_part = v.fract(); let rounded_frac = (frac_part * ROUND_SCALE).round() / ROUND_SCALE;
257 int_part + rounded_frac
258}
259
260#[inline]
262#[allow(clippy::cast_possible_truncation)]
263pub(crate) fn round_decimal_f32(v: f32) -> f32 {
264 if !v.is_finite() {
265 return v;
266 }
267 let v64 = f64::from(v);
269 let int_part = v64.trunc();
270 let frac_part = v64.fract();
271 let rounded_frac = (frac_part * ROUND_SCALE).round() / ROUND_SCALE;
272 (int_part + rounded_frac) as f32
273}
274
275#[inline]
281#[allow(clippy::cast_possible_truncation)]
282fn checked_f64_to_i64(v: f64) -> Option<i64> {
283 if v.is_finite() && v >= i64::MIN as f64 && v < i64::MAX as f64 {
286 Some(v as i64)
287 } else {
288 None
289 }
290}
291
292#[inline]
295#[allow(clippy::cast_possible_truncation)]
296fn checked_f64_to_i32(v: f64) -> Option<i32> {
297 if v.is_finite() && v >= f64::from(i32::MIN) && v <= f64::from(i32::MAX) {
301 Some(v as i32)
302 } else {
303 None
304 }
305}
306
307#[inline]
313fn sas_datetime_to_unix_subsec(val: f64, scale: f64) -> Option<i64> {
314 #[allow(clippy::cast_precision_loss)]
315 checked_f64_to_i64(((val - SEC_SHIFT as f64) * scale).round())
316}
317
318#[inline]
321fn sas_time_to_ms(val: f64) -> Option<i32> {
322 checked_f64_to_i32((val * 1_000.0).round())
323}
324
325#[inline]
328fn sas_time_to_us(val: f64) -> Option<i64> {
329 checked_f64_to_i64((val * 1_000_000.0).round())
330}
331
332#[inline]
335fn sas_time_to_ns(val: f64) -> Option<i64> {
336 checked_f64_to_i64((val * 1_000_000_000.0).round())
337}
338
339#[allow(
353 clippy::too_many_lines,
354 clippy::cast_possible_truncation,
355 clippy::cast_sign_loss,
356 clippy::cast_precision_loss
357)]
358pub(crate) extern "C" fn handle_value(
359 obs_index: c_int,
360 variable: *mut readstat_sys::readstat_variable_t,
361 value: readstat_sys::readstat_value_t,
362 ctx: *mut c_void,
363) -> c_int {
364 match catch_unwind(AssertUnwindSafe(|| {
365 handle_value_inner(obs_index, variable, value, ctx)
366 })) {
367 Ok(result) => result,
368 Err(_) => {
369 if !ctx.is_null() {
370 unsafe { &mut *ctx.cast::<ReadStatData>() }.abort_error =
371 Some(ReadStatError::CallbackPanic);
372 }
373 ReadStatHandler::READSTAT_HANDLER_ABORT as c_int
374 }
375 }
376}
377
378pub(crate) extern "C" fn handle_streaming_value(
379 obs_index: c_int,
380 variable: *mut readstat_sys::readstat_variable_t,
381 value: readstat_sys::readstat_value_t,
382 ctx: *mut c_void,
383) -> c_int {
384 match catch_unwind(AssertUnwindSafe(|| {
385 let stream = unsafe { &mut *ctx.cast::<StreamingData<'_>>() };
386 let var_index = unsafe { readstat_sys::readstat_variable_get_index(variable) };
387 let result = handle_value_inner(
388 obs_index,
389 variable,
390 value,
391 std::ptr::from_mut(&mut stream.data).cast(),
392 );
393 if result != ReadStatHandler::READSTAT_HANDLER_OK as c_int {
394 return result;
395 }
396 if var_index == stream.data.total_var_count - 1
397 && let Err(error) = stream.row_complete()
398 {
399 stream.data.abort_error = Some(error);
400 return ReadStatHandler::READSTAT_HANDLER_ABORT as c_int;
401 }
402 result
403 })) {
404 Ok(result) => result,
405 Err(_) => {
406 if !ctx.is_null() {
407 unsafe { &mut *ctx.cast::<StreamingData<'_>>() }
408 .data
409 .abort_error = Some(ReadStatError::CallbackPanic);
410 }
411 ReadStatHandler::READSTAT_HANDLER_ABORT as c_int
412 }
413 }
414}
415
416fn handle_value_inner(
417 obs_index: c_int,
418 variable: *mut readstat_sys::readstat_variable_t,
419 value: readstat_sys::readstat_value_t,
420 ctx: *mut c_void,
421) -> c_int {
422 let d = unsafe { &mut *ctx.cast::<ReadStatData>() };
424
425 let var_index: c_int = unsafe { readstat_sys::readstat_variable_get_index(variable) };
427 let value_type: readstat_sys::readstat_type_t =
428 unsafe { readstat_sys::readstat_value_type(value) };
429 let is_missing: c_int = unsafe { readstat_sys::readstat_value_is_missing(value, variable) };
430
431 debug!("chunk_rows_to_process is {}", d.chunk_rows_to_process);
432 debug!("chunk_row_start is {}", d.chunk_row_start);
433 debug!("chunk_row_end is {}", d.chunk_row_end);
434 debug!("chunk_rows_processed is {}", d.chunk_rows_processed);
435 debug!("var_count is {}", d.var_count);
436 debug!("obs_index is {obs_index}");
437 debug!("var_index is {var_index}");
438 debug!("value_type is {value_type:#?}");
439 debug!("is_missing is {is_missing}");
440
441 let col_index = match &d.column_filter {
445 Some(filter) => filter.get(&var_index).copied(),
446 None => Some(var_index),
447 };
448
449 let Some(col_index) = col_index else {
450 d.note_value(var_index);
453 return ReadStatHandler::READSTAT_HANDLER_OK as c_int;
454 };
455
456 macro_rules! type_mismatch_abort {
462 () => {{
463 d.abort_error = Some(ReadStatError::Other(format!(
464 "ReadStat value type did not match the expected Arrow builder for column index {col_index}"
465 )));
466 return ReadStatHandler::READSTAT_HANDLER_ABORT as c_int;
467 }};
468 }
469
470 let Some(builder) = d.builders.get_mut(col_index as usize) else {
472 type_mismatch_abort!();
473 };
474
475 macro_rules! date_overflow_abort {
477 () => {{
478 d.abort_error = Some(ReadStatError::DateOverflow);
479 return ReadStatHandler::READSTAT_HANDLER_ABORT as c_int;
480 }};
481 }
482
483 match value_type {
484 readstat_sys::readstat_type_e_READSTAT_TYPE_STRING
485 | readstat_sys::readstat_type_e_READSTAT_TYPE_STRING_REF => {
486 let ColumnBuilder::Str(sb) = builder else {
487 type_mismatch_abort!();
488 };
489 if is_missing == 1 {
490 sb.append_null();
491 } else {
492 let ptr = unsafe { readstat_sys::readstat_string_value(value) };
493 if ptr.is_null() {
494 sb.append_null();
495 } else {
496 let cstr = unsafe { std::ffi::CStr::from_ptr(ptr) };
497 if let Ok(s) = cstr.to_str() {
499 sb.append_value(s);
500 } else {
501 let s = String::from_utf8_lossy(cstr.to_bytes());
503 sb.append_value(s.as_ref());
504 }
505 }
506 }
507 }
508 readstat_sys::readstat_type_e_READSTAT_TYPE_INT8 => {
509 if is_missing == 1 {
510 builder.append_null();
511 } else {
512 let v = unsafe { readstat_sys::readstat_int8_value(value) };
513 debug!("value is {v:#?}");
514 if let ColumnBuilder::Int16(b) = builder {
516 b.append_value(i16::from(v));
517 } else {
518 type_mismatch_abort!();
519 }
520 }
521 }
522 readstat_sys::readstat_type_e_READSTAT_TYPE_INT16 => {
523 if is_missing == 1 {
524 builder.append_null();
525 } else {
526 let v = unsafe { readstat_sys::readstat_int16_value(value) };
527 debug!("value is {v:#?}");
528 if let ColumnBuilder::Int16(b) = builder {
529 b.append_value(v);
530 } else {
531 type_mismatch_abort!();
532 }
533 }
534 }
535 readstat_sys::readstat_type_e_READSTAT_TYPE_INT32 => {
536 if is_missing == 1 {
537 builder.append_null();
538 } else {
539 let v = unsafe { readstat_sys::readstat_int32_value(value) };
540 debug!("value is {v:#?}");
541 if let ColumnBuilder::Int32(b) = builder {
542 b.append_value(v);
543 } else {
544 type_mismatch_abort!();
545 }
546 }
547 }
548 readstat_sys::readstat_type_e_READSTAT_TYPE_FLOAT => {
549 if is_missing == 1 {
550 builder.append_null();
551 } else {
552 let raw = unsafe { readstat_sys::readstat_float_value(value) };
553 debug!("value (before parsing) is {raw:#?}");
554 let val = round_decimal_f32(raw);
555 debug!("value (after parsing) is {val:#?}");
556 if let ColumnBuilder::Float32(b) = builder {
557 b.append_value(val);
558 } else {
559 type_mismatch_abort!();
560 }
561 }
562 }
563 readstat_sys::readstat_type_e_READSTAT_TYPE_DOUBLE => {
564 let var_format_class = d.vars.get(&col_index).and_then(|vm| vm.var_format_class);
565
566 if is_missing == 1 {
567 builder.append_null();
568 } else {
569 let raw = unsafe { readstat_sys::readstat_double_value(value) };
570 debug!("value (before parsing) is {raw:#?}");
571 let val = round_decimal_f64(raw);
572 debug!("value (after parsing) is {val:#?}");
573
574 match var_format_class {
575 None => {
576 if let ColumnBuilder::Float64(b) = builder {
577 b.append_value(val);
578 } else {
579 type_mismatch_abort!();
580 }
581 }
582 Some(ReadStatVarFormatClass::Date) => {
583 if let ColumnBuilder::Date32(b) = builder {
584 match checked_f64_to_i32(val)
585 .and_then(|days| days.checked_sub(DAY_SHIFT))
586 {
587 Some(shifted) => b.append_value(shifted),
588 None => date_overflow_abort!(),
589 }
590 } else {
591 type_mismatch_abort!();
592 }
593 }
594 Some(ReadStatVarFormatClass::DateTime) => {
595 if let ColumnBuilder::TimestampSecond(b) = builder {
596 match checked_f64_to_i64(val).and_then(|s| s.checked_sub(SEC_SHIFT)) {
597 Some(shifted) => b.append_value(shifted),
598 None => date_overflow_abort!(),
599 }
600 } else {
601 type_mismatch_abort!();
602 }
603 }
604 Some(ReadStatVarFormatClass::DateTimeWithMilliseconds) => {
605 if let ColumnBuilder::TimestampMillisecond(b) = builder {
606 match sas_datetime_to_unix_subsec(val, 1e3) {
607 Some(v) => b.append_value(v),
608 None => date_overflow_abort!(),
609 }
610 } else {
611 type_mismatch_abort!();
612 }
613 }
614 Some(ReadStatVarFormatClass::DateTimeWithMicroseconds) => {
615 if let ColumnBuilder::TimestampMicrosecond(b) = builder {
616 match sas_datetime_to_unix_subsec(val, 1e6) {
617 Some(v) => b.append_value(v),
618 None => date_overflow_abort!(),
619 }
620 } else {
621 type_mismatch_abort!();
622 }
623 }
624 Some(ReadStatVarFormatClass::DateTimeWithNanoseconds) => {
625 if let ColumnBuilder::TimestampNanosecond(b) = builder {
626 match sas_datetime_to_unix_subsec(val, 1e9) {
627 Some(v) => b.append_value(v),
628 None => date_overflow_abort!(),
629 }
630 } else {
631 type_mismatch_abort!();
632 }
633 }
634 Some(ReadStatVarFormatClass::Time) => {
635 if let ColumnBuilder::Time32Second(b) = builder {
636 match checked_f64_to_i32(val) {
637 Some(v) => b.append_value(v),
638 None => date_overflow_abort!(),
639 }
640 } else {
641 type_mismatch_abort!();
642 }
643 }
644 Some(ReadStatVarFormatClass::TimeWithMilliseconds) => {
645 if let ColumnBuilder::Time32Millisecond(b) = builder {
646 match sas_time_to_ms(val) {
647 Some(v) => b.append_value(v),
648 None => date_overflow_abort!(),
649 }
650 } else {
651 type_mismatch_abort!();
652 }
653 }
654 Some(ReadStatVarFormatClass::TimeWithMicroseconds) => {
655 if let ColumnBuilder::Time64Microsecond(b) = builder {
656 match sas_time_to_us(val) {
657 Some(v) => b.append_value(v),
658 None => date_overflow_abort!(),
659 }
660 } else {
661 type_mismatch_abort!();
662 }
663 }
664 Some(ReadStatVarFormatClass::TimeWithNanoseconds) => {
665 if let ColumnBuilder::Time64Nanosecond(b) = builder {
666 match sas_time_to_ns(val) {
667 Some(v) => b.append_value(v),
668 None => date_overflow_abort!(),
669 }
670 } else {
671 type_mismatch_abort!();
672 }
673 }
674 }
675 }
676 }
677 _ => {
678 d.abort_error = Some(ReadStatError::Other(format!(
679 "ReadStat returned an unsupported value type ({value_type}) for column index {col_index}"
680 )));
681 return ReadStatHandler::READSTAT_HANDLER_ABORT as c_int;
682 }
683 }
684
685 d.note_value(var_index);
687
688 ReadStatHandler::READSTAT_HANDLER_OK as c_int
689}
690
691#[cfg(test)]
692mod tests {
693 use super::*;
694
695 #[test]
696 fn catch_callback_converts_panic_to_failure() {
697 assert_eq!(catch_callback(-1, || panic!("test panic")), -1);
698 assert_eq!(catch_callback(-1, || 7), 7);
699 }
700
701 #[test]
702 #[cfg_attr(miri, ignore = "calls ReadStat C FFI, which Miri cannot execute")]
703 #[cfg_attr(
704 target_env = "msvc",
705 ignore = "synthetic bindgen bitfields do not use MSVC's C storage offset"
706 )]
707 fn tagged_missing_uses_combined_predicate() {
708 let mut value: readstat_sys::readstat_value_t = unsafe { std::mem::zeroed() };
709 value.type_ = readstat_sys::readstat_type_e_READSTAT_TYPE_DOUBLE;
710 value.set_is_tagged_missing(1);
711 value.tag = b'A' as c_char;
712 assert_eq!(
713 unsafe { readstat_sys::readstat_value_is_system_missing(value) },
714 0
715 );
716 assert_eq!(
719 unsafe { readstat_sys::readstat_value_is_missing(value, std::ptr::null_mut()) },
720 1
721 );
722 }
723
724 #[test]
725 fn unknown_row_count_sentinel_is_none() {
726 assert_eq!(row_count_from_c(-1), None);
727 assert_eq!(row_count_from_c(42), Some(42));
728 }
729
730 mod property_tests {
731 use super::*;
732 use proptest::prelude::*;
733
734 proptest! {
737 #[test]
738 fn round_f64_is_idempotent(v in any::<f64>()) {
739 let once = round_decimal_f64(v);
740 let twice = round_decimal_f64(once);
741 prop_assert!((once == twice) || (once.is_nan() && twice.is_nan()),
744 "not idempotent: round({}) = {}, round(round({})) = {}", v, once, v, twice);
745 }
746
747 #[test]
748 fn round_f64_preserves_sign(v in any::<f64>().prop_filter("finite nonzero", |v| v.is_finite() && *v != 0.0)) {
749 let rounded = round_decimal_f64(v);
750 prop_assert_eq!(v.is_sign_positive(), rounded.is_sign_positive());
751 }
752
753 #[test]
754 fn round_f64_preserves_finiteness(v in any::<f64>()) {
755 let rounded = round_decimal_f64(v);
756 prop_assert_eq!(v.is_finite(), rounded.is_finite());
757 }
758
759 #[test]
760 fn round_f64_bounded_error(v in any::<f64>().prop_filter("finite", |v| v.is_finite())) {
761 let rounded = round_decimal_f64(v);
762 let error = (v - rounded).abs();
763 let magnitude_error = v.abs() * f64::EPSILON;
769 prop_assert!(error <= 5e-15 + magnitude_error,
770 "error {} too large for input {} (bound {})", error, v, 5e-15 + magnitude_error);
771 }
772
773 #[test]
774 fn round_f64_passthrough_nonfinite(v in prop::num::f64::ANY.prop_filter("non-finite", |v| !v.is_finite())) {
775 let rounded = round_decimal_f64(v);
776 prop_assert_eq!(v.to_bits(), rounded.to_bits());
777 }
778 }
779
780 proptest! {
783 #[test]
784 fn round_f32_is_idempotent(v in any::<f32>()) {
785 let once = round_decimal_f32(v);
786 let twice = round_decimal_f32(once);
787 prop_assert!((once == twice) || (once.is_nan() && twice.is_nan()),
790 "not idempotent: round({}) = {}, round(round({})) = {}", v, once, v, twice);
791 }
792
793 #[test]
794 fn round_f32_preserves_sign(v in any::<f32>().prop_filter("finite nonzero", |v| v.is_finite() && *v != 0.0)) {
795 let rounded = round_decimal_f32(v);
796 prop_assert_eq!(v.is_sign_positive(), rounded.is_sign_positive());
797 }
798
799 #[test]
800 fn round_f32_preserves_finiteness(v in any::<f32>()) {
801 let rounded = round_decimal_f32(v);
802 prop_assert_eq!(v.is_finite(), rounded.is_finite());
803 }
804
805 #[test]
806 fn round_f32_passthrough_nonfinite(v in prop::num::f32::ANY.prop_filter("non-finite", |v| !v.is_finite())) {
807 let rounded = round_decimal_f32(v);
808 prop_assert_eq!(v.to_bits(), rounded.to_bits());
809 }
810 }
811
812 proptest! {
815 #[test]
818 fn day_shift_no_overflow(sas_days in (i32::MIN + DAY_SHIFT)..=i32::MAX) {
819 let shifted = sas_days.checked_sub(DAY_SHIFT);
820 prop_assert!(shifted.is_some(), "DAY_SHIFT overflow for sas_days={}", sas_days);
821 }
822
823 #[test]
826 fn sec_shift_no_overflow(sas_secs in (i64::MIN + SEC_SHIFT)..=i64::MAX) {
827 let shifted = sas_secs.checked_sub(SEC_SHIFT);
828 prop_assert!(shifted.is_some(), "SEC_SHIFT overflow for sas_secs={}", sas_secs);
829 }
830
831 #[test]
833 fn day_shift_round_trip(sas_days in (i32::MIN + DAY_SHIFT)..=i32::MAX) {
834 let unix_days = sas_days - DAY_SHIFT;
835 let back = unix_days + DAY_SHIFT;
836 prop_assert_eq!(sas_days, back);
837 }
838
839 #[test]
841 fn sec_shift_round_trip(sas_secs in (i64::MIN + SEC_SHIFT)..=i64::MAX) {
842 let unix_secs = sas_secs - SEC_SHIFT;
843 let back = unix_secs + SEC_SHIFT;
844 prop_assert_eq!(sas_secs, back);
845 }
846 }
847 } mod subsecond_conversion {
850 use super::*;
851
852 #[test]
856 fn datetime_ms_rounds_instead_of_truncates() {
857 let sas = 1_926_851_400.123_f64;
859 let ms = sas_datetime_to_unix_subsec(sas, 1e3).unwrap();
860 assert_eq!(ms % 1000, 123, "millisecond component must survive");
861 assert_eq!(ms, (1_926_851_400 - SEC_SHIFT) * 1000 + 123);
862 }
863
864 #[test]
865 fn datetime_us_rounds_instead_of_truncates() {
866 let sas = 1_926_851_400.123_456_f64;
867 let us = sas_datetime_to_unix_subsec(sas, 1e6).unwrap();
868 assert_eq!(us % 1_000_000, 123_456);
869 }
870
871 #[test]
872 fn datetime_ns_rounds_to_f64_precision() {
873 let sas = 1_926_851_400.123_f64;
878 let ns = sas_datetime_to_unix_subsec(sas, 1e9).unwrap();
879 let expected = (1_926_851_400 - SEC_SHIFT) * 1_000_000_000 + 123_000_000;
880 assert!(
881 (ns - expected).abs() <= 256,
882 "ns conversion off by more than f64 precision: {ns} vs {expected}"
883 );
884 }
885
886 #[test]
887 fn time_us_rounds_instead_of_truncates() {
888 let sas = 49_507.123_456_f64;
890 let us = sas_time_to_us(sas).unwrap();
891 assert_eq!(us, 49_507_123_456);
892 }
893
894 #[test]
895 fn time_ms_rounds_instead_of_truncates() {
896 let sas = 49_507.123_f64;
898 let ms = sas_time_to_ms(sas).unwrap();
899 assert_eq!(ms, 49_507_123);
900 }
901
902 #[test]
903 fn time_ns_rounds_instead_of_truncates() {
904 let sas = 49_507.123_456_789_f64;
907 let ns = sas_time_to_ns(sas).unwrap();
908 assert_eq!(ns, 49_507_123_456_789);
909 }
910
911 #[test]
912 fn time_ms_non_finite_is_none() {
913 assert_eq!(sas_time_to_ms(f64::NAN), None);
914 assert_eq!(sas_time_to_ns(f64::INFINITY), None);
915 }
916
917 #[test]
918 fn datetime_ms_non_finite_is_none() {
919 assert_eq!(sas_datetime_to_unix_subsec(f64::NAN, 1e3), None);
920 assert_eq!(sas_datetime_to_unix_subsec(f64::INFINITY, 1e9), None);
921 }
922
923 #[test]
927 fn datetime_ms_exact_across_fractions() {
928 let base = 2_000_000_000_i64; for frac in 0..1000 {
930 #[allow(clippy::cast_precision_loss)]
931 let sas = base as f64 + f64::from(frac) / 1000.0;
932 let ms = sas_datetime_to_unix_subsec(sas, 1e3).unwrap();
933 assert_eq!(
934 ms,
935 (base - SEC_SHIFT) * 1000 + i64::from(frac),
936 "wrong ms for fraction .{frac:03}"
937 );
938 }
939 }
940 }
941}