aster_forge_utils/
numbers.rs

1//! Checked numeric conversion helpers.
2//!
3//! Service code often crosses database, filesystem, and API boundaries that use different integer
4//! widths and signedness. These helpers make overflow and sign-loss checks explicit while producing
5//! consistent error messages for callers.
6
7use std::num::{NonZeroU32, NonZeroU64};
8
9use crate::{Result, UtilsError};
10
11/// Converts `0` to [`NonZeroU32::MIN`] and preserves non-zero values.
12///
13/// This helper is for APIs that require a non-zero numeric parameter but where product policy has
14/// already decided that an input of zero means "use the smallest legal value".
15#[must_use]
16pub fn non_zero_u32(value: u32) -> NonZeroU32 {
17    NonZeroU32::new(value).unwrap_or(NonZeroU32::MIN)
18}
19
20/// Converts `0` to [`NonZeroU64::MIN`] and preserves non-zero values.
21///
22/// This is the `u64` companion to [`non_zero_u32`].
23#[must_use]
24pub fn non_zero_u64(value: u64) -> NonZeroU64 {
25    NonZeroU64::new(value).unwrap_or(NonZeroU64::MIN)
26}
27
28/// Converts a signed byte count to `usize`.
29///
30/// # Errors
31///
32/// Returns an error when `bytes` is negative or does not fit in `usize` on the current platform.
33pub fn bytes_to_usize(bytes: i64, value_name: &str) -> Result<usize> {
34    i64_to_usize(bytes, value_name)
35}
36
37/// Converts `i32` to `usize`.
38///
39/// # Errors
40///
41/// Returns an error when `value` is negative or does not fit in `usize` on the current platform.
42pub fn i32_to_usize(value: i32, value_name: &str) -> Result<usize> {
43    usize::try_from(value).map_err(|_| {
44        UtilsError::numeric_conversion(format!("{value_name} cannot be negative: {value}"))
45    })
46}
47
48/// Converts `i64` to `i32`.
49///
50/// # Errors
51///
52/// Returns an error when `value` is outside the `i32` range.
53pub fn i64_to_i32(value: i64, value_name: &str) -> Result<i32> {
54    i32::try_from(value).map_err(|_| {
55        UtilsError::numeric_conversion(format!("{value_name} is outside i32 range: {value}"))
56    })
57}
58
59/// Converts `i64` to `usize`.
60///
61/// # Errors
62///
63/// Returns an error when `value` is negative or does not fit in `usize` on the current platform.
64pub fn i64_to_usize(value: i64, value_name: &str) -> Result<usize> {
65    usize::try_from(value).map_err(|_| {
66        UtilsError::numeric_conversion(format!(
67            "{value_name} exceeds platform usize range or is negative: {value}"
68        ))
69    })
70}
71
72/// Converts `i64` to `u64`.
73///
74/// # Errors
75///
76/// Returns an error when `value` is negative.
77pub fn i64_to_u64(value: i64, value_name: &str) -> Result<u64> {
78    u64::try_from(value).map_err(|_| {
79        UtilsError::numeric_conversion(format!("{value_name} cannot be negative: {value}"))
80    })
81}
82
83/// Converts `u128` to `u64`.
84///
85/// # Errors
86///
87/// Returns an error when `value` exceeds [`u64::MAX`].
88pub fn u128_to_u64(value: u128, value_name: &str) -> Result<u64> {
89    u64::try_from(value).map_err(|_| {
90        UtilsError::numeric_conversion(format!("{value_name} exceeds u64 range: {value}"))
91    })
92}
93
94/// Converts `u128` to `u64`, saturating values above `u64::MAX`.
95#[must_use]
96pub fn u128_to_u64_saturating(value: u128) -> u64 {
97    u64::try_from(value).unwrap_or(u64::MAX)
98}
99
100/// Converts seconds represented as `f64` to rounded milliseconds.
101///
102/// # Errors
103///
104/// Returns an error when `seconds` is non-finite, negative, outside [`std::time::Duration`]'s
105/// range, cannot be rounded safely, or the rounded millisecond count exceeds [`u64::MAX`].
106pub fn f64_seconds_to_u64_millis(seconds: f64, value_name: &str) -> Result<u64> {
107    if !seconds.is_finite() {
108        return Err(UtilsError::invalid_value(format!(
109            "{value_name} must be finite: {seconds}"
110        )));
111    }
112    if seconds < 0.0 {
113        return Err(UtilsError::invalid_value(format!(
114            "{value_name} cannot be negative: {seconds}"
115        )));
116    }
117
118    let duration = std::time::Duration::try_from_secs_f64(seconds).map_err(|_| {
119        UtilsError::invalid_value(format!("{value_name} exceeds duration range: {seconds}"))
120    })?;
121    let rounded_duration = duration
122        .checked_add(std::time::Duration::from_micros(500))
123        .ok_or_else(|| {
124            UtilsError::invalid_value(format!("{value_name} exceeds duration range: {seconds}"))
125        })?;
126
127    u128_to_u64(rounded_duration.as_millis(), value_name)
128}
129
130/// Converts `u32` to `usize`.
131///
132/// # Errors
133///
134/// Returns an error when `value` does not fit in `usize` on the current platform.
135pub fn u32_to_usize(value: u32, value_name: &str) -> Result<usize> {
136    usize::try_from(value).map_err(|_| {
137        UtilsError::numeric_conversion(format!(
138            "{value_name} exceeds platform usize range: {value}"
139        ))
140    })
141}
142
143/// Converts `u32` to `i64`.
144///
145/// This conversion is infallible because every `u32` value fits into `i64`.
146#[must_use]
147pub fn u32_to_i64(value: u32) -> i64 {
148    i64::from(value)
149}
150
151/// Converts `u32` to `i32`.
152///
153/// # Errors
154///
155/// Returns an error when `value` exceeds [`i32::MAX`].
156pub fn u32_to_i32(value: u32, value_name: &str) -> Result<i32> {
157    i32::try_from(value).map_err(|_| {
158        UtilsError::numeric_conversion(format!("{value_name} exceeds i32 range: {value}"))
159    })
160}
161
162/// Converts `u64` to `i64`.
163///
164/// # Errors
165///
166/// Returns an error when `value` exceeds [`i64::MAX`].
167pub fn u64_to_i64(value: u64, value_name: &str) -> Result<i64> {
168    i64::try_from(value).map_err(|_| {
169        UtilsError::numeric_conversion(format!("{value_name} exceeds i64 range: {value}"))
170    })
171}
172
173/// Converts `u64` to `usize`.
174///
175/// # Errors
176///
177/// Returns an error when `value` does not fit in `usize` on the current platform.
178pub fn u64_to_usize(value: u64, value_name: &str) -> Result<usize> {
179    usize::try_from(value).map_err(|_| {
180        UtilsError::numeric_conversion(format!(
181            "{value_name} exceeds platform usize range: {value}"
182        ))
183    })
184}
185
186/// Converts `usize` to `i32`.
187///
188/// # Errors
189///
190/// Returns an error when `value` exceeds [`i32::MAX`].
191pub fn usize_to_i32(value: usize, value_name: &str) -> Result<i32> {
192    i32::try_from(value).map_err(|_| {
193        UtilsError::numeric_conversion(format!("{value_name} exceeds i32 range: {value}"))
194    })
195}
196
197/// Converts `usize` values such as `Vec::len()` or byte-slice lengths to `i64`.
198///
199/// This is infallible only on 32-bit platforms, but the fallible signature keeps call sites
200/// consistent with the other checked conversions.
201///
202/// # Errors
203///
204/// Returns an error when `value` exceeds [`i64::MAX`].
205pub fn usize_to_i64(value: usize, value_name: &str) -> Result<i64> {
206    i64::try_from(value).map_err(|_| {
207        UtilsError::numeric_conversion(format!("{value_name} exceeds i64 range: {value}"))
208    })
209}
210
211/// Converts `usize` to `u32`.
212///
213/// # Errors
214///
215/// Returns an error when `value` exceeds [`u32::MAX`].
216pub fn usize_to_u32(value: usize, value_name: &str) -> Result<u32> {
217    u32::try_from(value).map_err(|_| {
218        UtilsError::numeric_conversion(format!("{value_name} exceeds u32 range: {value}"))
219    })
220}
221
222/// Converts `usize` to `u64`.
223///
224/// # Errors
225///
226/// Returns an error when `value` does not fit in `u64` on the current platform.
227pub fn usize_to_u64(value: usize, value_name: &str) -> Result<u64> {
228    u64::try_from(value).map_err(|_| {
229        UtilsError::numeric_conversion(format!("{value_name} exceeds u64 range: {value}"))
230    })
231}
232
233/// Calculates the number of chunks needed to cover `total_size`.
234///
235/// # Errors
236///
237/// Returns an error when `total_size` is negative, `chunk_size` is not positive, the rounding
238/// addition overflows, or the resulting chunk count does not fit in `i32`.
239pub fn calc_total_chunks(total_size: i64, chunk_size: i64, context: &str) -> Result<i32> {
240    if total_size < 0 {
241        return Err(UtilsError::invalid_value(format!(
242            "{context} total_size cannot be negative: {total_size}"
243        )));
244    }
245    if chunk_size <= 0 {
246        return Err(UtilsError::invalid_value(format!(
247            "{context} chunk_size must be positive, got {chunk_size}"
248        )));
249    }
250
251    let adjusted = total_size.checked_add(chunk_size - 1).ok_or_else(|| {
252        UtilsError::invalid_value(format!("{context} total_size is too large: {total_size}"))
253    })?;
254    let chunks = adjusted / chunk_size;
255
256    i32::try_from(chunks).map_err(|_| {
257        UtilsError::invalid_value(format!("{context} requires too many chunks: {chunks}"))
258    })
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn bytes_to_usize_accepts_positive_values() {
267        assert_eq!(bytes_to_usize(5_242_880, "chunk_size").unwrap(), 5_242_880);
268    }
269
270    #[test]
271    fn non_zero_helpers_preserve_positive_values() {
272        assert_eq!(non_zero_u32(7).get(), 7);
273        assert_eq!(non_zero_u64(9).get(), 9);
274    }
275
276    #[test]
277    fn non_zero_helpers_fallback_to_min_for_zero() {
278        assert_eq!(non_zero_u32(0), NonZeroU32::MIN);
279        assert_eq!(non_zero_u64(0), NonZeroU64::MIN);
280    }
281
282    #[test]
283    fn bytes_to_usize_rejects_negative_values() {
284        let err = bytes_to_usize(-1, "chunk_size").unwrap_err();
285        assert!(matches!(err, UtilsError::NumericConversion(_)));
286    }
287
288    #[test]
289    fn i32_to_usize_rejects_negative_values() {
290        let err = i32_to_usize(-1, "total_chunks").unwrap_err();
291        assert!(matches!(err, UtilsError::NumericConversion(_)));
292    }
293
294    #[test]
295    fn i64_to_i32_accepts_bounds_and_rejects_overflow() {
296        assert_eq!(i64_to_i32(i64::from(i32::MIN), "value").unwrap(), i32::MIN);
297        assert_eq!(i64_to_i32(i64::from(i32::MAX), "value").unwrap(), i32::MAX);
298
299        let positive = i64_to_i32(i64::from(i32::MAX) + 1, "value").unwrap_err();
300        assert!(matches!(positive, UtilsError::NumericConversion(_)));
301
302        let negative = i64_to_i32(i64::from(i32::MIN) - 1, "value").unwrap_err();
303        assert!(matches!(negative, UtilsError::NumericConversion(_)));
304    }
305
306    #[test]
307    fn i64_to_usize_accepts_zero_and_rejects_negative_values() {
308        assert_eq!(i64_to_usize(0, "offset").unwrap(), 0);
309        assert_eq!(i64_to_usize(42, "offset").unwrap(), 42);
310
311        let err = i64_to_usize(-1, "offset").unwrap_err();
312        assert!(matches!(err, UtilsError::NumericConversion(_)));
313    }
314
315    #[test]
316    fn i64_to_u64_accepts_positive_values() {
317        assert_eq!(i64_to_u64(42, "content_length").unwrap(), 42);
318    }
319
320    #[test]
321    fn i64_to_u64_rejects_negative_values() {
322        let err = i64_to_u64(-1, "content_length").unwrap_err();
323        assert!(matches!(err, UtilsError::NumericConversion(_)));
324    }
325
326    #[test]
327    fn u128_to_u64_accepts_bounds_and_rejects_overflow() {
328        assert_eq!(u128_to_u64(0, "size").unwrap(), 0);
329        assert_eq!(u128_to_u64(u128::from(u64::MAX), "size").unwrap(), u64::MAX);
330
331        let err = u128_to_u64(u128::from(u64::MAX) + 1, "size").unwrap_err();
332        assert!(matches!(err, UtilsError::NumericConversion(_)));
333    }
334
335    #[test]
336    fn u128_to_u64_saturating_clamps_overflow() {
337        assert_eq!(u128_to_u64_saturating(0), 0);
338        assert_eq!(u128_to_u64_saturating(u128::from(u64::MAX)), u64::MAX);
339        assert_eq!(u128_to_u64_saturating(u128::from(u64::MAX) + 1), u64::MAX);
340    }
341
342    #[test]
343    fn f64_seconds_to_u64_millis_rounds_to_nearest_millisecond() {
344        assert_eq!(f64_seconds_to_u64_millis(1.2344, "duration").unwrap(), 1234);
345        assert_eq!(f64_seconds_to_u64_millis(1.2345, "duration").unwrap(), 1235);
346        assert_eq!(f64_seconds_to_u64_millis(0.0004, "duration").unwrap(), 0);
347        assert_eq!(f64_seconds_to_u64_millis(0.0005, "duration").unwrap(), 1);
348    }
349
350    #[test]
351    fn f64_seconds_to_u64_millis_accepts_zero() {
352        assert_eq!(f64_seconds_to_u64_millis(0.0, "duration").unwrap(), 0);
353    }
354
355    #[test]
356    fn f64_seconds_to_u64_millis_rejects_invalid_values() {
357        let negative = f64_seconds_to_u64_millis(-1.0, "duration").unwrap_err();
358        assert!(matches!(negative, UtilsError::InvalidValue(_)));
359
360        let nan = f64_seconds_to_u64_millis(f64::NAN, "duration").unwrap_err();
361        assert!(matches!(nan, UtilsError::InvalidValue(_)));
362
363        let infinity = f64_seconds_to_u64_millis(f64::INFINITY, "duration").unwrap_err();
364        assert!(matches!(infinity, UtilsError::InvalidValue(_)));
365    }
366
367    #[test]
368    fn f64_seconds_to_u64_millis_rejects_u64_millis_overflow() {
369        let overflow_seconds = "18446744073709552".parse::<f64>().unwrap();
370        let err = f64_seconds_to_u64_millis(overflow_seconds, "duration").unwrap_err();
371        assert!(matches!(err, UtilsError::NumericConversion(_)));
372    }
373
374    #[test]
375    fn u32_conversions_are_lossless_on_supported_targets() {
376        assert_eq!(u32_to_i32(0, "value").unwrap(), 0);
377        assert_eq!(u32_to_i32(i32::MAX as u32, "value").unwrap(), i32::MAX);
378        assert_eq!(u32_to_i64(u32::MAX), i64::from(u32::MAX));
379        assert_eq!(u32_to_usize(0, "value").unwrap(), 0);
380
381        #[cfg(any(target_pointer_width = "32", target_pointer_width = "64"))]
382        assert_eq!(u32_to_usize(u32::MAX, "value").unwrap(), u32::MAX as usize);
383    }
384
385    #[test]
386    fn u32_to_i32_rejects_overflow() {
387        let overflow = u32::try_from(i32::MAX)
388            .unwrap_or(u32::MAX)
389            .saturating_add(1);
390        let err = u32_to_i32(overflow, "value").unwrap_err();
391        assert!(matches!(err, UtilsError::NumericConversion(_)));
392    }
393
394    #[test]
395    fn usize_to_i32_rejects_overflow() {
396        let overflow = usize::try_from(i32::MAX)
397            .unwrap_or(usize::MAX)
398            .saturating_add(1);
399        let err = usize_to_i32(overflow, "uploaded_part_count").unwrap_err();
400        assert!(matches!(err, UtilsError::NumericConversion(_)));
401    }
402
403    #[test]
404    fn usize_to_u32_accepts_bounds_and_rejects_overflow() {
405        assert_eq!(usize_to_u32(0, "part_count").unwrap(), 0);
406
407        let max = usize::try_from(u32::MAX).unwrap_or(usize::MAX);
408        assert_eq!(usize_to_u32(max, "part_count").unwrap(), u32::MAX);
409
410        if let Some(overflow) = max.checked_add(1) {
411            let err = usize_to_u32(overflow, "part_count").unwrap_err();
412            assert!(matches!(err, UtilsError::NumericConversion(_)));
413        }
414    }
415
416    #[test]
417    fn usize_to_i64_accepts_small_values() {
418        assert_eq!(usize_to_i64(1024, "body_len").unwrap(), 1024);
419    }
420
421    #[test]
422    fn usize_to_u64_accepts_common_values() {
423        assert_eq!(usize_to_u64(0, "test").unwrap(), 0);
424        #[cfg(target_pointer_width = "64")]
425        assert_eq!(usize_to_u64(usize::MAX, "test").unwrap(), u64::MAX);
426    }
427
428    #[test]
429    fn u64_to_i64_accepts_within_i64_range() {
430        assert_eq!(u64_to_i64(0, "test").unwrap(), 0);
431        let max_i64_as_u64 = u64::try_from(i64::MAX).unwrap_or(u64::MAX);
432        assert_eq!(u64_to_i64(max_i64_as_u64, "test").unwrap(), i64::MAX);
433    }
434
435    #[test]
436    fn u64_to_i64_rejects_overflow() {
437        let overflow = u64::try_from(i64::MAX)
438            .unwrap_or(u64::MAX)
439            .saturating_add(1);
440        let err = u64_to_i64(overflow, "test").unwrap_err();
441        assert!(matches!(err, UtilsError::NumericConversion(_)));
442    }
443
444    #[test]
445    fn u64_to_usize_accepts_within_platform_range() {
446        assert_eq!(u64_to_usize(0, "test").unwrap(), 0);
447        #[cfg(target_pointer_width = "64")]
448        assert_eq!(u64_to_usize(u64::MAX, "test").unwrap(), usize::MAX);
449        // on 32-bit this would reject overflow
450    }
451
452    #[test]
453    #[cfg(target_pointer_width = "32")]
454    fn u64_to_usize_rejects_overflow() {
455        // u64::MAX won't fit in usize on 32-bit targets
456        let err = u64_to_usize(u64::MAX, "cursor_value").unwrap_err();
457        assert!(matches!(err, UtilsError::NumericConversion(_)));
458    }
459
460    #[test]
461    fn calc_total_chunks_rounds_up() {
462        assert_eq!(
463            calc_total_chunks(10_485_761, 5_242_880, "multipart upload").unwrap(),
464            3
465        );
466    }
467
468    #[test]
469    fn calc_total_chunks_handles_exact_division() {
470        assert_eq!(
471            calc_total_chunks(10_485_760, 5_242_880, "multipart upload").unwrap(),
472            2
473        );
474    }
475
476    #[test]
477    fn calc_total_chunks_allows_zero_size() {
478        assert_eq!(calc_total_chunks(0, 5, "multipart upload").unwrap(), 0);
479    }
480
481    #[test]
482    fn calc_total_chunks_rejects_negative_total_size() {
483        let err = calc_total_chunks(-1, 5, "multipart upload").unwrap_err();
484        assert!(matches!(err, UtilsError::InvalidValue(_)));
485    }
486
487    #[test]
488    fn calc_total_chunks_rejects_non_positive_chunk_size() {
489        let err = calc_total_chunks(10, 0, "multipart upload").unwrap_err();
490        assert!(matches!(err, UtilsError::InvalidValue(_)));
491    }
492
493    #[test]
494    fn calc_total_chunks_rejects_i32_overflow() {
495        let overflow_total_size = (i64::from(i32::MAX) + 1) * 5;
496        let err = calc_total_chunks(overflow_total_size, 1, "multipart upload").unwrap_err();
497        assert!(matches!(err, UtilsError::InvalidValue(_)));
498    }
499}