Skip to main content

readstat/
cb.rs

1//! FFI callback functions invoked by the `ReadStat` C library during parsing.
2//!
3//! The `ReadStat` C parser uses a callback-driven architecture: as it reads a `.sas7bdat`
4//! file, it invokes registered callbacks for metadata, variables, and values. Each
5//! callback receives a raw `*mut c_void` context pointer that is cast back to the
6//! appropriate Rust struct ([`ReadStatMetadata`] or [`ReadStatData`]) to accumulate
7//! parsed results.
8
9use 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/// Return codes for `ReadStat` C callback functions.
25///
26/// Mirrors the `readstat_handler_t` enum from the C API. Only `OK` and `ABORT`
27/// are currently used; `SKIP_VARIABLE` is included for completeness with the
28/// C API contract.
29#[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
38// C callback functions
39
40/// Contains Rust panics before they can unwind through C.
41pub(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/// FFI callback that extracts file-level metadata from the `ReadStat` C parser.
50///
51/// Called once during parsing. Populates the [`ReadStatMetadata`] struct
52/// (accessed via the `ctx` pointer) with row/variable counts, encoding,
53/// timestamps, compression, and endianness.
54///
55/// # Safety
56///
57/// - `metadata` must be a valid pointer to a `readstat_metadata_t` produced by the C parser.
58/// - `ctx` must be a valid pointer to a [`ReadStatMetadata`] instance that outlives this call.
59/// - This function must only be called by the `ReadStat` C library as a registered callback.
60#[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    // dereference ctx pointer
79    let m = unsafe { &mut *ctx.cast::<ReadStatMetadata>() };
80
81    // get metadata
82    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    // insert into ReadStatMetadata struct
128    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/// FFI callback that extracts per-variable metadata from the `ReadStat` C parser.
146///
147/// Called once for each variable (column) in the dataset. Populates a
148/// [`ReadStatVarMetadata`] entry in the [`ReadStatMetadata::vars`] map
149/// with the variable's name, type, label, and SAS format classification.
150///
151/// # Safety
152///
153/// - `variable` must be a valid pointer to a `readstat_variable_t` produced by the C parser.
154/// - `ctx` must be a valid pointer to a [`ReadStatMetadata`] instance that outlives this call.
155/// - This function must only be called by the `ReadStat` C library as a registered callback.
156#[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    // dereference ctx pointer
179    let m = unsafe { &mut *ctx.cast::<ReadStatMetadata>() };
180
181    // get variable metadata
182    #[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    // insert into BTreeMap within ReadStatMetadata struct
215    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
232/// SAS epoch (1960-01-01) to Unix epoch (1970-01-01) offset in days.
233pub(crate) const DAY_SHIFT: i32 = 3653;
234/// SAS epoch to Unix epoch offset in seconds.
235pub(crate) const SEC_SHIFT: i64 = 315_619_200;
236
237/// Scale factor for rounding to 14 decimal places: `10^14`.
238pub(crate) const ROUND_SCALE: f64 = 1e14;
239
240/// Rounds an f64 to 14 decimal places using pure arithmetic.
241///
242/// Eliminates the string formatting roundtrip entirely. For values like 4.6
243/// that can't be exactly represented in IEEE 754, this cleans up trailing
244/// noise (e.g. `4.6000000000000005` → `4.6`).
245///
246/// Splits into integer and fractional parts before scaling to avoid overflow:
247/// large SAS datetime values (~1.9e9) multiplied by 1e14 would exceed f64's
248/// exact integer range (2^53), causing rounding errors.
249#[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(); // always in (-1, 1), so frac * 1e14 < 1e14 < 2^53
256    let rounded_frac = (frac_part * ROUND_SCALE).round() / ROUND_SCALE;
257    int_part + rounded_frac
258}
259
260/// Rounds an f32 to 14 decimal places using pure arithmetic.
261#[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    // Promote to f64 for the rounding to avoid f32 precision loss
268    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/// Converts an `f64` to `i64`, returning `None` for non-finite or out-of-range
276/// values instead of silently saturating (the behaviour of an `as` cast).
277///
278/// Used by the date/time value arms so that an out-of-range SAS datetime
279/// surfaces as [`ReadStatError::DateOverflow`] rather than a clamped value.
280#[inline]
281#[allow(clippy::cast_possible_truncation)]
282fn checked_f64_to_i64(v: f64) -> Option<i64> {
283    // i64::MAX as f64 rounds up to 2^63, which is not representable as i64, so
284    // use a strict upper bound to keep the subsequent `as` cast exact.
285    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/// Converts an `f64` to `i32`, returning `None` for non-finite or out-of-range
293/// values instead of silently saturating.
294#[inline]
295#[allow(clippy::cast_possible_truncation)]
296fn checked_f64_to_i32(v: f64) -> Option<i32> {
297    // Unlike i64::MAX, i32::MAX (2^31 - 1) is exactly representable as f64, so an
298    // inclusive upper bound is correct here — a strict `<` would wrongly reject
299    // exactly i32::MAX. The `as` cast is exact across the full inclusive range.
300    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/// Converts a SAS datetime (seconds since 1960-01-01, possibly fractional) to
308/// a Unix-epoch timestamp at the given sub-second `scale` (1e3 for ms, 1e6 for
309/// µs, 1e9 for ns). Rounds rather than truncates: f64 representation error at
310/// SAS-datetime magnitudes (~1.9e9 s) is larger than one sub-second unit, so
311/// truncation would land one unit low about half the time.
312#[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/// Converts a SAS time (seconds since midnight, possibly fractional) to
319/// milliseconds, rounding rather than truncating.
320#[inline]
321fn sas_time_to_ms(val: f64) -> Option<i32> {
322    checked_f64_to_i32((val * 1_000.0).round())
323}
324
325/// Converts a SAS time (seconds since midnight, possibly fractional) to
326/// microseconds, rounding rather than truncating.
327#[inline]
328fn sas_time_to_us(val: f64) -> Option<i64> {
329    checked_f64_to_i64((val * 1_000_000.0).round())
330}
331
332/// Converts a SAS time (seconds since midnight, possibly fractional) to
333/// nanoseconds, rounding rather than truncating.
334#[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/// FFI callback that extracts a single cell value during row parsing.
340///
341/// Called for every cell in every row. Appends the value directly into the
342/// appropriate typed Arrow [`ColumnBuilder`] in [`ReadStatData::builders`],
343/// eliminating intermediate `String` allocations for string columns.
344/// Tracks row completion for progress reporting.
345///
346/// # Safety
347///
348/// - `variable` must be a valid pointer to a `readstat_variable_t` produced by the C parser.
349/// - `value` must be a valid `readstat_value_t` produced by the C parser.
350/// - `ctx` must be a valid pointer to a [`ReadStatData`] instance that outlives this call.
351/// - This function must only be called by the `ReadStat` C library as a registered callback.
352#[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    // dereference ctx pointer
423    let d = unsafe { &mut *ctx.cast::<ReadStatData>() };
424
425    // get index, type, and missingness
426    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    // Map the original variable index to its column in the (possibly filtered)
442    // builders. With a column filter active, an unselected variable maps to
443    // `None` and is skipped below.
444    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        // Unselected column: skip the value, but still advance the row counter
451        // when this is the row's final variable so row boundaries stay correct.
452        d.note_value(var_index);
453        return ReadStatHandler::READSTAT_HANDLER_OK as c_int;
454    };
455
456    // Records a builder/value mismatch and aborts parsing gracefully. A panic
457    // here would be an abort: this is an `extern "C"` callback, so unwinding
458    // is not an option. Reachable only if the file's data section disagrees
459    // with the metadata the builders were built from (e.g. the file changed on
460    // disk between the metadata and data parses).
461    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    // Append value directly into the typed Arrow builder
471    let Some(builder) = d.builders.get_mut(col_index as usize) else {
472        type_mismatch_abort!();
473    };
474
475    // Records a date/time conversion overflow and aborts parsing.
476    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                    // Fast path: valid UTF-8 (the common case for SAS data)
498                    if let Ok(s) = cstr.to_str() {
499                        sb.append_value(s);
500                    } else {
501                        // Lossy fallback for rare non-UTF-8 data
502                        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                // Schema maps Int8 → Int16, so widen
515                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    // Advance the row counter when this was the row's final variable.
686    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        // The combined C predicate checks tagged missing before consulting variable
717        // missing ranges, so null is valid for this focused bitfield test.
718        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        // --- round_decimal_f64 ---
735
736        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                // Use value equality, not bit equality: rounding tiny negatives
742                // to zero may flip -0.0 → 0.0 (both equal per IEEE 754).
743                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                // Rounding to 14 decimal places gives at most 0.5e-14 = 5e-15 error in
764                // exact arithmetic.  However, the final `int_part + rounded_frac` addition
765                // cannot be more precise than 1 ULP of the result.  For |v| in [32, 64) that
766                // ULP is 2^-47 ≈ 7.1e-15, which exceeds 5e-15 — so the bound must scale
767                // with the magnitude of v.
768                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        // --- round_decimal_f32 ---
781
782        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                // Use value equality, not bit equality: rounding tiny negatives
788                // to zero may flip -0.0 → 0.0 (both equal per IEEE 754).
789                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        // --- Epoch shift arithmetic ---
813
814        proptest! {
815            /// Any valid SAS date value (days since 1960-01-01) within the representable
816            /// i32 range should not overflow when shifted to Unix epoch.
817            #[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            /// Any valid SAS datetime value (seconds since 1960-01-01) within the
824            /// representable i64 range should not overflow when shifted to Unix epoch.
825            #[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            /// Round-trip: SAS days → Unix days → SAS days
832            #[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            /// Round-trip: SAS seconds → Unix seconds → SAS seconds
840            #[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    } // end property_tests
848
849    mod subsecond_conversion {
850        use super::*;
851
852        /// A modern SAS datetime with .123 fractional seconds. The nearest f64
853        /// to `…800.123` lies just below it, so truncation (the old behavior)
854        /// yielded `…122` ms; rounding must yield `…123`.
855        #[test]
856        fn datetime_ms_rounds_instead_of_truncates() {
857            // 2021-01-20 12:30:00.123 as a SAS datetime (seconds since 1960)
858            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            // At ~1.9e9 seconds adjacent f64 values are about 238 ns apart.
874            // Microseconds remain reliable, but arbitrary nanoseconds do not.
875            // The ns conversion must still round to the nearest representable
876            // value rather than truncate below it.
877            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            // 13:45:07.123456 as a SAS time (seconds since midnight)
889            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            // 13:45:07.123 as a SAS time (seconds since midnight)
897            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            // 13:45:07.123456789 as a SAS time (seconds since midnight). f64 holds
905            // substantially better than ns precision at this magnitude.
906            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        /// Sweep many fractional values: the converted ms component must match
924        /// the decimal fraction exactly for all of them (the old truncation
925        /// failed for roughly half).
926        #[test]
927        fn datetime_ms_exact_across_fractions() {
928            let base = 2_000_000_000_i64; // SAS seconds, year ~2023
929            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}