Skip to main content

miniextendr_api/
strict.rs

1//! Strict conversion helpers for `#[miniextendr(strict)]`.
2//!
3//! These functions panic instead of silently widening when a value cannot be
4//! exactly represented as an R integer (`INTSXP`). This provides an opt-in
5//! alternative to the default `IntoR` behavior which silently falls back to
6//! `REALSXP` (f64) for out-of-range values.
7//!
8//! # Motivation
9//!
10//! R has no native 64-bit integer type. The default `i64::into_sexp()` picks
11//! `INTSXP` when the value fits and `REALSXP` otherwise — silently losing
12//! precision for values outside `[-2^53, 2^53]`. With `#[miniextendr(strict)]`,
13//! the macro generates calls to these helpers instead, which panic (→ R error)
14//! if the value doesn't fit in i32.
15//!
16//! # Paired with
17//!
18//! This is the **strict outbound** path. Its lax counterpart is the bare
19//! [`IntoR`] impls for `i64`/`u64`/`isize`/`usize` (see [`crate::into_r`]).
20//! Failure mode of staying on the lax path when you cared about exact
21//! representation: a counter / ID just above `i32::MAX` lands in R as
22//! `REALSXP`, and anything above `2^53` starts colliding silently.
23//!
24//! There is **no `TryFromSexpStrict` trait** — inbound is already
25//! strict-by-default because [`crate::from_r::TryFromSexp`] returns
26//! `Result<T, SexpError>`. The looser inbound path is
27//! [`crate::coerce::Coerce`] / [`crate::coerce::TryCoerce`].
28//!
29//! For storage-directed conversions (force `Vec<i64>` into `INTSXP` and error
30//! if any element doesn't fit) see [`crate::into_r_as::IntoRAs`] — it's a
31//! third path with value-based runtime checks.
32
33use crate::coerce::TryCoerce;
34use crate::from_r::{BatchedErrors, SexpError, TryFromSexp};
35use crate::into_r::IntoR;
36use crate::{SEXP, SEXPTYPE, SexpExt};
37
38/// Fold a batched strict-vec [`BatchedErrors`] into the single panic every
39/// `checked_vec_*_into_sexp` raises, under `container` (e.g. `"Vec<i64>"`).
40///
41/// Reuses [`BatchedErrors::into_error`]'s `"<container> conversion failed:
42/// invalid value at index <i>: ...; and N more"` grammar (#1192/#1097), but
43/// extracts the inner message directly rather than going through
44/// `SexpError`'s `Display` impl — that impl wraps every variant as `"invalid
45/// value: {msg}"`, which would read as a doubled "invalid value: `Vec<i64>`
46/// conversion failed: invalid value at index 0: ..." in a panic message.
47fn panic_strict_vec_batched(container: &str, errors: BatchedErrors) -> ! {
48    let SexpError::InvalidValue(msg) = errors.into_error(container) else {
49        unreachable!("BatchedErrors::into_error always returns SexpError::InvalidValue")
50    };
51    panic!(
52        "strict conversion failed: {msg}; use a non-strict function to allow lossy f64 widening"
53    );
54}
55
56/// Convert `i64` to R integer, panicking if outside i32 range.
57///
58/// The valid range is `(i32::MIN, i32::MAX]` — `i32::MIN` is excluded because
59/// it is `NA_integer_` in R.
60#[inline]
61pub fn checked_into_sexp_i64(val: i64) -> SEXP {
62    if val > i32::MIN as i64 && val <= i32::MAX as i64 {
63        (val as i32).into_sexp()
64    } else {
65        panic!(
66            "strict conversion failed: i64 value {} is outside R integer range \
67             ({}..={}); use a non-strict function to allow lossy f64 widening",
68            val,
69            i32::MIN as i64 + 1,
70            i32::MAX
71        );
72    }
73}
74
75/// Convert `u64` to R integer, panicking if > i32::MAX.
76#[inline]
77pub fn checked_into_sexp_u64(val: u64) -> SEXP {
78    if val <= i32::MAX as u64 {
79        (val as i32).into_sexp()
80    } else {
81        panic!(
82            "strict conversion failed: u64 value {} exceeds R integer max ({}); \
83             use a non-strict function to allow lossy f64 widening",
84            val,
85            i32::MAX
86        );
87    }
88}
89
90/// Convert `isize` to R integer, panicking if outside i32 range.
91#[inline]
92pub fn checked_into_sexp_isize(val: isize) -> SEXP {
93    checked_into_sexp_i64(val as i64)
94}
95
96/// Convert `usize` to R integer, panicking if > i32::MAX.
97#[inline]
98pub fn checked_into_sexp_usize(val: usize) -> SEXP {
99    checked_into_sexp_u64(val as u64)
100}
101
102/// Convert `Vec<i64>` to R integer vector, panicking if any element is outside i32 range.
103///
104/// Walks the whole vector, batching every out-of-range element into one
105/// panic instead of aborting at the first — see `panic_strict_vec_batched`.
106pub fn checked_vec_i64_into_sexp(val: Vec<i64>) -> SEXP {
107    let mut coerced: Vec<i32> = Vec::with_capacity(val.len());
108    let mut errors = BatchedErrors::default();
109    for (i, x) in val.into_iter().enumerate() {
110        if x > i32::MIN as i64 && x <= i32::MAX as i64 {
111            coerced.push(x as i32);
112        } else {
113            errors.push(|| {
114                format!(
115                    "invalid value at index {i}: i64 value {x} is outside R integer range \
116                     ({}..={})",
117                    i32::MIN as i64 + 1,
118                    i32::MAX
119                )
120            });
121        }
122    }
123    if !errors.is_empty() {
124        panic_strict_vec_batched("Vec<i64>", errors);
125    }
126    coerced.into_sexp()
127}
128
129/// Convert `Vec<u64>` to R integer vector, panicking if any element > i32::MAX.
130///
131/// Walks the whole vector, batching every out-of-range element into one
132/// panic instead of aborting at the first — see `panic_strict_vec_batched`.
133pub fn checked_vec_u64_into_sexp(val: Vec<u64>) -> SEXP {
134    let mut coerced: Vec<i32> = Vec::with_capacity(val.len());
135    let mut errors = BatchedErrors::default();
136    for (i, x) in val.into_iter().enumerate() {
137        if x <= i32::MAX as u64 {
138            coerced.push(x as i32);
139        } else {
140            errors.push(|| {
141                format!(
142                    "invalid value at index {i}: u64 value {x} exceeds R integer max ({})",
143                    i32::MAX
144                )
145            });
146        }
147    }
148    if !errors.is_empty() {
149        panic_strict_vec_batched("Vec<u64>", errors);
150    }
151    coerced.into_sexp()
152}
153
154/// Convert `Vec<isize>` to R integer vector, panicking if any element is outside i32 range.
155pub fn checked_vec_isize_into_sexp(val: Vec<isize>) -> SEXP {
156    checked_vec_i64_into_sexp(val.into_iter().map(|x| x as i64).collect())
157}
158
159/// Convert `Vec<usize>` to R integer vector, panicking if any element > i32::MAX.
160pub fn checked_vec_usize_into_sexp(val: Vec<usize>) -> SEXP {
161    checked_vec_u64_into_sexp(val.into_iter().map(|x| x as u64).collect())
162}
163
164/// Convert `Vec<Option<i64>>` to R integer vector in strict mode.
165///
166/// Panics if any `Some(x)` value is outside i32 range. `None` becomes
167/// `NA_INTEGER`. Walks the whole vector, batching every out-of-range `Some`
168/// into one panic instead of aborting at the first — see
169/// `panic_strict_vec_batched`.
170pub fn checked_vec_option_i64_into_sexp(val: Vec<Option<i64>>) -> SEXP {
171    let mut coerced: Vec<Option<i32>> = Vec::with_capacity(val.len());
172    let mut errors = BatchedErrors::default();
173    for (i, opt) in val.into_iter().enumerate() {
174        match opt {
175            Some(x) => {
176                if x > i32::MIN as i64 && x <= i32::MAX as i64 {
177                    coerced.push(Some(x as i32));
178                } else {
179                    errors.push(|| {
180                        format!(
181                            "invalid value at index {i}: i64 value {x} is outside R integer range \
182                             ({}..={})",
183                            i32::MIN as i64 + 1,
184                            i32::MAX
185                        )
186                    });
187                }
188            }
189            None => coerced.push(None),
190        }
191    }
192    if !errors.is_empty() {
193        panic_strict_vec_batched("Vec<Option<i64>>", errors);
194    }
195    coerced.into_sexp()
196}
197
198/// Convert `Vec<Option<u64>>` to R integer vector in strict mode.
199///
200/// Walks the whole vector, batching every out-of-range `Some` into one
201/// panic instead of aborting at the first — see `panic_strict_vec_batched`.
202pub fn checked_vec_option_u64_into_sexp(val: Vec<Option<u64>>) -> SEXP {
203    let mut coerced: Vec<Option<i32>> = Vec::with_capacity(val.len());
204    let mut errors = BatchedErrors::default();
205    for (i, opt) in val.into_iter().enumerate() {
206        match opt {
207            Some(x) => {
208                if x <= i32::MAX as u64 {
209                    coerced.push(Some(x as i32));
210                } else {
211                    errors.push(|| {
212                        format!(
213                            "invalid value at index {i}: u64 value {x} exceeds R integer max ({})",
214                            i32::MAX
215                        )
216                    });
217                }
218            }
219            None => coerced.push(None),
220        }
221    }
222    if !errors.is_empty() {
223        panic_strict_vec_batched("Vec<Option<u64>>", errors);
224    }
225    coerced.into_sexp()
226}
227
228/// Convert `Vec<Option<isize>>` to R integer vector in strict mode.
229pub fn checked_vec_option_isize_into_sexp(val: Vec<Option<isize>>) -> SEXP {
230    checked_vec_option_i64_into_sexp(val.into_iter().map(|opt| opt.map(|x| x as i64)).collect())
231}
232
233/// Convert `Vec<Option<usize>>` to R integer vector in strict mode.
234pub fn checked_vec_option_usize_into_sexp(val: Vec<Option<usize>>) -> SEXP {
235    checked_vec_option_u64_into_sexp(val.into_iter().map(|opt| opt.map(|x| x as u64)).collect())
236}
237
238/// Convert `Option<i64>` to R integer in strict mode.
239/// Panics if `Some(x)` is outside i32 range. `None` becomes `NA_integer_`.
240#[inline]
241pub fn checked_option_i64_into_sexp(val: Option<i64>) -> SEXP {
242    match val {
243        Some(x) => checked_into_sexp_i64(x),
244        None => Option::<i32>::None.into_sexp(),
245    }
246}
247
248/// Convert `Option<u64>` to R integer in strict mode.
249/// Panics if `Some(x)` exceeds i32::MAX. `None` becomes `NA_integer_`.
250#[inline]
251pub fn checked_option_u64_into_sexp(val: Option<u64>) -> SEXP {
252    match val {
253        Some(x) => checked_into_sexp_u64(x),
254        None => Option::<i32>::None.into_sexp(),
255    }
256}
257
258/// Convert `Option<isize>` to R integer in strict mode.
259#[inline]
260pub fn checked_option_isize_into_sexp(val: Option<isize>) -> SEXP {
261    checked_option_i64_into_sexp(val.map(|x| x as i64))
262}
263
264/// Convert `Option<usize>` to R integer in strict mode.
265#[inline]
266pub fn checked_option_usize_into_sexp(val: Option<usize>) -> SEXP {
267    checked_option_u64_into_sexp(val.map(|x| x as u64))
268}
269
270// region: Strict INPUT helpers — only accept INTSXP and REALSXP, reject RAWSXP/LGLSXP
271
272/// Convert R SEXP to `i64` in strict mode.
273///
274/// Only INTSXP and REALSXP are accepted. RAWSXP and LGLSXP are rejected.
275/// For REALSXP, uses `TryCoerce` to reject fractional, NaN, and out-of-range values.
276#[inline]
277pub fn checked_try_from_sexp_i64(sexp: SEXP, param: &str) -> i64 {
278    checked_try_from_sexp_numeric_scalar::<i64>(sexp, param)
279}
280
281/// Convert R SEXP to `u64` in strict mode.
282#[inline]
283pub fn checked_try_from_sexp_u64(sexp: SEXP, param: &str) -> u64 {
284    checked_try_from_sexp_numeric_scalar::<u64>(sexp, param)
285}
286
287/// Convert R SEXP to `isize` in strict mode.
288#[inline]
289pub fn checked_try_from_sexp_isize(sexp: SEXP, param: &str) -> isize {
290    let val = checked_try_from_sexp_i64(sexp, param);
291    isize::try_from(val).unwrap_or_else(|_| {
292        panic!(
293            "strict conversion failed for parameter '{}': i64 value {} does not fit in isize",
294            param, val
295        )
296    })
297}
298
299/// Convert R SEXP to `usize` in strict mode.
300#[inline]
301pub fn checked_try_from_sexp_usize(sexp: SEXP, param: &str) -> usize {
302    let val = checked_try_from_sexp_u64(sexp, param);
303    usize::try_from(val).unwrap_or_else(|_| {
304        panic!(
305            "strict conversion failed for parameter '{}': u64 value {} does not fit in usize",
306            param, val
307        )
308    })
309}
310
311/// Convert R SEXP to `Vec<i64>` in strict mode.
312pub fn checked_vec_try_from_sexp_i64(sexp: SEXP, param: &str) -> Vec<i64> {
313    checked_vec_try_from_sexp_numeric::<i64>(sexp, param)
314}
315
316/// Convert R SEXP to `Vec<u64>` in strict mode.
317pub fn checked_vec_try_from_sexp_u64(sexp: SEXP, param: &str) -> Vec<u64> {
318    checked_vec_try_from_sexp_numeric::<u64>(sexp, param)
319}
320
321/// Convert R SEXP to `Vec<isize>` in strict mode.
322pub fn checked_vec_try_from_sexp_isize(sexp: SEXP, param: &str) -> Vec<isize> {
323    checked_vec_try_from_sexp_i64(sexp, param)
324        .into_iter()
325        .map(|x| {
326            isize::try_from(x).unwrap_or_else(|_| {
327            panic!(
328                "strict conversion failed for parameter '{}': i64 value {} does not fit in isize",
329                param, x
330            )
331        })
332        })
333        .collect()
334}
335
336/// Convert R SEXP to `Vec<usize>` in strict mode.
337pub fn checked_vec_try_from_sexp_usize(sexp: SEXP, param: &str) -> Vec<usize> {
338    checked_vec_try_from_sexp_u64(sexp, param)
339        .into_iter()
340        .map(|x| {
341            usize::try_from(x).unwrap_or_else(|_| {
342            panic!(
343                "strict conversion failed for parameter '{}': u64 value {} does not fit in usize",
344                param, x
345            )
346        })
347        })
348        .collect()
349}
350
351/// Convert R SEXP to `Vec<Option<i64>>` in strict mode.
352///
353/// Applies the same input-SEXP-type gate as [`checked_vec_try_from_sexp_i64`]
354/// — only INTSXP and REALSXP are accepted; LGLSXP and RAWSXP are rejected.
355/// NA elements become `None`; type strictness and missingness are orthogonal.
356pub fn checked_vec_option_try_from_sexp_i64(sexp: SEXP, param: &str) -> Vec<Option<i64>> {
357    checked_vec_option_try_from_sexp_numeric::<i64>(sexp, param)
358}
359
360/// Convert R SEXP to `Vec<Option<u64>>` in strict mode.
361pub fn checked_vec_option_try_from_sexp_u64(sexp: SEXP, param: &str) -> Vec<Option<u64>> {
362    checked_vec_option_try_from_sexp_numeric::<u64>(sexp, param)
363}
364
365/// Convert R SEXP to `Vec<Option<isize>>` in strict mode.
366pub fn checked_vec_option_try_from_sexp_isize(sexp: SEXP, param: &str) -> Vec<Option<isize>> {
367    checked_vec_option_try_from_sexp_i64(sexp, param)
368        .into_iter()
369        .map(|opt| {
370            opt.map(|x| {
371                isize::try_from(x).unwrap_or_else(|_| {
372                    panic!(
373                        "strict conversion failed for parameter '{}': i64 value {} does not fit in isize",
374                        param, x
375                    )
376                })
377            })
378        })
379        .collect()
380}
381
382/// Convert R SEXP to `Vec<Option<usize>>` in strict mode.
383pub fn checked_vec_option_try_from_sexp_usize(sexp: SEXP, param: &str) -> Vec<Option<usize>> {
384    checked_vec_option_try_from_sexp_u64(sexp, param)
385        .into_iter()
386        .map(|opt| {
387            opt.map(|x| {
388                usize::try_from(x).unwrap_or_else(|_| {
389                    panic!(
390                        "strict conversion failed for parameter '{}': u64 value {} does not fit in usize",
391                        param, x
392                    )
393                })
394            })
395        })
396        .collect()
397}
398
399/// Generic strict scalar conversion: only INTSXP and REALSXP allowed.
400#[inline]
401fn checked_try_from_sexp_numeric_scalar<T>(sexp: SEXP, param: &str) -> T
402where
403    i32: TryCoerce<T>,
404    f64: TryCoerce<T>,
405    <i32 as TryCoerce<T>>::Error: std::fmt::Debug,
406    <f64 as TryCoerce<T>>::Error: std::fmt::Debug,
407{
408    let actual = sexp.type_of();
409    match actual {
410        SEXPTYPE::INTSXP => {
411            let value: i32 = TryFromSexp::try_from_sexp(sexp).unwrap_or_else(|e| {
412                panic!(
413                    "strict conversion failed for parameter '{}': {:?}",
414                    param, e
415                )
416            });
417            TryCoerce::<T>::try_coerce(value).unwrap_or_else(|e| {
418                panic!(
419                    "strict conversion failed for parameter '{}': {:?}",
420                    param, e
421                )
422            })
423        }
424        SEXPTYPE::REALSXP => {
425            let value: f64 = TryFromSexp::try_from_sexp(sexp).unwrap_or_else(|e| {
426                panic!(
427                    "strict conversion failed for parameter '{}': {:?}",
428                    param, e
429                )
430            });
431            TryCoerce::<T>::try_coerce(value).unwrap_or_else(|e| {
432                panic!(
433                    "strict conversion failed for parameter '{}': {:?}",
434                    param, e
435                )
436            })
437        }
438        _ => panic!(
439            "strict conversion failed for parameter '{}': expected integer or double, got {:?}",
440            param, actual
441        ),
442    }
443}
444
445/// Generic strict vector conversion: only INTSXP and REALSXP allowed.
446fn checked_vec_try_from_sexp_numeric<T>(sexp: SEXP, param: &str) -> Vec<T>
447where
448    i32: TryCoerce<T>,
449    f64: TryCoerce<T>,
450    <i32 as TryCoerce<T>>::Error: std::fmt::Debug,
451    <f64 as TryCoerce<T>>::Error: std::fmt::Debug,
452{
453    let actual = sexp.type_of();
454    match actual {
455        SEXPTYPE::INTSXP => {
456            let slice: &[i32] = unsafe { sexp.as_slice() };
457            slice
458                .iter()
459                .copied()
460                .map(|v| {
461                    TryCoerce::<T>::try_coerce(v).unwrap_or_else(|e| {
462                        panic!(
463                            "strict conversion failed for parameter '{}': {:?}",
464                            param, e
465                        )
466                    })
467                })
468                .collect()
469        }
470        SEXPTYPE::REALSXP => {
471            let slice: &[f64] = unsafe { sexp.as_slice() };
472            slice
473                .iter()
474                .copied()
475                .map(|v| {
476                    TryCoerce::<T>::try_coerce(v).unwrap_or_else(|e| {
477                        panic!(
478                            "strict conversion failed for parameter '{}': {:?}",
479                            param, e
480                        )
481                    })
482                })
483                .collect()
484        }
485        _ => panic!(
486            "strict conversion failed for parameter '{}': expected integer or double vector, got {:?}",
487            param, actual
488        ),
489    }
490}
491
492/// Generic strict `Vec<Option<T>>` conversion: only INTSXP and REALSXP allowed.
493///
494/// Mirrors [`checked_vec_try_from_sexp_numeric`] but maps R's NA sentinel
495/// (`NA_INTEGER` for INTSXP, `NA_REAL` for REALSXP) to `None` instead of
496/// erroring — missingness is orthogonal to the input-type gate.
497fn checked_vec_option_try_from_sexp_numeric<T>(sexp: SEXP, param: &str) -> Vec<Option<T>>
498where
499    i32: TryCoerce<T>,
500    f64: TryCoerce<T>,
501    <i32 as TryCoerce<T>>::Error: std::fmt::Debug,
502    <f64 as TryCoerce<T>>::Error: std::fmt::Debug,
503{
504    let actual = sexp.type_of();
505    match actual {
506        SEXPTYPE::INTSXP => {
507            let slice: &[i32] = unsafe { sexp.as_slice() };
508            slice
509                .iter()
510                .copied()
511                .map(|v| {
512                    if v == crate::altrep_traits::NA_INTEGER {
513                        None
514                    } else {
515                        Some(TryCoerce::<T>::try_coerce(v).unwrap_or_else(|e| {
516                            panic!(
517                                "strict conversion failed for parameter '{}': {:?}",
518                                param, e
519                            )
520                        }))
521                    }
522                })
523                .collect()
524        }
525        SEXPTYPE::REALSXP => {
526            let slice: &[f64] = unsafe { sexp.as_slice() };
527            slice
528                .iter()
529                .copied()
530                .map(|v| {
531                    if crate::from_r::is_na_real(v) {
532                        None
533                    } else {
534                        Some(TryCoerce::<T>::try_coerce(v).unwrap_or_else(|e| {
535                            panic!(
536                                "strict conversion failed for parameter '{}': {:?}",
537                                param, e
538                            )
539                        }))
540                    }
541                })
542                .collect()
543        }
544        _ => panic!(
545            "strict conversion failed for parameter '{}': expected integer or double vector, got {:?}",
546            param, actual
547        ),
548    }
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554
555    #[test]
556    fn i64_in_range_succeeds() {
557        // These should not panic (we can't check SEXP in unit tests without R,
558        // but we can verify no panic occurs)
559        let _ = std::panic::catch_unwind(|| checked_into_sexp_i64(0));
560        let _ = std::panic::catch_unwind(|| checked_into_sexp_i64(42));
561        let _ = std::panic::catch_unwind(|| checked_into_sexp_i64(-1));
562        let _ = std::panic::catch_unwind(|| checked_into_sexp_i64(i32::MAX as i64));
563    }
564
565    #[test]
566    fn i64_out_of_range_panics() {
567        let result = std::panic::catch_unwind(|| checked_into_sexp_i64(i64::MAX));
568        assert!(result.is_err(), "should panic for i64::MAX");
569
570        let result = std::panic::catch_unwind(|| checked_into_sexp_i64(i32::MIN as i64));
571        assert!(result.is_err(), "should panic for i32::MIN (NA_integer_)");
572
573        let result = std::panic::catch_unwind(|| checked_into_sexp_i64(i32::MAX as i64 + 1));
574        assert!(result.is_err(), "should panic for i32::MAX + 1");
575    }
576
577    #[test]
578    fn u64_in_range_succeeds() {
579        let _ = std::panic::catch_unwind(|| checked_into_sexp_u64(0));
580        let _ = std::panic::catch_unwind(|| checked_into_sexp_u64(i32::MAX as u64));
581    }
582
583    #[test]
584    fn u64_out_of_range_panics() {
585        let result = std::panic::catch_unwind(|| checked_into_sexp_u64(i32::MAX as u64 + 1));
586        assert!(result.is_err());
587    }
588
589    /// Downcast a `panic!` payload (produced via format args, so always a
590    /// `String`) into an owned `String` for message assertions.
591    fn panic_message(payload: Box<dyn std::any::Any + Send>) -> String {
592        *payload
593            .downcast::<String>()
594            .expect("panic payload should be a String")
595    }
596
597    #[test]
598    fn vec_i64_batches_multiple_out_of_range_indices() {
599        let result =
600            std::panic::catch_unwind(|| checked_vec_i64_into_sexp(vec![1, i64::MAX, 2, i64::MIN]));
601        let msg = panic_message(result.expect_err("should panic for out-of-range elements"));
602        assert!(
603            msg.starts_with("strict conversion failed: Vec<i64> conversion failed:"),
604            "{msg}"
605        );
606        assert!(msg.contains("invalid value at index 1: i64 value"), "{msg}");
607        assert!(msg.contains("invalid value at index 3: i64 value"), "{msg}");
608        assert!(
609            !msg.contains("and "),
610            "should not summarize under the cap: {msg}"
611        );
612        assert!(
613            msg.ends_with("use a non-strict function to allow lossy f64 widening"),
614            "{msg}"
615        );
616    }
617
618    #[test]
619    fn vec_i64_batches_caps_and_summarizes_remainder() {
620        let vals: Vec<i64> = std::iter::repeat_n(i64::MAX, 15).collect();
621        let result = std::panic::catch_unwind(|| checked_vec_i64_into_sexp(vals));
622        let msg = panic_message(result.expect_err("should panic for out-of-range elements"));
623        assert!(msg.contains("and 5 more"), "{msg}");
624    }
625
626    #[test]
627    fn vec_u64_batches_multiple_out_of_range_indices() {
628        let bad = i32::MAX as u64 + 1;
629        let result = std::panic::catch_unwind(|| checked_vec_u64_into_sexp(vec![0, bad, 1, bad]));
630        let msg = panic_message(result.expect_err("should panic for out-of-range elements"));
631        assert!(
632            msg.starts_with("strict conversion failed: Vec<u64> conversion failed:"),
633            "{msg}"
634        );
635        assert!(msg.contains("invalid value at index 1: u64 value"), "{msg}");
636        assert!(msg.contains("invalid value at index 3: u64 value"), "{msg}");
637    }
638
639    #[test]
640    fn vec_option_i64_batches_multiple_out_of_range_indices() {
641        let result = std::panic::catch_unwind(|| {
642            checked_vec_option_i64_into_sexp(vec![Some(1), Some(i64::MAX), None, Some(i64::MIN)])
643        });
644        let msg = panic_message(result.expect_err("should panic for out-of-range elements"));
645        assert!(
646            msg.starts_with("strict conversion failed: Vec<Option<i64>> conversion failed:"),
647            "{msg}"
648        );
649        assert!(msg.contains("invalid value at index 1: i64 value"), "{msg}");
650        assert!(msg.contains("invalid value at index 3: i64 value"), "{msg}");
651    }
652
653    #[test]
654    fn vec_option_u64_batches_multiple_out_of_range_indices() {
655        let bad = i32::MAX as u64 + 1;
656        let result = std::panic::catch_unwind(|| {
657            checked_vec_option_u64_into_sexp(vec![Some(0), Some(bad), None, Some(bad)])
658        });
659        let msg = panic_message(result.expect_err("should panic for out-of-range elements"));
660        assert!(
661            msg.starts_with("strict conversion failed: Vec<Option<u64>> conversion failed:"),
662            "{msg}"
663        );
664        assert!(msg.contains("invalid value at index 1: u64 value"), "{msg}");
665        assert!(msg.contains("invalid value at index 3: u64 value"), "{msg}");
666    }
667}
668// endregion