Skip to main content

readstat/
common.rs

1//! Shared utility functions used across the crate.
2//!
3//! Provides helpers for computing streaming chunk offsets ([`build_offsets`]) and
4//! converting C string pointers to owned Rust strings ([`ptr_to_string`]).
5
6use std::ffi::CStr;
7
8/// Computes row offset boundaries for streaming chunk-based processing.
9///
10/// Given a total `row_count` and `stream_rows` (chunk size), returns a sorted
11/// vector of offsets for use with [`windows(2)`](slice::windows) to form
12/// `[start, end)` pairs. If `stream_rows` is 0, it is treated as 1.
13///
14/// # Example
15///
16/// ```
17/// # use readstat::build_offsets;
18/// let offsets = build_offsets(25, 10);
19/// assert_eq!(offsets, vec![0, 10, 20, 25]);
20/// // Produces pairs: [0,10), [10,20), [20,25)
21/// ```
22pub fn build_offsets(row_count: u32, stream_rows: u32) -> Vec<u32> {
23    let stream_rows = stream_rows.max(1);
24    let chunks = row_count.div_ceil(stream_rows);
25    let mut offsets = Vec::with_capacity(chunks as usize + 1);
26
27    for c in 0..chunks {
28        offsets.push(c * stream_rows);
29    }
30    offsets.push(row_count);
31
32    offsets
33}
34
35/// Converts a C string pointer to an owned Rust [`String`].
36///
37/// Returns an empty string if the pointer is null. Uses lossy UTF-8 conversion
38/// to handle non-UTF-8 data gracefully.
39pub(crate) fn ptr_to_string(x: *const std::os::raw::c_char) -> String {
40    if x.is_null() {
41        String::new()
42    } else {
43        // From Rust documentation - https://doc.rust-lang.org/std/ffi/struct.CStr.html
44        let cstr = unsafe { CStr::from_ptr(x) };
45        // Get copy-on-write Cow<'_, str>, then guarantee a freshly-owned String allocation
46        String::from_utf8_lossy(cstr.to_bytes()).to_string()
47    }
48}
49
50#[cfg(test)]
51mod tests {
52    use super::*;
53    use std::ffi::CString;
54
55    // --- build_offsets tests ---
56
57    #[test]
58    fn build_offsets_exact_division() {
59        let offsets = build_offsets(30, 10);
60        assert_eq!(offsets, vec![0, 10, 20, 30]);
61    }
62
63    #[test]
64    fn build_offsets_non_exact_division() {
65        let offsets = build_offsets(25, 10);
66        assert_eq!(offsets, vec![0, 10, 20, 25]);
67    }
68
69    #[test]
70    fn build_offsets_stream_exceeds_row_count() {
71        let offsets = build_offsets(5, 10);
72        assert_eq!(offsets, vec![0, 5]);
73    }
74
75    #[test]
76    fn build_offsets_single_row() {
77        let offsets = build_offsets(1, 10);
78        assert_eq!(offsets, vec![0, 1]);
79    }
80
81    #[test]
82    fn build_offsets_equal_stream_and_rows() {
83        let offsets = build_offsets(10, 10);
84        assert_eq!(offsets, vec![0, 10]);
85    }
86
87    #[test]
88    fn build_offsets_zero_rows() {
89        let offsets = build_offsets(0, 10);
90        assert_eq!(offsets, vec![0]);
91        // No windows produced for zero rows
92        assert_eq!(offsets.windows(2).count(), 0);
93    }
94
95    #[test]
96    fn build_offsets_zero_chunk_size_is_one() {
97        assert_eq!(build_offsets(4, 0), vec![0, 1, 2, 3, 4]);
98    }
99
100    #[test]
101    fn build_offsets_windows_produce_valid_pairs() {
102        let offsets = build_offsets(25, 10);
103        let pairs: Vec<_> = offsets.windows(2).map(|w| (w[0], w[1])).collect();
104        assert_eq!(pairs, vec![(0, 10), (10, 20), (20, 25)]);
105    }
106
107    #[test]
108    fn build_offsets_single_chunk_windows() {
109        let offsets = build_offsets(5, 10);
110        let pairs: Vec<_> = offsets.windows(2).map(|w| (w[0], w[1])).collect();
111        assert_eq!(pairs, vec![(0, 5)]);
112    }
113
114    #[test]
115    fn build_offsets_large_dataset() {
116        let offsets = build_offsets(100_000, 10_000);
117        assert_eq!(offsets.len(), 11);
118        assert_eq!(*offsets.first().unwrap(), 0);
119        assert_eq!(*offsets.last().unwrap(), 100_000);
120    }
121
122    // --- ptr_to_string tests ---
123
124    #[test]
125    fn ptr_to_string_null_returns_empty() {
126        let result = ptr_to_string(std::ptr::null());
127        assert_eq!(result, "");
128    }
129
130    #[test]
131    fn ptr_to_string_valid_cstring() {
132        let cs = CString::new("hello").unwrap();
133        let result = ptr_to_string(cs.as_ptr());
134        assert_eq!(result, "hello");
135    }
136
137    #[test]
138    fn ptr_to_string_empty_cstring() {
139        let cs = CString::new("").unwrap();
140        let result = ptr_to_string(cs.as_ptr());
141        assert_eq!(result, "");
142    }
143
144    #[test]
145    fn ptr_to_string_with_unicode() {
146        let cs = CString::new("UTF-8 encoded: café").unwrap();
147        let result = ptr_to_string(cs.as_ptr());
148        assert_eq!(result, "UTF-8 encoded: café");
149    }
150
151    #[test]
152    fn ptr_to_string_with_truncated_utf8() {
153        // Simulates SAS truncating "café" at a byte boundary mid-character.
154        // "café" in UTF-8 is [63, 61, 66, C3, A9]. Truncating after 4 bytes
155        // leaves [63, 61, 66, C3] — an incomplete 2-byte sequence.
156        // ptr_to_string should replace the dangling 0xC3 with U+FFFD.
157        // Safety: we need a null-terminated buffer for CStr::from_ptr.
158        // Build one explicitly so the test is self-contained.
159        let mut buf = b"caf\xC3".to_vec();
160        buf.push(0); // null terminator
161        let ptr = buf.as_ptr().cast::<std::os::raw::c_char>();
162
163        let result = ptr_to_string(ptr);
164        assert_eq!(result, "caf\u{FFFD}");
165    }
166
167    #[test]
168    fn ptr_to_string_with_invalid_continuation_byte() {
169        // 0xFF is never valid in UTF-8
170        let mut buf = b"hello\xFFworld".to_vec();
171        buf.push(0);
172        let ptr = buf.as_ptr().cast::<std::os::raw::c_char>();
173
174        let result = ptr_to_string(ptr);
175        assert_eq!(result, "hello\u{FFFD}world");
176    }
177
178    // --- Property-based tests ---
179
180    mod property_tests {
181        use super::*;
182        use proptest::prelude::*;
183
184        proptest! {
185            /// First offset is always 0; last offset is always row_count.
186            #[test]
187            fn offsets_start_at_zero_end_at_row_count(
188                row_count in 0u32..100_000,
189                stream_rows in 0u32..50_000
190            ) {
191                let offsets = build_offsets(row_count, stream_rows);
192                prop_assert_eq!(*offsets.first().unwrap(), 0);
193                prop_assert_eq!(*offsets.last().unwrap(), row_count);
194            }
195
196            /// Offsets are strictly monotonically increasing (no duplicates, no going backwards).
197            #[test]
198            fn offsets_are_monotonically_increasing(
199                row_count in 1u32..100_000,
200                stream_rows in 0u32..50_000
201            ) {
202                let offsets = build_offsets(row_count, stream_rows);
203                for pair in offsets.windows(2) {
204                    prop_assert!(pair[0] < pair[1], "offsets not strictly increasing: {} >= {}", pair[0], pair[1]);
205                    prop_assert!(pair[1] <= row_count);
206                }
207            }
208
209            /// Every chunk (window pair) has size <= stream_rows.
210            #[test]
211            fn chunk_sizes_bounded_by_stream_rows(
212                row_count in 1u32..100_000,
213                stream_rows in 1u32..50_000
214            ) {
215                let offsets = build_offsets(row_count, stream_rows);
216                for pair in offsets.windows(2) {
217                    let chunk_size = pair[1] - pair[0];
218                    prop_assert!(chunk_size <= stream_rows, "chunk {} > stream_rows {}", chunk_size, stream_rows);
219                }
220            }
221
222            /// The chunks cover all rows: sum of chunk sizes equals row_count.
223            #[test]
224            fn chunks_cover_all_rows(
225                row_count in 0u32..100_000,
226                stream_rows in 1u32..50_000
227            ) {
228                let offsets = build_offsets(row_count, stream_rows);
229                let total: u32 = offsets.windows(2).map(|w| w[1] - w[0]).sum();
230                prop_assert_eq!(total, row_count);
231            }
232
233            /// Zero stream_rows is handled without panic (treated as 1).
234            #[test]
235            fn zero_stream_rows_does_not_panic(row_count in 0u32..10_000) {
236                let offsets = build_offsets(row_count, 0);
237                prop_assert_eq!(*offsets.first().unwrap(), 0);
238                prop_assert_eq!(*offsets.last().unwrap(), row_count);
239            }
240        }
241    }
242}