Skip to main content

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